devtools681420 commited on
Commit
eeeab18
·
1 Parent(s): 79d1357

feat: Add new dashboard pages for full, receipt, actual, and expedition data, incorporating session management, a loading UI, and data processing.

Browse files
Files changed (5) hide show
  1. app.py +100 -12
  2. pages/atual.py +217 -183
  3. pages/exp.py +580 -495
  4. pages/full.py +524 -474
  5. pages/rec.py +484 -603
app.py CHANGED
@@ -7,7 +7,6 @@ from streamlit.components.v1 import html as _html
7
  from dotenv import load_dotenv
8
  load_dotenv()
9
 
10
- # ← substitui GSheetsConnection
11
  from gs_client import read_ws, write_ws
12
 
13
  st.set_page_config(
@@ -17,8 +16,15 @@ st.set_page_config(
17
  initial_sidebar_state="collapsed"
18
  )
19
 
 
 
 
 
 
 
 
20
  # ── COOKIE SESSION ────────────────────────────────────────────────────
21
- _cc = CookieController()
22
  COOKIE_NAME = "pmja_session"
23
  SESSION_EXPIRY_HOURS = 8
24
 
@@ -103,7 +109,7 @@ COLS = [
103
  "last_login", "email_verified", "verification_code", "code_expiry", "image_url",
104
  ]
105
 
106
- # ── DB (agora usa gs_client) ──────────────────────────────────────────
107
  def get_users() -> pd.DataFrame:
108
  df = read_ws("users_auth", spreadsheet="main")
109
  if df.empty: return pd.DataFrame(columns=COLS)
@@ -182,11 +188,12 @@ def verify_email_code(username, code):
182
  time.sleep(1)
183
  return True, "Email verificado!"
184
 
185
- # ── session state ─────────────────────────────────────────────────────
186
  for k, v in [
187
  ("logged_in", False), ("user_data", None), ("page", "login"),
188
  ("temp_username", None), ("msg", ""), ("msg_type", ""),
189
  ("_action", None), ("session_uid", None), ("session_usr", None), ("session_exp", None),
 
190
  ]:
191
  if k not in st.session_state:
192
  st.session_state[k] = v
@@ -214,6 +221,71 @@ elif action == "extend":
214
  save_session(user["id"], user["username"], SESSION_EXPIRY_HOURS)
215
  st.session_state.msg = "Sessão estendida por 2 horas"; st.session_state.msg_type = "success"
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ── CSS ───────────────────────────────────────────────────────────────
218
  _CSS = """\
219
  <style>
@@ -264,7 +336,7 @@ div[data-testid="stAlert"]{font-size:13px!important;border-radius:12px!important
264
  """
265
  _html(_CSS, height=0)
266
 
267
- # ── page state ────────────────────────────────────────────────────────
268
  pg = "dashboard" if st.session_state.logged_in else st.session_state.page
269
  user = st.session_state.user_data
270
 
@@ -328,22 +400,39 @@ with card:
328
  st.text_input("Usuário", placeholder="nome_usuario", key="li_u")
329
  st.text_input("Senha", placeholder="••••••••", type="password", key="li_p")
330
  sub = st.form_submit_button("Entrar →", type="primary", use_container_width=True)
 
331
  if sub:
332
  u = st.session_state.li_u.strip(); p = st.session_state.li_p
333
  if not (u and p):
334
- st.session_state.msg = "Preencha todos os campos"; st.session_state.msg_type = "error"; st.rerun()
 
 
335
  else:
 
 
 
 
336
  ok, result = login_user(u, p)
 
337
  if ok:
338
- st.session_state.logged_in = True; st.session_state.user_data = result
 
339
  save_session(result["id"], u, SESSION_EXPIRY_HOURS)
340
  st.switch_page("pages/rec.py")
341
  elif result == "EMAIL_NOT_VERIFIED":
 
342
  resend_verification_code(u)
343
- st.session_state.temp_username = u; st.session_state.page = "verify"
344
- st.session_state.msg = "📧 Código reenviado para seu email"; st.session_state.msg_type = "info"; st.rerun()
 
 
 
345
  else:
346
- st.session_state.msg = result; st.session_state.msg_type = "error"; st.rerun()
 
 
 
 
347
  divider()
348
  if st.button("Criar nova conta →", use_container_width=True, key="go_reg"):
349
  st.session_state.page = "register"; st.session_state.msg = ""; st.rerun()
@@ -447,5 +536,4 @@ with card:
447
  "<div style='text-align:center;padding-top:12px;font-size:10px;color:#94a3b8;letter-spacing:.1em;'>"
448
  "PMJA · UHE Jaguara · Rifaina SP</div>",
449
  unsafe_allow_html=True,
450
- )
451
- #rec.py
 
7
  from dotenv import load_dotenv
8
  load_dotenv()
9
 
 
10
  from gs_client import read_ws, write_ws
11
 
12
  st.set_page_config(
 
16
  initial_sidebar_state="collapsed"
17
  )
18
 
19
+ # ── ESCONDE UI DO STREAMLIT ───────────────────────────────────────────
20
+ st.markdown("""<style>
21
+ #MainMenu,footer,header,.stDeployButton,[data-testid="stToolbar"],
22
+ [data-testid="stDecoration"],[data-testid="stStatusWidget"]{display:none!important}
23
+ footer{visibility:hidden!important;height:0!important}
24
+ </style>""", unsafe_allow_html=True)
25
+
26
  # ── COOKIE SESSION ────────────────────────────────────────────────────
27
+ _cc = CookieController()
28
  COOKIE_NAME = "pmja_session"
29
  SESSION_EXPIRY_HOURS = 8
30
 
 
109
  "last_login", "email_verified", "verification_code", "code_expiry", "image_url",
110
  ]
111
 
112
+ # ── DB ────────────────────────────────────────────────────────────────
113
  def get_users() -> pd.DataFrame:
114
  df = read_ws("users_auth", spreadsheet="main")
115
  if df.empty: return pd.DataFrame(columns=COLS)
 
188
  time.sleep(1)
189
  return True, "Email verificado!"
190
 
191
+ # ── SESSION STATE ─────────────────────────────────────────────────────
192
  for k, v in [
193
  ("logged_in", False), ("user_data", None), ("page", "login"),
194
  ("temp_username", None), ("msg", ""), ("msg_type", ""),
195
  ("_action", None), ("session_uid", None), ("session_usr", None), ("session_exp", None),
196
+ ("show_loading", False),
197
  ]:
198
  if k not in st.session_state:
199
  st.session_state[k] = v
 
221
  save_session(user["id"], user["username"], SESSION_EXPIRY_HOURS)
222
  st.session_state.msg = "Sessão estendida por 2 horas"; st.session_state.msg_type = "success"
223
 
224
+ # ── LOADING SCREEN MD ────────────────────────────────────────────────
225
+ LOADING_MD = """
226
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Sora:wght@700;800&display=swap" rel="stylesheet">
227
+ <style>
228
+ #pmja-loading{position:fixed;inset:0;z-index:999999;
229
+ background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
230
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
231
+ font-family:'DM Sans',sans-serif;}
232
+ #pmja-loading .ld-logos{display:flex;align-items:center;gap:28px;margin-bottom:40px}
233
+ #pmja-loading .ld-logo{height:36px;filter:brightness(0) invert(1);opacity:.9}
234
+ #pmja-loading .ld-div{width:1px;height:36px;background:rgba(255,255,255,.25)}
235
+ #pmja-loading .ld-title{font-family:'Sora',sans-serif;font-size:clamp(16px,4vw,22px);
236
+ color:#fff;font-weight:800;letter-spacing:-.5px;margin-bottom:4px;text-align:center}
237
+ #pmja-loading .ld-sub{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);
238
+ font-weight:600;margin-bottom:44px;text-align:center}
239
+ #pmja-loading .ld-spinner{width:34px;height:34px;border:3px solid rgba(255,255,255,.2);
240
+ border-top-color:#fff;border-radius:50%;animation:ldspin .8s linear infinite;margin-bottom:28px}
241
+ @keyframes ldspin{to{transform:rotate(360deg)}}
242
+ #pmja-loading .ld-bar-wrap{width:min(280px,70vw);height:4px;
243
+ background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:16px}
244
+ #pmja-loading .ld-bar{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
245
+ #pmja-loading .ld-status{font-size:12px;color:rgba(255,255,255,.65);font-weight:600;
246
+ letter-spacing:.3px;text-align:center}
247
+ #pmja-loading .ld-dots span{animation:ldblink 1.2s infinite;opacity:0}
248
+ #pmja-loading .ld-dots span:nth-child(2){animation-delay:.2s}
249
+ #pmja-loading .ld-dots span:nth-child(3){animation-delay:.4s}
250
+ @keyframes ldblink{0%,100%{opacity:0}50%{opacity:1}}
251
+ </style>
252
+ <div id="pmja-loading">
253
+ <div class="ld-logos">
254
+ <img class="ld-logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
255
+ <div class="ld-div"></div>
256
+ <img class="ld-logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
257
+ </div>
258
+ <div class="ld-spinner"></div>
259
+ <div class="ld-title">PMJA — Gestão de Materiais</div>
260
+ <div class="ld-sub">UHE Jaguara · Rifaina SP</div>
261
+ <div class="ld-bar-wrap"><div class="ld-bar" id="pmja-bar"></div></div>
262
+ <div class="ld-status" id="pmja-status">Autenticando
263
+ <span class="ld-dots"><span>.</span><span>.</span><span>.</span></span>
264
+ </div>
265
+ </div>
266
+ <script>
267
+ (function(){
268
+ var steps=[
269
+ {p:25,msg:'Autenticando'},
270
+ {p:55,msg:'Verificando credenciais'},
271
+ {p:80,msg:'Carregando dados'},
272
+ {p:96,msg:'Redirecionando'}
273
+ ];
274
+ var i=0;
275
+ function next(){
276
+ if(i>=steps.length)return;
277
+ var s=steps[i++];
278
+ var bar=document.getElementById('pmja-bar');
279
+ var st=document.getElementById('pmja-status');
280
+ if(bar)bar.style.width=s.p+'%';
281
+ if(st)st.innerHTML=s.msg+'<span class="ld-dots"><span>.</span><span>.</span><span>.</span></span>';
282
+ setTimeout(next,700+Math.random()*400);
283
+ }
284
+ setTimeout(next,200);
285
+ })();
286
+ </script>
287
+ """
288
+
289
  # ── CSS ───────────────────────────────────────────────────────────────
290
  _CSS = """\
291
  <style>
 
336
  """
337
  _html(_CSS, height=0)
338
 
339
+ # ── PAGE STATE ────────────────────────────────────────────────────────
340
  pg = "dashboard" if st.session_state.logged_in else st.session_state.page
341
  user = st.session_state.user_data
342
 
 
400
  st.text_input("Usuário", placeholder="nome_usuario", key="li_u")
401
  st.text_input("Senha", placeholder="••••••••", type="password", key="li_p")
402
  sub = st.form_submit_button("Entrar →", type="primary", use_container_width=True)
403
+
404
  if sub:
405
  u = st.session_state.li_u.strip(); p = st.session_state.li_p
406
  if not (u and p):
407
+ st.session_state.msg = "Preencha todos os campos"
408
+ st.session_state.msg_type = "error"
409
+ st.rerun()
410
  else:
411
+ # Mostra loading antes de autenticar
412
+ loading_ph = st.empty()
413
+ loading_ph.markdown(LOADING_MD, unsafe_allow_html=True)
414
+
415
  ok, result = login_user(u, p)
416
+
417
  if ok:
418
+ st.session_state.logged_in = True
419
+ st.session_state.user_data = result
420
  save_session(result["id"], u, SESSION_EXPIRY_HOURS)
421
  st.switch_page("pages/rec.py")
422
  elif result == "EMAIL_NOT_VERIFIED":
423
+ loading_ph.empty()
424
  resend_verification_code(u)
425
+ st.session_state.temp_username = u
426
+ st.session_state.page = "verify"
427
+ st.session_state.msg = "📧 Código reenviado para seu email"
428
+ st.session_state.msg_type = "info"
429
+ st.rerun()
430
  else:
431
+ loading_ph.empty()
432
+ st.session_state.msg = result
433
+ st.session_state.msg_type = "error"
434
+ st.rerun()
435
+
436
  divider()
437
  if st.button("Criar nova conta →", use_container_width=True, key="go_reg"):
438
  st.session_state.page = "register"; st.session_state.msg = ""; st.rerun()
 
536
  "<div style='text-align:center;padding-top:12px;font-size:10px;color:#94a3b8;letter-spacing:.1em;'>"
537
  "PMJA · UHE Jaguara · Rifaina SP</div>",
538
  unsafe_allow_html=True,
539
+ )
 
pages/atual.py CHANGED
@@ -8,7 +8,39 @@ from gs_client import read_ws, write_ws
8
  from dotenv import load_dotenv
9
  load_dotenv()
10
 
11
- # ── TIMEZONE BRASÍLIA ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def now_brt():
13
  return datetime.now(ZoneInfo("America/Sao_Paulo"))
14
 
@@ -71,6 +103,181 @@ def session_mins():
71
  return max(0, int((exp - datetime.now()).total_seconds() // 60))
72
  return 0
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  # ── EMAIL: TAREFA CRIADA ──────────────────────────────────────────────
75
  def send_task_created_email(task_row):
76
  def clean(v, fb=""):
@@ -128,7 +335,6 @@ def send_task_created_email(task_row):
128
  except Exception as e:
129
  st.warning(f"Falha ao enviar email: {e}")
130
 
131
-
132
  # ── EMAIL: TAREFA FINALIZADA ──────────────────────────────────────────
133
  def send_task_done_email(task_row):
134
  def clean(v, fb=""):
@@ -184,124 +390,6 @@ def send_task_done_email(task_row):
184
  except Exception as e:
185
  st.warning(f"Falha ao enviar email: {e}")
186
 
187
- # ── AUTH ──────────────────────────────────────────────────────────────
188
- if not st.session_state.get("logged_in"):
189
- s = load_session()
190
- if not s:
191
- st.switch_page("app.py")
192
-
193
- # ── AUTO SWITCH ───────────────────────────────────────────────────────
194
- if 'inicio_exibicao' not in st.session_state:
195
- st.session_state.inicio_exibicao = time.time()
196
- if time.time() - st.session_state.inicio_exibicao >= 180:
197
- st.session_state.inicio_exibicao = time.time()
198
- st.switch_page("pages/rec.py")
199
-
200
- st.set_page_config(
201
- layout="wide",
202
- initial_sidebar_state="collapsed",
203
- page_title="TaskFlow | Kanban",
204
- page_icon="📊"
205
- )
206
-
207
- # ── ACESSO A DADOS via gs_client ──────────────────────────────────────
208
- def _read(planilha, ws, ttl=600):
209
- """Lê worksheet; planilha='main' ou 'users'."""
210
- # ttl=0 significa forçar leitura fresca (ignora cache)
211
- if ttl == 0:
212
- read_ws.clear()
213
- df = read_ws(ws, spreadsheet=planilha)
214
- if df is None or df.empty:
215
- return pd.DataFrame()
216
- for col in df.columns:
217
- if df[col].dtype == object:
218
- df[col] = df[col].fillna("").astype(str)
219
- df[col] = df[col].replace("nan", "").replace("None", "")
220
- return df
221
-
222
- def _update(planilha, ws, df):
223
- write_ws(ws, df, spreadsheet=planilha)
224
-
225
- # ── DB ────────────────────────────────────────────────────────────────
226
- def calc_status(d):
227
- try:
228
- dl = datetime.strptime(str(d).strip(), '%d/%m/%Y').date()
229
- t = now_brt().date()
230
- return "Atrasada" if dl < t else "Curto Prazo" if dl <= t + timedelta(days=3) else "Em dia"
231
- except:
232
- return "Em dia"
233
-
234
- def make_formulas(row_num, responsible_id, user_id):
235
- return {
236
- 'responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!E:E;"");"")' ,
237
- 'url_responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!K:K;"");"")' ,
238
- 'email_responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!C:C;"");"")' ,
239
- 'user_full_name': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!E:E;"");"")' ,
240
- 'user_email': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!C:C;"");"")' ,
241
- 'user_image': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!K:K;"");"")' ,
242
- 'status': f'=SE(G{row_num}="";"";SE(G{row_num}<HOJE();"Atrasada";SE(G{row_num}<=HOJE()+3;"Curto Prazo";"Em dia")))',
243
- }
244
-
245
- def recalc():
246
- try:
247
- df = _read("users", "tasks", ttl=0)
248
- if df.empty: return True
249
- for i in df.index:
250
- row_num = i + 2
251
- resp_id = df.loc[i, 'responsible_id'] if 'responsible_id' in df.columns else ''
252
- user_id = df.loc[i, 'user_id'] if 'user_id' in df.columns else ''
253
- formulas = make_formulas(row_num, resp_id, user_id)
254
- for k, val in formulas.items():
255
- df.loc[i, k] = val
256
- _update("users", "tasks", df)
257
- load_data.clear()
258
- return True
259
- except Exception as e:
260
- st.error(f"recalc: {e}")
261
- return False
262
-
263
- @st.cache_data(ttl=600)
264
- def load_data():
265
- try:
266
- u = _read("users", "users_auth")
267
- c = _read("users", "config")
268
- t = _read("users", "tasks")
269
-
270
- if not t.empty:
271
- # Normaliza my_task
272
- _map = {
273
- 'a fazer': 'A Fazer', 'em andamento': 'Em Andamento',
274
- 'paralizada': 'Paralizada', 'finalizada': 'Finalizada',
275
- 'finalizado': 'Finalizada',
276
- }
277
- if 'my_task' not in t.columns:
278
- t['my_task'] = 'A Fazer'
279
- else:
280
- t['my_task'] = t['my_task'].apply(
281
- lambda v: _map.get(str(v).strip().lower(), str(v).strip()) or 'A Fazer'
282
- )
283
-
284
- # Recalcula status localmente (ignora fórmula do Sheets)
285
- if 'deadline' in t.columns:
286
- t['status'] = t['deadline'].apply(calc_status)
287
-
288
- # ID numérico para comparações seguras
289
- if 'id' in t.columns:
290
- t['id'] = pd.to_numeric(t['id'], errors='coerce').fillna(0).astype(int)
291
- # Remove linhas com id=0 (linhas vazias vindas do Google Sheets)
292
- t = t[t['id'] != 0].reset_index(drop=True)
293
-
294
- return (
295
- u, c, t,
296
- u['full_name'].tolist() if not u.empty and 'full_name' in u.columns else [],
297
- c['priority'].tolist() if not c.empty and 'priority' in c.columns else [],
298
- )
299
- except Exception as e:
300
- st.error(f"Erro ao carregar dados: {e}")
301
- return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), [], []
302
-
303
- users_df, config_df, tasks_df, users_list, prio_list = load_data()
304
-
305
  def update_sheet(td, action):
306
  try:
307
  df = _read("users", "tasks", ttl=0)
@@ -352,34 +440,7 @@ def update_sheet(td, action):
352
  st.error(f"update_sheet: {e}")
353
  return False
354
 
355
-
356
- # ── CSS ───────────────────────────────────────────────────────────────
357
- st.markdown("""<style>
358
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
359
- *{font-family:'Inter',sans-serif!important}
360
- #MainMenu,footer,header,.stDeployButton,[data-testid="stToolbar"],[data-testid="stToolbarActions"],
361
- [data-testid="stDecoration"],[data-testid="stStatusWidget"],[data-testid="collapsedControl"],
362
- [data-testid="stSidebarCollapsedControl"],[data-testid="manage-app-button"],[data-testid="stBaseButton-header"],
363
- button[title="Deploy"],button[aria-label="Deploy"],button[aria-label="Share"],button[title="Share"],
364
- .stAppDeployButton,section[data-testid="stSidebar"],div[class*="deployButton"],div[class*="viewerBadge"],
365
- div[class*="StatusWidget"],iframe[title="streamlit_analytics"]{display:none!important}
366
- footer{visibility:hidden!important;height:0!important}
367
- .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
368
- .stButton>button{position:fixed!important;left:-9999px!important;opacity:0!important;pointer-events:all!important;width:1px!important;height:1px!important}
369
- [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;height:0!important;overflow:visible!important;position:relative!important}
370
- iframe{position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
371
- @media(max-width:900px){
372
- iframe{position:relative!important;width:100%!important;height:100dvh!important;overflow:auto!important}
373
- [data-testid="stAppViewContainer"],[data-testid="stMain"],.main,.block-container{height:100dvh!important;overflow:visible!important;padding:0!important}
374
- }
375
- .main,[data-testid="stAppViewContainer"]{background:#fff!important}
376
- div[data-testid="stDialog"]{z-index:99999!important;position:fixed!important}
377
- div[data-testid="stDialog"] .stButton>button{font-size:13px!important;color:#374151!important;background:#fff!important;border:1px solid rgba(0,0,0,.12)!important;padding:6px 14px!important;height:34px!important;width:auto!important;border-radius:8px!important;cursor:pointer!important;position:relative!important;left:auto!important;top:auto!important;opacity:1!important}
378
- div[data-testid="stDialog"] .stButton>button[kind="primary"]{background:#1d4ed8!important;border-color:#1d4ed8!important;color:#fff!important}
379
- div[data-testid="stDialog"] [data-testid="stHorizontalBlock"]{height:auto!important;overflow:visible!important;gap:8px!important}
380
- </style>""", unsafe_allow_html=True)
381
-
382
- # ── STATE ──
383
  for k, v in [('dialog_action', None), ('dialog_task_id', None), ('show_menu', False)]:
384
  st.session_state.setdefault(k, v)
385
 
@@ -442,7 +503,7 @@ if not st.session_state.get("user_data"):
442
  else:
443
  clear_session(); st.switch_page("app.py")
444
 
445
- user = st.session_state.user_data
446
  img_url = user.get('image_url', '')
447
  mins = session_mins()
448
 
@@ -626,16 +687,11 @@ def build_board(df, u, img, mins, show_menu, prios, stats, resps):
626
  im = ''
627
  ava = (f'<img src="{im}" onerror="this.style.display=\'none\'">'
628
  if im else f'<div class="av-fb">{"".join(w[0].upper() for w in nm.split()[:2]) or "?"}</div>')
629
- h += (f'<div class="card" draggable="true" data-id="{tid3}" data-status="{t["my_task"]}"'
630
  f' data-priority="{t.get("priority","")}" data-stat="{t.get("status","")}"'
631
  f' data-title="{str(t.get("title","")).lower()}" data-desc="{str(t.get("description","")).lower()}"'
632
  f' data-responsible="{nm.lower()}">'
633
- f'<div class="ca">'
634
- f'<button class="act" onclick="event.stopPropagation();sa(\'edit\',{tid3})" title="Editar">'
635
- f'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4z"/></svg></button>'
636
- f'<button class="act act-d" onclick="event.stopPropagation();sa(\'delete\',{tid3})" title="Excluir">'
637
- f'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6M9 6V4h6v2"/></svg></button>'
638
- f'</div>'
639
  f'<div class="ct">{t["title"]}</div>{dh}'
640
  f'<div class="cb">{sbadge(t["status"])} {pbadge(t["priority"])}</div>'
641
  f'<div class="cf"><span class="cd2">📅 {t["deadline"]}</span>'
@@ -695,16 +751,12 @@ html,body{{height:100%;overflow:hidden;background:#fff}}
695
  .pf{{height:100%;border-radius:10px;opacity:.6;transition:width .3s ease}}
696
  .ctg{{display:none;font-size:12px;color:#9ca3af;transition:transform .2s;margin-left:6px}}
697
  .dz{{flex:1;overflow-y:auto;overflow-x:hidden;padding:6px;display:flex;flex-direction:column;gap:6px;min-height:60px}}
698
- .dz.over{{filter:brightness(.94);outline:2px dashed rgba(0,0,0,.18);border-radius:8px}}
699
- .card{{background:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.95);border-radius:8px;padding:9px 10px;cursor:grab;position:relative;transition:box-shadow .14s,transform .14s,background .14s,opacity .2s;flex-shrink:0;backdrop-filter:blur(4px)}}
700
  .card:hover{{background:#fff;box-shadow:0 3px 12px rgba(0,0,0,.1);transform:translateY(-1px)}}
701
- .card:hover .ca{{opacity:1;pointer-events:all}}.card:active{{cursor:grabbing}}
702
- .card.dragging{{opacity:.35}}.card.hidden{{display:none}}
703
- .ca{{position:absolute;top:7px;right:7px;display:flex;gap:3px;opacity:0;pointer-events:none;transition:opacity .13s}}
704
- @media(hover:none){{.ca{{opacity:1;pointer-events:all}}}}
705
- .act{{width:20px;height:20px;border-radius:5px;border:1px solid rgba(0,0,0,.09);background:rgba(255,255,255,.9);color:#6b7280;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .12s}}
706
- .act:hover{{background:#f3f4f6;color:#111827}}.act-d:hover{{background:#fef2f2!important;color:#dc2626!important}}
707
- .ct{{font-size:12px;font-weight:600;color:#111827;line-height:1.4;margin-bottom:4px;padding-right:48px;word-break:break-word}}
708
  .cd{{font-size:10.5px;color:#6b7280;line-height:1.45;margin-bottom:6px;word-break:break-word;white-space:pre-wrap}}
709
  .cb{{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:7px}}
710
  .badge{{display:inline-flex;align-items:center;font-size:9px;font-weight:600;padding:2px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:.3px;border:1px solid transparent;line-height:1.4}}
@@ -716,7 +768,6 @@ html,body{{height:100%;overflow:hidden;background:#fff}}
716
  .cu img{{width:18px;height:18px;border-radius:50%;object-fit:cover;border:1.5px solid rgba(0,0,0,.09)}}
717
  .av-fb{{width:18px;height:18px;border-radius:50%;background:#e5e7eb;color:#374151;font-size:8px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}}
718
  .cu span{{font-size:10px;color:#6b7280;font-weight:500}}
719
- .toast{{position:fixed;bottom:12px;right:12px;background:#111827;color:#f9fafb;padding:7px 14px;border-radius:7px;font-size:11px;font-weight:500;display:none;z-index:9999}}
720
  @media(max-width:900px){{
721
  html,body{{height:auto!important;overflow-x:hidden!important;overflow-y:auto!important;min-height:100%}}
722
  .tbs{{display:none}}.tbf{{display:none}}.tft{{display:flex}}.tbun{{display:none}}
@@ -770,7 +821,6 @@ html,body{{height:100%;overflow:hidden;background:#fff}}
770
  <select class="sel" id="fRm" onchange="af()">{ro}</select>
771
  <button class="tbcl" id="bCm" onclick="cf()">✕</button>
772
  </div>
773
- <div class="toast" id="toast">Salvando…</div>
774
  <div class="board">{cols}</div>
775
  <script>
776
  function sa(a,id,st){{
@@ -785,10 +835,6 @@ function sa(a,id,st){{
785
  }}catch(e){{console.warn(e)}}
786
  setTimeout(()=>{{try{{const u=new URL(window.parent.location.href);if(u.searchParams.get('action')===a)window.parent.location.href=u.toString();}}catch(e2){{}}}},150);
787
  }}
788
- function mv(id,st){{
789
- const t=document.getElementById('toast');t.style.display='block';t.textContent='Salvando…';
790
- sa('move',id,st);setTimeout(()=>{{t.textContent='✓ Salvo!';setTimeout(()=>t.style.display='none',1000);}},400);
791
- }}
792
  function gv(id){{const e=document.getElementById(id);return e?(e.value||'').toLowerCase().trim():''}}
793
  function af(){{
794
  const s=gv('fS')||gv('fSm'),p=gv('fP')||gv('fPm'),st=gv('fSt')||gv('fStm'),r=gv('fR')||gv('fRm');
@@ -820,18 +866,6 @@ function tfd(){{
820
  const d=document.getElementById('fdw'),b=document.getElementById('bFT');
821
  b.classList.toggle('active',d.classList.toggle('open'));
822
  }}
823
- let dc;
824
- document.addEventListener('dragstart',e=>{{if(e.target.classList.contains('card')){{dc=e.target;setTimeout(()=>e.target.classList.add('dragging'),0);e.dataTransfer.effectAllowed='move';}}}});
825
- document.addEventListener('dragend',e=>{{if(e.target.classList.contains('card'))e.target.classList.remove('dragging');}});
826
- document.querySelectorAll('.dz').forEach(z=>{{
827
- z.addEventListener('dragover',e=>{{e.preventDefault();z.classList.add('over');}});
828
- z.addEventListener('dragleave',e=>{{if(!z.contains(e.relatedTarget))z.classList.remove('over');}});
829
- z.addEventListener('drop',e=>{{
830
- e.preventDefault();z.classList.remove('over');
831
- if(dc){{const id=dc.getAttribute('data-id'),ov=dc.getAttribute('data-status'),nv=z.getAttribute('data-status');
832
- if(ov!==nv){{dc.setAttribute('data-status',nv);z.appendChild(dc);mv(id,nv);}}}}
833
- }});
834
- }});
835
  </script></body></html>'''
836
 
837
  components.html(
 
8
  from dotenv import load_dotenv
9
  load_dotenv()
10
 
11
+ # ── CONFIG deve ser o PRIMEIRO comando st. ──────────────────────────
12
+ st.set_page_config(
13
+ layout="wide",
14
+ initial_sidebar_state="collapsed",
15
+ page_title="TaskFlow | Kanban",
16
+ page_icon="📊"
17
+ )
18
+
19
+ # ── ESCONDE UI DO STREAMLIT imediatamente ─────────────────────────────
20
+ st.markdown("""<style>
21
+ #MainMenu,footer,header,.stDeployButton,[data-testid="stToolbar"],[data-testid="stToolbarActions"],
22
+ [data-testid="stDecoration"],[data-testid="stStatusWidget"],[data-testid="collapsedControl"],
23
+ [data-testid="stSidebarCollapsedControl"],[data-testid="manage-app-button"],[data-testid="stBaseButton-header"],
24
+ button[title="Deploy"],button[aria-label="Deploy"],button[aria-label="Share"],button[title="Share"],
25
+ .stAppDeployButton,section[data-testid="stSidebar"],div[class*="deployButton"],div[class*="viewerBadge"],
26
+ div[class*="StatusWidget"]{display:none!important}
27
+ footer{visibility:hidden!important;height:0!important}
28
+ .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
29
+ .stButton>button{position:fixed!important;left:-9999px!important;opacity:0!important;pointer-events:all!important;width:1px!important;height:1px!important}
30
+ [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;height:0!important;overflow:visible!important;position:relative!important}
31
+ iframe{position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
32
+ @media(max-width:900px){
33
+ iframe{position:relative!important;width:100%!important;height:100dvh!important;overflow:auto!important}
34
+ [data-testid="stAppViewContainer"],[data-testid="stMain"],.main,.block-container{height:100dvh!important;overflow:visible!important;padding:0!important}
35
+ }
36
+ .main,[data-testid="stAppViewContainer"]{background:#fff!important}
37
+ div[data-testid="stDialog"]{z-index:99999!important;position:fixed!important}
38
+ div[data-testid="stDialog"] .stButton>button{font-size:13px!important;color:#374151!important;background:#fff!important;border:1px solid rgba(0,0,0,.12)!important;padding:6px 14px!important;height:34px!important;width:auto!important;border-radius:8px!important;cursor:pointer!important;position:relative!important;left:auto!important;top:auto!important;opacity:1!important}
39
+ div[data-testid="stDialog"] .stButton>button[kind="primary"]{background:#1d4ed8!important;border-color:#1d4ed8!important;color:#fff!important}
40
+ div[data-testid="stDialog"] [data-testid="stHorizontalBlock"]{height:auto!important;overflow:visible!important;gap:8px!important}
41
+ </style>""", unsafe_allow_html=True)
42
+
43
+ # ── TIMEZONE BRASÍLIA ─────────────────────────────────────────────────
44
  def now_brt():
45
  return datetime.now(ZoneInfo("America/Sao_Paulo"))
46
 
 
103
  return max(0, int((exp - datetime.now()).total_seconds() // 60))
104
  return 0
105
 
106
+ # ── AUTH ──────────────────────────────────────────────────────────────
107
+ if not st.session_state.get("logged_in"):
108
+ s = load_session()
109
+ if not s:
110
+ st.switch_page("app.py")
111
+
112
+ # ── AUTO SWITCH ───────────────────────────────────────────────────────
113
+ if 'inicio_exibicao' not in st.session_state:
114
+ st.session_state.inicio_exibicao = time.time()
115
+ if time.time() - st.session_state.inicio_exibicao >= 180:
116
+ st.session_state.inicio_exibicao = time.time()
117
+ st.switch_page("pages/rec.py")
118
+
119
+ # ── LOADING SCREEN ────────────────────────────────────────────────────
120
+ LOADING_MD = """
121
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Sora:wght@700;800&display=swap" rel="stylesheet">
122
+ <style>
123
+ #pmja-loading{position:fixed;inset:0;z-index:999999;
124
+ background:linear-gradient(135deg,#1d4ed8 0%,#1e40af 100%);
125
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
126
+ font-family:'Inter',sans-serif;}
127
+ #pmja-loading .ld-logos{display:flex;align-items:center;gap:28px;margin-bottom:40px}
128
+ #pmja-loading .ld-logo{height:36px;filter:brightness(0) invert(1);opacity:.9}
129
+ #pmja-loading .ld-div{width:1px;height:36px;background:rgba(255,255,255,.25)}
130
+ #pmja-loading .ld-title{font-family:'Sora',sans-serif;font-size:clamp(16px,4vw,22px);
131
+ color:#fff;font-weight:800;letter-spacing:-.5px;margin-bottom:4px;text-align:center}
132
+ #pmja-loading .ld-sub{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);
133
+ font-weight:600;margin-bottom:44px;text-align:center}
134
+ #pmja-loading .ld-spinner{width:34px;height:34px;border:3px solid rgba(255,255,255,.2);
135
+ border-top-color:#fff;border-radius:50%;animation:ldspin .8s linear infinite;margin-bottom:28px}
136
+ @keyframes ldspin{to{transform:rotate(360deg)}}
137
+ #pmja-loading .ld-bar-wrap{width:min(280px,70vw);height:4px;
138
+ background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:16px}
139
+ #pmja-loading .ld-bar{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
140
+ #pmja-loading .ld-status{font-size:12px;color:rgba(255,255,255,.65);font-weight:600;
141
+ letter-spacing:.3px;text-align:center}
142
+ #pmja-loading .ld-dots span{animation:ldblink 1.2s infinite;opacity:0}
143
+ #pmja-loading .ld-dots span:nth-child(2){animation-delay:.2s}
144
+ #pmja-loading .ld-dots span:nth-child(3){animation-delay:.4s}
145
+ @keyframes ldblink{0%,100%{opacity:0}50%{opacity:1}}
146
+ </style>
147
+ <div id="pmja-loading">
148
+ <div class="ld-logos">
149
+ <img class="ld-logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
150
+ <div class="ld-div"></div>
151
+ <img class="ld-logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
152
+ </div>
153
+ <div class="ld-spinner"></div>
154
+ <div class="ld-title">PMJA — Kanban</div>
155
+ <div class="ld-sub">Gestão de Materiais</div>
156
+ <div class="ld-bar-wrap"><div class="ld-bar" id="pmja-bar"></div></div>
157
+ <div class="ld-status" id="pmja-status">Conectando ao Google Sheets
158
+ <span class="ld-dots"><span>.</span><span>.</span><span>.</span></span>
159
+ </div>
160
+ </div>
161
+ <script>
162
+ (function(){
163
+ var steps=[
164
+ {p:20,msg:'Conectando ao Google Sheets'},
165
+ {p:45,msg:'Carregando tarefas'},
166
+ {p:68,msg:'Carregando usuários'},
167
+ {p:88,msg:'Montando o board'},
168
+ {p:96,msg:'Quase pronto'}
169
+ ];
170
+ var i=0;
171
+ function next(){
172
+ if(i>=steps.length)return;
173
+ var s=steps[i++];
174
+ var bar=document.getElementById('pmja-bar');
175
+ var st=document.getElementById('pmja-status');
176
+ if(bar)bar.style.width=s.p+'%';
177
+ if(st)st.innerHTML=s.msg+'<span class="ld-dots"><span>.</span><span>.</span><span>.</span></span>';
178
+ setTimeout(next,950+Math.random()*550);
179
+ }
180
+ setTimeout(next,300);
181
+ })();
182
+ </script>
183
+ """
184
+
185
+ loading_placeholder = st.empty()
186
+ loading_placeholder.markdown(LOADING_MD, unsafe_allow_html=True)
187
+
188
+ # ── ACESSO A DADOS via gs_client ──────────────────────────────────────
189
+ def _read(planilha, ws, ttl=600):
190
+ if ttl == 0:
191
+ read_ws.clear()
192
+ df = read_ws(ws, spreadsheet=planilha)
193
+ if df is None or df.empty:
194
+ return pd.DataFrame()
195
+ for col in df.columns:
196
+ if df[col].dtype == object:
197
+ df[col] = df[col].fillna("").astype(str)
198
+ df[col] = df[col].replace("nan", "").replace("None", "")
199
+ return df
200
+
201
+ def _update(planilha, ws, df):
202
+ write_ws(ws, df, spreadsheet=planilha)
203
+
204
+ # ── DB ────────────────────────────────────────────────────────────────
205
+ def calc_status(d):
206
+ try:
207
+ dl = datetime.strptime(str(d).strip(), '%d/%m/%Y').date()
208
+ t = now_brt().date()
209
+ return "Atrasada" if dl < t else "Curto Prazo" if dl <= t + timedelta(days=3) else "Em dia"
210
+ except:
211
+ return "Em dia"
212
+
213
+ def make_formulas(row_num, responsible_id, user_id):
214
+ return {
215
+ 'responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!E:E;"");"")' ,
216
+ 'url_responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!K:K;"");"")' ,
217
+ 'email_responsible': f'=SEERRO(PROCX(D{row_num};users_auth!A:A;users_auth!C:C;"");"")' ,
218
+ 'user_full_name': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!E:E;"");"")' ,
219
+ 'user_email': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!C:C;"");"")' ,
220
+ 'user_image': f'=SEERRO(PROCX(N{row_num};users_auth!A:A;users_auth!K:K;"");"")' ,
221
+ 'status': f'=SE(G{row_num}="";"";SE(G{row_num}<HOJE();"Atrasada";SE(G{row_num}<=HOJE()+3;"Curto Prazo";"Em dia")))',
222
+ }
223
+
224
+ def recalc():
225
+ try:
226
+ df = _read("users", "tasks", ttl=0)
227
+ if df.empty: return True
228
+ for i in df.index:
229
+ row_num = i + 2
230
+ resp_id = df.loc[i, 'responsible_id'] if 'responsible_id' in df.columns else ''
231
+ user_id = df.loc[i, 'user_id'] if 'user_id' in df.columns else ''
232
+ formulas = make_formulas(row_num, resp_id, user_id)
233
+ for k, val in formulas.items():
234
+ df.loc[i, k] = val
235
+ _update("users", "tasks", df)
236
+ load_data.clear()
237
+ return True
238
+ except Exception as e:
239
+ st.error(f"recalc: {e}")
240
+ return False
241
+
242
+ @st.cache_data(ttl=600)
243
+ def load_data():
244
+ try:
245
+ u = _read("users", "users_auth")
246
+ c = _read("users", "config")
247
+ t = _read("users", "tasks")
248
+
249
+ if not t.empty:
250
+ _map = {
251
+ 'a fazer': 'A Fazer', 'em andamento': 'Em Andamento',
252
+ 'paralizada': 'Paralizada', 'finalizada': 'Finalizada',
253
+ 'finalizado': 'Finalizada',
254
+ }
255
+ if 'my_task' not in t.columns:
256
+ t['my_task'] = 'A Fazer'
257
+ else:
258
+ t['my_task'] = t['my_task'].apply(
259
+ lambda v: _map.get(str(v).strip().lower(), str(v).strip()) or 'A Fazer'
260
+ )
261
+ if 'deadline' in t.columns:
262
+ t['status'] = t['deadline'].apply(calc_status)
263
+ if 'id' in t.columns:
264
+ t['id'] = pd.to_numeric(t['id'], errors='coerce').fillna(0).astype(int)
265
+ t = t[t['id'] != 0].reset_index(drop=True)
266
+
267
+ return (
268
+ u, c, t,
269
+ u['full_name'].tolist() if not u.empty and 'full_name' in u.columns else [],
270
+ c['priority'].tolist() if not c.empty and 'priority' in c.columns else [],
271
+ )
272
+ except Exception as e:
273
+ st.error(f"Erro ao carregar dados: {e}")
274
+ return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), [], []
275
+
276
+ users_df, config_df, tasks_df, users_list, prio_list = load_data()
277
+
278
+ # ── FECHA O LOADING ───────────────────────────────────────────────────
279
+ loading_placeholder.empty()
280
+
281
  # ── EMAIL: TAREFA CRIADA ──────────────────────────────────────────────
282
  def send_task_created_email(task_row):
283
  def clean(v, fb=""):
 
335
  except Exception as e:
336
  st.warning(f"Falha ao enviar email: {e}")
337
 
 
338
  # ── EMAIL: TAREFA FINALIZADA ──────────────────────────────────────────
339
  def send_task_done_email(task_row):
340
  def clean(v, fb=""):
 
390
  except Exception as e:
391
  st.warning(f"Falha ao enviar email: {e}")
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def update_sheet(td, action):
394
  try:
395
  df = _read("users", "tasks", ttl=0)
 
440
  st.error(f"update_sheet: {e}")
441
  return False
442
 
443
+ # ── STATE ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  for k, v in [('dialog_action', None), ('dialog_task_id', None), ('show_menu', False)]:
445
  st.session_state.setdefault(k, v)
446
 
 
503
  else:
504
  clear_session(); st.switch_page("app.py")
505
 
506
+ user = st.session_state.user_data
507
  img_url = user.get('image_url', '')
508
  mins = session_mins()
509
 
 
687
  im = ''
688
  ava = (f'<img src="{im}" onerror="this.style.display=\'none\'">'
689
  if im else f'<div class="av-fb">{"".join(w[0].upper() for w in nm.split()[:2]) or "?"}</div>')
690
+ h += (f'<div class="card" data-id="{tid3}" data-status="{t["my_task"]}"'
691
  f' data-priority="{t.get("priority","")}" data-stat="{t.get("status","")}"'
692
  f' data-title="{str(t.get("title","")).lower()}" data-desc="{str(t.get("description","")).lower()}"'
693
  f' data-responsible="{nm.lower()}">'
694
+
 
 
 
 
 
695
  f'<div class="ct">{t["title"]}</div>{dh}'
696
  f'<div class="cb">{sbadge(t["status"])} {pbadge(t["priority"])}</div>'
697
  f'<div class="cf"><span class="cd2">📅 {t["deadline"]}</span>'
 
751
  .pf{{height:100%;border-radius:10px;opacity:.6;transition:width .3s ease}}
752
  .ctg{{display:none;font-size:12px;color:#9ca3af;transition:transform .2s;margin-left:6px}}
753
  .dz{{flex:1;overflow-y:auto;overflow-x:hidden;padding:6px;display:flex;flex-direction:column;gap:6px;min-height:60px}}
754
+ .card{{background:rgba(255,255,255,.8);border:1px solid rgba(255,255,255,.95);border-radius:8px;padding:9px 10px;cursor:default;position:relative;transition:box-shadow .14s,transform .14s,background .14s,opacity .2s;flex-shrink:0;backdrop-filter:blur(4px)}}
 
755
  .card:hover{{background:#fff;box-shadow:0 3px 12px rgba(0,0,0,.1);transform:translateY(-1px)}}
756
+ .card:hover .ca{{opacity:1;pointer-events:all}}.card:active{{cursor:default}}
757
+ .card.hidden{{display:none}}
758
+ .act-d:hover{{background:#fef2f2!important;color:#dc2626!important}}
759
+ .ct{{font-size:12px;font-weight:600;color:#111827;line-height:1.4;margin-bottom:4px;word-break:break-word}}
 
 
 
760
  .cd{{font-size:10.5px;color:#6b7280;line-height:1.45;margin-bottom:6px;word-break:break-word;white-space:pre-wrap}}
761
  .cb{{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:7px}}
762
  .badge{{display:inline-flex;align-items:center;font-size:9px;font-weight:600;padding:2px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:.3px;border:1px solid transparent;line-height:1.4}}
 
768
  .cu img{{width:18px;height:18px;border-radius:50%;object-fit:cover;border:1.5px solid rgba(0,0,0,.09)}}
769
  .av-fb{{width:18px;height:18px;border-radius:50%;background:#e5e7eb;color:#374151;font-size:8px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}}
770
  .cu span{{font-size:10px;color:#6b7280;font-weight:500}}
 
771
  @media(max-width:900px){{
772
  html,body{{height:auto!important;overflow-x:hidden!important;overflow-y:auto!important;min-height:100%}}
773
  .tbs{{display:none}}.tbf{{display:none}}.tft{{display:flex}}.tbun{{display:none}}
 
821
  <select class="sel" id="fRm" onchange="af()">{ro}</select>
822
  <button class="tbcl" id="bCm" onclick="cf()">✕</button>
823
  </div>
 
824
  <div class="board">{cols}</div>
825
  <script>
826
  function sa(a,id,st){{
 
835
  }}catch(e){{console.warn(e)}}
836
  setTimeout(()=>{{try{{const u=new URL(window.parent.location.href);if(u.searchParams.get('action')===a)window.parent.location.href=u.toString();}}catch(e2){{}}}},150);
837
  }}
 
 
 
 
838
  function gv(id){{const e=document.getElementById(id);return e?(e.value||'').toLowerCase().trim():''}}
839
  function af(){{
840
  const s=gv('fS')||gv('fSm'),p=gv('fP')||gv('fPm'),st=gv('fSt')||gv('fStm'),r=gv('fR')||gv('fRm');
 
866
  const d=document.getElementById('fdw'),b=document.getElementById('bFT');
867
  b.classList.toggle('active',d.classList.toggle('open'));
868
  }}
 
 
 
 
 
 
 
 
 
 
 
 
869
  </script></body></html>'''
870
 
871
  components.html(
pages/exp.py CHANGED
@@ -8,6 +8,22 @@ from gs_client import read_ws
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard Expedição")
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # ── SESSION ───────────────────────────────────────────────────────────
12
  _cc = CookieController()
13
  COOKIE_NAME = "pmja_session"
@@ -38,38 +54,71 @@ if time.time() - st.session_state.inicio_exibicao >= 120:
38
  st.session_state.inicio_exibicao = time.time()
39
  st.switch_page("pages/full.py")
40
 
41
- st.markdown("""<style>
42
- #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
43
- [data-testid="stStatusWidget"]{display:none!important}
44
- .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
45
- iframe{position:fixed!important;top:0!important;left:0!important;
46
- width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
47
- [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
48
- height:0!important;overflow:visible!important}
49
- .main,[data-testid="stAppViewContainer"]{background:#f5f7fa!important}
50
- @media(max-width:900px){
51
- iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
52
- .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
53
- .block-container{height:auto!important}
54
- }
55
- </style>""", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  # ── HELPERS ───────────────────────────────────────────────────────────
58
  def fmt(n):
59
  if pd.isna(n): return "0"
60
- return f"{int(n):,}".replace(',', '.')
61
-
62
- def fix_df(df):
63
- for col in df.columns:
64
- if df[col].dtype == 'object':
65
- num = pd.to_numeric(df[col], errors='coerce')
66
- df[col] = num if num.notna().sum() == df[col].notna().sum() \
67
- else df[col].fillna('').astype(str).replace('nan', '')
68
- return df
69
 
70
  def extrair_categoria(col):
71
- for s in [' Requisições', ' Requisiçoes', ' Unid. Itens', ' Unid Itens',
72
- ' Itens por unidade', ' Items por unidade']:
73
  col = col.replace(s, '')
74
  return col.strip()
75
 
@@ -82,90 +131,75 @@ def proc_exp(df_raw):
82
  df = df.dropna(subset=['data']).copy()
83
  df['ano'] = df['data'].dt.year
84
  df['mes_num'] = df['data'].dt.month
85
-
86
  req, unid, itens = [], [], []
87
  for col in df.columns:
88
- c = col.lower().strip().replace('.', '').replace(' ', ' ')
89
  if 'requisi' in c and 'unid' not in c and 'por' not in c: req.append(col)
90
  if 'unid' in c and 'iten' in c and 'por' not in c: unid.append(col)
91
  if 'iten' in c and 'por' in c and 'unid' in c: itens.append(col)
92
-
93
- for col in req + unid + itens:
94
- if col in df.columns:
95
- df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
96
-
97
  df['qtd_requisicoes'] = df[req].sum(axis=1)
98
  df['qtd_unidades_emitidas'] = df[unid].sum(axis=1)
99
  df['qtd_itens_total'] = df[itens].sum(axis=1) if itens else 0
100
-
101
- df_final = df[['mes_num', 'ano', 'qtd_requisicoes',
102
- 'qtd_unidades_emitidas', 'qtd_itens_total']].sort_values(['ano','mes_num']).reset_index(drop=True)
103
- df_det = df[['mes_num', 'ano'] + req + unid + itens].sort_values(['ano','mes_num']).reset_index(drop=True)
104
  return df_final, df_det, req, unid, itens
105
 
106
  # ── BUILD FRAMES ──────────────────────────────────────────────────────
107
  def build_frames(df_plot, df_det, req_cols, unid_cols, itens_cols, anos_unicos, mes_max, CORES_EXP):
108
  MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
109
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
110
-
111
  metricas_linha = [
112
- {'col': 'qtd_requisicoes', 'cores': ['#001a33','#003366','#004d99','#0066cc']},
113
- {'col': 'qtd_unidades_emitidas', 'cores': ['#003d66','#005a9c','#0077cc','#3399dd']},
114
- {'col': 'qtd_itens_total', 'cores': ['#0056A3','#0075be','#3399dd','#66b3ee']},
115
  ]
116
- tipos_l1 = ['barra_h', 'barra_v', 'linha']
117
-
118
  frames = []
119
- for mes_atual in range(1, mes_max + 1):
120
  frame = {"mes": mes_atual, "cards": {}, "linha1": [], "linha2": []}
121
-
122
  df_ate = df_plot[df_plot['mes_num'] <= mes_atual]
123
  df_det_ate = df_det[df_det['mes_num'] <= mes_atual]
124
-
125
- # cards
126
  for col, tit, cor, suf in [
127
- ('qtd_requisicoes', 'Requisições', '#003a70', ''),
128
- ('qtd_unidades_emitidas', 'Unidades Emitidas', '#005a9c', ''),
129
- ('qtd_itens_total', 'Total de Itens', '#0075be', ''),
130
  ]:
131
  frame["cards"][col] = {"valor": fmt(df_ate[col].sum()), "cor": cor, "titulo": tit, "sufixo": suf}
132
-
133
- # linha1
134
  meses_ex = sorted(df_plot[df_plot['mes_num'] <= mes_atual]['mes_num'].unique())
135
  for idx_m, cfg in enumerate(metricas_linha):
136
  col = cfg['col']
137
  traces = []
138
  for idx_ano, ano in enumerate(anos_unicos):
139
- d = df_plot[(df_plot['ano'] == ano) & (df_plot['mes_num'] <= mes_atual)].sort_values('mes_num')
140
  if d.empty: continue
141
  cor = cfg['cores'][idx_ano % len(cfg['cores'])]
142
  vals, texts = [], []
143
  for m in meses_ex:
144
- row = d[d['mes_num'] == m]
145
  v = float(row[col].iloc[0]) if not row.empty else 0.0
146
- vals.append(v)
147
- texts.append(fmt(v) if v > 0 else "")
148
  traces.append({
149
  "ano": str(ano), "cor": cor,
150
  "meses": meses_ex, "y": vals, "text": texts,
151
- "fill_rgba": f"rgba({int(cor[1:3],16)},{int(cor[3:5],16)},{int(cor[5:7],16)},0.1)"
152
  })
153
  frame["linha1"].append({"tipo": tipos_l1[idx_m], "traces": traces, "meses_ex": meses_ex})
154
-
155
- # linha2: sistemas
156
- grupos = [
157
  {'cols': req_cols, 'tipo': 'donut'},
158
  {'cols': unid_cols, 'tipo': 'linha_sis'},
159
  {'cols': itens_cols, 'tipo': 'barv_sis'},
160
- ]
161
- for grp in grupos:
162
  totais = {}
163
  for c in grp['cols']:
164
  cat = extrair_categoria(c)
165
  if cat not in totais:
166
  totais[cat] = {'v': 0.0, 'cor': CORES_EXP[len(totais) % len(CORES_EXP)]}
167
  totais[cat]['v'] += float(df_det_ate[c].sum()) if c in df_det_ate.columns else 0.0
168
- dados = sorted([(k, v) for k, v in totais.items() if v['v'] > 0],
169
  key=lambda x: x[1]['v'], reverse=True)
170
  frame["linha2"].append({
171
  "tipo": grp['tipo'],
@@ -173,62 +207,49 @@ def build_frames(df_plot, df_det, req_cols, unid_cols, itens_cols, anos_unicos,
173
  "valores":[d[1]['v'] for d in dados],
174
  "cores": [d[1]['cor'] for d in dados],
175
  })
176
-
177
  frames.append(frame)
178
  return frames
179
 
180
- # ── LOAD DATA ─────────────────────────────────────────────────────────
181
- with st.spinner('📊 Carregando dados...'):
182
- df_exp_raw = read_ws("exp_dados") # ← gs_client (era: load_ws("exp_dados"))
183
-
184
  result = proc_exp(df_exp_raw) if df_exp_raw is not None and not df_exp_raw.empty else None
185
 
186
  if result is not None:
187
  df_expedicao, df_det, req_cols, unid_cols, itens_cols = result
188
-
189
- for col in ['qtd_requisicoes', 'qtd_unidades_emitidas', 'qtd_itens_total']:
190
  df_expedicao[col] = pd.to_numeric(df_expedicao[col], errors='coerce').fillna(0)
191
-
192
  mes_max = int(df_expedicao['mes_num'].max())
193
  anos_unicos = sorted(df_expedicao['ano'].unique())
194
-
195
- CORES_EXP = ['#001a33','#002d4d','#003d66','#004d80','#005d99',
196
- '#006db3','#007dcc','#1a8dd4','#3399dd','#4da6e6',
197
- '#66b3ee','#80c0f5','#99ccff','#b3d9ff']
198
-
199
  MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
200
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
201
-
202
  metricas_cfg = [
203
- {'col':'qtd_requisicoes', 'cores':['#001a33','#003366','#004d99','#0066cc'], 'suf':''},
204
- {'col':'qtd_unidades_emitidas', 'cores':['#003d66','#005a9c','#0077cc','#3399dd'], 'suf':''},
205
- {'col':'qtd_itens_total', 'cores':['#0056A3','#0075be','#3399dd','#66b3ee'], 'suf':''},
206
  ]
207
 
208
  def sub_anos(cfg):
209
  parts = []
210
  for i, ano in enumerate(anos_unicos):
211
  cor = cfg['cores'][i % len(cfg['cores'])]
212
- total = fmt(df_expedicao[df_expedicao['ano'] == ano][cfg['col']].sum())
213
- parts.append(f"<span style='color:{cor};font-weight:700'>{ano}: {total}{cfg['suf']}</span>")
214
  return " <span style='color:#666'>|</span> ".join(parts)
215
 
216
  def sub_sistemas(cols):
217
  totais = {}
218
  for c in cols:
219
  cat = extrair_categoria(c)
220
- if cat not in totais:
221
- totais[cat] = {'v': 0.0, 'cor': CORES_EXP[len(totais) % len(CORES_EXP)]}
222
  totais[cat]['v'] += float(df_det[c].sum()) if c in df_det.columns else 0.0
223
- dados = sorted([(k, v) for k, v in totais.items() if v['v'] > 0],
224
  key=lambda x: x[1]['v'], reverse=True)
225
- parts = []
226
- for d in dados:
227
- parts.append(f"<span style='color:{d[1]['cor']};font-weight:700'>{d[0]}: {fmt(d[1]['v'])}</span>")
228
- return " <span style='color:#666'>|</span> ".join(parts)
229
 
230
- frames = build_frames(df_expedicao, df_det, req_cols, unid_cols, itens_cols,
231
- anos_unicos, mes_max, CORES_EXP)
232
 
233
  import numpy as np
234
  class NpEncoder(json.JSONEncoder):
@@ -238,72 +259,77 @@ if result is not None:
238
  if isinstance(obj, np.ndarray): return obj.tolist()
239
  return super().default(obj)
240
 
241
- frames_json = json.dumps(frames, cls=NpEncoder, ensure_ascii=False)
242
- meses_json = json.dumps(MESES_PT)
243
- TITULOS_L1 = ['Requisições', 'Unidades Emitidas', 'Total de Itens']
244
- TITULOS_L2 = ['Requisições por Sistema', 'Unidades Emitidas por Sistema', 'Total de Itens por Sistema']
245
- subs_l1 = [sub_anos(c) for c in metricas_cfg]
246
- subs_l2 = [sub_sistemas(req_cols), sub_sistemas(unid_cols), sub_sistemas(itens_cols)]
247
 
248
- html = f"""<!DOCTYPE html><html><head>
 
 
249
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
250
- <link rel="preconnect" href="https://fonts.googleapis.com">
251
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
252
- <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600;9..40,700;9..40,800&family=Sora:wght@700;800;900&display=swap" rel="stylesheet">
253
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
254
  <style>
255
- *{{margin:0;padding:0;box-sizing:border-box}}
256
- html,body{{width:100%;height:100%;overflow:hidden;
257
  background:linear-gradient(135deg,#f5f7fa 0%,#e8eef5 100%);
258
- font-family:'DM Sans',sans-serif;}}
259
- .root{{display:grid;width:100%;height:100dvh;gap:3px;padding:4px;overflow:hidden;
260
- grid-template-rows:auto auto 1fr 1fr;}}
261
- .hdr{{background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
262
- border-radius:8px;padding:4px 14px;display:flex;align-items:center;justify-content:space-between;
263
- box-shadow:0 4px 12px rgba(0,58,112,.12);}}
264
- .logo{{height:clamp(22px,3.2dvh,36px);filter:brightness(0) invert(1);opacity:.92}}
265
- .ht{{font-family:'Sora',sans-serif;font-size:clamp(12px,1.8dvh,20px);color:#fff;font-weight:800;letter-spacing:-.5px}}
266
- .hs{{font-family:'DM Sans',sans-serif;font-size:clamp(9px,1.2dvh,14px);color:rgba(255,255,255,.9);font-weight:700}}
267
- .cards{{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;}}
268
- .mc{{background:linear-gradient(135deg,#ffffff 0%,#f8fafd 100%);
269
- border-radius:8px;padding:clamp(4px,.6dvh,10px) clamp(6px,.7vw,14px);
270
- border-left:4px solid;box-shadow:0 2px 12px rgba(0,58,112,.08);overflow:hidden;
271
- transition:box-shadow .2s;}}
272
- .mc:hover{{box-shadow:0 4px 18px rgba(0,58,112,.14);}}
273
- .ml{{font-family:'DM Sans',sans-serif;font-size:clamp(7px,.85dvh,11px);color:#666;
274
- font-weight:700;text-transform:uppercase;letter-spacing:.3px;margin-bottom:2px;white-space:nowrap;}}
275
- .mv{{font-family:'Sora',sans-serif;font-size:clamp(13px,2dvh,26px);
276
- font-weight:800;line-height:1;white-space:nowrap;}}
277
- .row3{{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;min-height:0;}}
278
- .cb{{background:white;border-radius:10px;
 
279
  box-shadow:0 2px 12px rgba(0,58,112,.07),0 1px 3px rgba(0,58,112,.04);
280
- overflow:visible;min-height:0;display:flex;flex-direction:column;
281
- border:1px solid rgba(0,58,112,.04);}}
282
- .ct{{background:linear-gradient(180deg,#fafbfd 0%,#ffffff 100%);
283
- padding:4px 8px 3px;border-radius:10px 10px 0 0;
284
- border-bottom:1px solid #eef1f6;flex-shrink:0;}}
285
- .ct-title{{font-family:'Sora',sans-serif;font-size:clamp(9px,1.15dvh,15px);
286
- color:#1a1a1a;font-weight:800;text-align:center;}}
287
- .ct-sub{{text-align:center;font-size:clamp(7px,.8dvh,10px);font-weight:700;
288
- margin-top:1px;line-height:1.3;font-family:'DM Sans',sans-serif;}}
289
- .cp{{flex:1;min-height:0;overflow:visible;}}
290
- @media(max-width:900px){{
291
- html,body{{overflow-y:auto!important;height:auto!important}}
292
- .root{{height:auto!important;overflow:visible!important;grid-template-rows:auto!important}}
293
- .cards{{grid-template-columns:repeat(3,1fr)!important}}
294
- .row3{{grid-template-columns:repeat(2,1fr)!important;min-height:200px}}
295
- .cb{{min-height:220px!important}}
296
- .cp{{min-height:180px!important}}
297
- }}
298
- @media(max-width:600px){{
299
- .cards{{grid-template-columns:1fr 1fr!important}}
300
- .row3{{grid-template-columns:1fr!important}}
301
- .cb{{min-height:200px!important}}
302
- .hs{{display:none}}
303
- .hdr{{padding:4px 8px!important}}
304
- }}
 
 
 
305
  </style>
306
- </head><body>
 
307
  <div class="root">
308
  <div class="hdr">
309
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
@@ -317,389 +343,448 @@ html,body{{width:100%;height:100%;overflow:hidden;
317
  <div class="row3" id="row1"></div>
318
  <div class="row3" id="row2"></div>
319
  </div>
320
-
321
  <script>
322
- var FRAMES = {frames_json};
323
- var MESES = {meses_json};
324
- var TITULOS_L1 = {json.dumps(TITULOS_L1)};
325
- var TITULOS_L2 = {json.dumps(TITULOS_L2)};
326
- var SUBS_L1 = {json.dumps(subs_l1)};
327
- var SUBS_L2 = {json.dumps(subs_l2)};
328
 
329
  var CARDS_CFG = [
330
- {{col:'qtd_requisicoes', titulo:'Requisições', cor:'#003a70', sufixo:''}},
331
- {{col:'qtd_unidades_emitidas', titulo:'Unidades Emitidas', cor:'#005a9c', sufixo:''}},
332
- {{col:'qtd_itens_total', titulo:'Total de Itens', cor:'#0075be', sufixo:''}},
333
  ];
334
 
335
- var FRAME_MS = 700;
336
- var PAUSE_MS = 60000;
337
- var frameIdx = 0, animTimer = null, cdownTimer = null;
338
- var initialized = {{}};
 
 
339
 
340
- function buildDOM() {{
341
  var cr = document.getElementById('cards-row');
342
- CARDS_CFG.forEach(function(c) {{
343
  cr.innerHTML += '<div class="mc" style="border-left-color:'+c.cor+'">'
344
- +'<div class="ml">'+c.titulo+'</div>'
345
- +'<div class="mv" id="mv_'+c.col+'" style="color:'+c.cor+'">0</div></div>';
346
- }});
347
  var r1 = document.getElementById('row1');
348
- for(var i=0;i<3;i++) {{
349
  r1.innerHTML += '<div class="cb"><div class="ct">'
350
- +'<div class="ct-title">'+TITULOS_L1[i]+'</div>'
351
- +'<div class="ct-sub">'+SUBS_L1[i]+'</div></div>'
352
- +'<div class="cp" id="l1_'+i+'"></div></div>';
353
- }}
354
  var r2 = document.getElementById('row2');
355
- for(var i=0;i<3;i++) {{
356
  r2.innerHTML += '<div class="cb"><div class="ct">'
357
- +'<div class="ct-title">'+TITULOS_L2[i]+'</div>'
358
- +'<div class="ct-sub">'+SUBS_L2[i]+'</div></div>'
359
- +'<div class="cp" id="l2_'+i+'"></div></div>';
360
- }}
361
- }}
362
 
363
- function gsize(id) {{
364
  var el = document.getElementById(id);
365
- if(!el) return [200,150];
366
  return [el.clientWidth||200, el.clientHeight||150];
367
- }}
 
368
  var tickvals = Object.keys(MESES).map(Number);
369
- var ticktext = Object.values(MESES);
370
-
371
- function fmtDot(v) {{
372
- var n=Math.round(v); if(n===0) return '0';
373
- return n.toString().replace(/\\B(?=(\\d{{3}})+(?!\\d))/g,'.');
374
- }}
375
-
376
- function renderBarH(item, elId) {{
377
- var wh=gsize(elId), el=document.getElementById(elId);
378
- var meses=item.meses_ex||[], data=[];
379
- var yOrder=meses.map(function(m){{return MESES[m];}}).slice().reverse();
380
- var maxVal=0;
381
- item.traces.forEach(function(t){{t.y.forEach(function(v){{if(v>maxVal)maxVal=v;}});}});
382
- item.traces.forEach(function(t) {{
383
- var yV=t.y.slice().reverse();
384
- data.push({{type:'bar',orientation:'h',name:t.ano,
385
- x:yV,y:yOrder,
386
- text:yV.map(function(){{return '';}}),
387
- marker:{{color:t.cor,line:{{color:'white',width:1}}}},
388
- hovertemplate:'<b>'+t.ano+' - %{{y}}</b><br>%{{x:,.0f}}<extra></extra>'}});
389
- }});
390
- var annotations=[];
391
- var accum=[];
392
- yOrder.forEach(function(){{accum.push(0);}});
393
- item.traces.forEach(function(t) {{
394
- var yV=t.y.slice().reverse();
395
- yV.forEach(function(v,i) {{
396
- if(!v){{accum[i]+=(v||0);return;}}
397
- var xStart=accum[i];
398
- var segW=v;
399
- var minInside=maxVal*0.10;
400
- var inside=segW>=minInside;
401
- annotations.push({{
402
- x: inside ? xStart+segW/2 : xStart+segW+maxVal*0.015,
 
 
 
 
 
 
 
403
  y: yOrder[i],
404
- xref:'x',yref:'y',showarrow:false,
405
  xanchor: inside?'center':'left',
406
  yanchor:'middle',
407
  text:'<b>'+fmtDot(v)+'</b>',
408
- font:{{size:11,family:'DM Sans',color: inside?'white':'#1e3a5f'}}
409
- }});
410
- accum[i]+=v;
411
- }});
412
- }});
413
- var layout={{width:wh[0],height:wh[1],autosize:false,barmode:'stack',
414
- template:'plotly_white',showlegend:false,font:{{family:'DM Sans',size:11}},
415
- margin:{{l:70,r:56,t:8,b:18}},plot_bgcolor:'rgba(248,250,252,0.8)',
416
- paper_bgcolor:'white',bargap:0.28,annotations:annotations,
417
- xaxis:{{tickfont:{{size:9,family:'DM Sans',color:'#9ca3af'}},showgrid:true,
418
- gridcolor:'rgba(200,200,200,0.25)',rangemode:'tozero',zeroline:false,range:[0,maxVal*1.25]}},
419
- yaxis:{{tickfont:{{size:11,family:'DM Sans',color:'#374151'}},
420
- showgrid:false,categoryorder:'array',categoryarray:yOrder,ticksuffix:' '}},
421
- hoverlabel:{{bgcolor:'white',font_size:12,font_family:'DM Sans'}}}};
422
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
423
- else Plotly.react(el,data,layout);
424
- roundBarsH(el);
425
- }}
426
-
427
- function roundBarsH(boxEl) {{
428
- setTimeout(function() {{
429
- boxEl.querySelectorAll('.bars path').forEach(function(p) {{
430
- var d=p.getAttribute('d'); if(!d) return;
431
- var segs=d.match(/[A-Za-z][^A-Za-z]*/g)||[],cx=0,cy=0,aX=[],aY=[];
432
- segs.forEach(function(tok){{
433
- var cmd=tok[0];
434
- var pts=tok.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){{return !isNaN(n);}});
435
- if(cmd==='M'||cmd==='L'){{aX.push(pts[0]);aY.push(pts[1]);cx=pts[0];cy=pts[1];}}
436
- else if(cmd==='H'){{aX.push(pts[0]);aY.push(cy);cx=pts[0];}}
437
- else if(cmd==='V'){{aX.push(cx);aY.push(pts[0]);cy=pts[0];}}
438
- }});
439
- if(aX.length<2) return;
440
- var xLeft=Math.min.apply(null,aX),xRight=Math.max.apply(null,aX);
441
- var yTop=Math.min.apply(null,aY),yBot=Math.max.apply(null,aY);
442
- var w=xRight-xLeft,h=yBot-yTop;
443
- if(w<4||h<2) return;
444
- var r=Math.min(8,h/2);
445
- var nd='M '+xLeft+','+yTop+' H '+(xRight-r)+' Q '+xRight+','+yTop+' '+xRight+','+(yTop+r)
446
- +' V '+(yBot-r)+' Q '+xRight+','+yBot+' '+(xRight-r)+','+yBot+' H '+xLeft+' V '+yTop+' Z';
447
- p.setAttribute('d',nd);
448
- }});
449
- }},80);
450
- }}
451
-
452
- function renderBarV(item, elId) {{
453
- var wh=gsize(elId), el=document.getElementById(elId);
454
- var meses=item.meses_ex||[], data=[];
455
- var maxByMes={{}};
456
- meses.forEach(function(m,mi){{var s=0;item.traces.forEach(function(t){{s+=t.y[mi]||0;}});maxByMes[mi]=s;}});
457
- var globalMax=Math.max.apply(null,meses.map(function(_,mi){{return maxByMes[mi];}}));
458
- var annotations=[];
459
- meses.forEach(function(m,mi) {{
460
- var tot=maxByMes[mi];
461
- if(!tot) return;
462
- annotations.push({{x:MESES[m],y:tot,xref:'x',yref:'y',showarrow:false,
463
  yanchor:'bottom',xanchor:'center',yshift:5,
464
- text:'<b>'+fmtDot(tot)+'</b>',font:{{size:11,family:'DM Sans',color:'#1e3a5f'}}}});
465
- }});
466
- var accumV=[];
467
- meses.forEach(function(){{accumV.push(0);}});
468
- item.traces.forEach(function(t) {{
469
- var segAnnots=[];
470
- t.y.forEach(function(v,mi) {{
471
- if(v>0) {{
472
- var yBase=accumV[mi];
473
- var minH=globalMax*0.08;
474
- if(v>=minH) {{
475
- segAnnots.push({{x:MESES[meses[mi]],y:yBase+v/2,xref:'x',yref:'y',showarrow:false,
476
  xanchor:'center',yanchor:'middle',
477
- text:'<b>'+fmtDot(v)+'</b>',font:{{size:10,family:'DM Sans',color:'white'}}}});
478
- }}
479
- accumV[mi]+=v;
480
- }} else {{accumV[mi]+=(v||0);}}
481
- }});
482
- segAnnots.forEach(function(a){{annotations.push(a);}});
483
- data.push({{type:'bar',name:t.ano,
484
- x:meses.map(function(m){{return MESES[m];}}),y:t.y,
485
- text:t.y.map(function(){{return '';}}),
486
- marker:{{color:t.cor,line:{{color:'white',width:1}}}},
487
- hovertemplate:'<b>'+t.ano+' - %{{x}}</b><br>%{{y:,.0f}}<extra></extra>'}});
488
- }});
489
- var layout={{width:wh[0],height:wh[1],autosize:false,barmode:'stack',
490
- template:'plotly_white',showlegend:false,font:{{family:'DM Sans',size:11}},
491
- margin:{{l:34,r:16,t:30,b:32}},plot_bgcolor:'rgba(248,250,252,0.8)',
492
- paper_bgcolor:'white',bargap:0.28,annotations:annotations,
493
- xaxis:{{tickfont:{{size:11,family:'DM Sans',color:'#374151'}},showgrid:false,tickangle:0}},
494
- yaxis:{{tickfont:{{size:9,family:'DM Sans',color:'#9ca3af'}},showgrid:true,
495
- gridcolor:'rgba(200,200,200,0.25)',zeroline:false,range:[0,globalMax*1.20]}},
496
- hoverlabel:{{bgcolor:'white',font_size:12,font_family:'DM Sans'}}}};
497
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
498
- else Plotly.react(el,data,layout);
499
- roundBarsV(el);
500
- }}
501
-
502
- function roundBarsV(boxEl) {{
503
- setTimeout(function() {{
504
- boxEl.querySelectorAll('.bars path').forEach(function(p) {{
505
- var d=p.getAttribute('d'); if(!d) return;
506
- var toks=d.match(/[A-Za-z][^A-Za-z]*/g)||[],cx=0,cy=0,aX=[],aY=[];
507
- toks.forEach(function(tok){{
508
- var cmd=tok[0];
509
- var pts=tok.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){{return !isNaN(n);}});
510
- if(cmd==='M'||cmd==='L'){{aX.push(pts[0]);aY.push(pts[1]);cx=pts[0];cy=pts[1];}}
511
- else if(cmd==='H'){{aX.push(pts[0]);aY.push(cy);cx=pts[0];}}
512
- else if(cmd==='V'){{aX.push(cx);aY.push(pts[0]);cy=pts[0];}}
513
- }});
514
- if(aX.length<2) return;
515
- var x0=Math.min.apply(null,aX),x1=Math.max.apply(null,aX);
516
- var yTop=Math.min.apply(null,aY),yBot=Math.max.apply(null,aY);
517
- var w=x1-x0,h=yBot-yTop; if(w<2||h<4) return;
518
- var r=Math.min(8,w/2);
519
- var nd='M '+x0+','+yBot+' H '+x1+' V '+(yTop+r)
520
- +' Q '+x1+','+yTop+' '+(x1-r)+','+yTop
521
- +' H '+(x0+r)+' Q '+x0+','+yTop+' '+x0+','+(yTop+r)+' V '+yBot+' Z';
522
- p.setAttribute('d',nd);
523
- }});
524
- }},80);
525
- }}
526
-
527
- function renderLine(item, elId) {{
528
- var wh=gsize(elId), el=document.getElementById(elId);
529
- var data=[], annotations=[], yshifts=[18,32,46,60];
530
- item.traces.forEach(function(t,i) {{
531
- data.push({{type:'scatter',mode:'lines+markers',x:t.meses,y:t.y,
532
- line:{{width:3,color:t.cor,shape:'spline'}},
533
- marker:{{size:8,color:t.cor,line:{{width:2,color:'white'}}}},
534
- fill:i>0?'tonexty':null,fillcolor:t.fill_rgba,
535
- cliponaxis:false,showlegend:false,name:t.ano,
536
- hovertemplate:'<b>'+t.ano+' - %{{customdata}}</b><br>%{{y:,.0f}}<extra></extra>',
537
- customdata:t.meses.map(function(m){{return MESES[m];}})
538
- }});
539
- var shift=yshifts[i%yshifts.length];
540
- t.meses.forEach(function(mx,j) {{
541
- if(!t.text[j]) return;
542
- annotations.push({{x:mx,y:t.y[j],xref:'x',yref:'y',text:t.text[j],
543
  showarrow:false,yshift:shift,
544
- font:{{size:11,family:'DM Sans',color:'#1a1a1a'}},bgcolor:'rgba(0,0,0,0)'}});
545
- }});
546
- }});
547
- var layout={{width:wh[0],height:wh[1],autosize:false,
548
- hovermode:'x unified',template:'plotly_white',
549
- font:{{family:'DM Sans',size:9}},showlegend:false,
550
- margin:{{l:38,r:18,t:30,b:38}},plot_bgcolor:'rgba(248,249,250,0.5)',annotations:annotations,
551
- xaxis:{{tickmode:'array',tickvals:tickvals,ticktext:ticktext,tickangle:0,
552
- tickfont:{{size:9,family:'DM Sans'}},showgrid:true,gridcolor:'rgba(200,200,200,0.2)',ticklabelstandoff:8}},
553
- yaxis:{{tickfont:{{size:9,family:'DM Sans'}},rangemode:'tozero',
554
- showgrid:true,gridcolor:'rgba(200,200,200,0.2)'}},
555
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans'}}}};
556
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
557
- else Plotly.react(el,data,layout);
558
- }}
559
-
560
- function renderDonut(item, elId) {{
561
- if(!item.valores.length) return;
562
- var wh=gsize(elId), el=document.getElementById(elId);
563
- var total=item.valores.reduce(function(s,v){{return s+v;}},0);
564
- var labels=item.labels.map(function(l,i) {{
565
- var pct=total>0?(item.valores[i]/total*100).toFixed(1):'0.0';
 
 
 
 
 
 
 
566
  return l+': '+fmtDot(item.valores[i])+' ('+pct+'%)';
567
- }});
568
- var textF=item.valores.map(function(v){{return total>0?(v/total*100).toFixed(1)+'%':'';}});
569
- var data=[{{type:'pie',labels:labels,values:item.valores,
570
- marker:{{colors:item.cores,line:{{color:'white',width:3}}}},
571
- hole:0.4,text:textF,textinfo:'text',
572
- textfont:{{size:12,family:'Sora',weight:700}},insidetextorientation:'radial',
573
- hovertemplate:'<b>%{{label}}</b><extra></extra>',
574
- pull:item.valores.map(function(v){{return v===Math.max.apply(null,item.valores)?0.03:0;}}),
575
- rotation:90,direction:'clockwise',sort:false}}];
576
- var layout={{width:wh[0],height:wh[1],autosize:false,template:'plotly_white',
577
- font:{{family:'DM Sans',size:9}},showlegend:true,
578
- legend:{{orientation:'v',yanchor:'middle',y:0.5,xanchor:'left',x:1.02,font:{{size:8,family:'DM Sans',color:'#333'}}}},
579
- margin:{{l:10,r:130,t:10,b:10}},hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans'}}}};
580
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
581
- else Plotly.react(el,data,layout);
582
- }}
583
-
584
- function renderLineSis(item, elId) {{
585
- if(!item.valores.length) return;
586
- var wh=gsize(elId), el=document.getElementById(elId);
587
- var total=item.valores.reduce(function(s,v){{return s+v;}},0);
588
- var xVals=item.labels.map(function(_,i){{return i;}});
589
- var texts=item.valores.map(function(v) {{
590
- var pct=total>0?(v/total*100).toFixed(1):'0.0';
591
- return v>0?'<b>'+fmtDot(v)+'</b><br>'+pct+'%':'';
592
- }});
593
- var cor=item.cores[0]||'#0056A3';
594
- var r=parseInt(cor.slice(1,3),16),g=parseInt(cor.slice(3,5),16),b=parseInt(cor.slice(5,7),16);
595
- var fill='rgba('+r+','+g+','+b+',0.2)';
596
- var data=[{{type:'scatter',mode:'lines+markers+text',x:xVals,y:item.valores,
597
- text:texts,textposition:'top center',textfont:{{size:11,family:'DM Sans',color:'#1a1a1a'}},
598
- line:{{width:4,color:cor,shape:'spline'}},marker:{{size:14,color:item.cores,symbol:'circle'}},
599
- fill:'tozeroy',fillcolor:fill,
600
- hovertemplate:'<b>%{{customdata}}</b><br>%{{y:,.0f}}<extra></extra>',
601
- customdata:item.labels,showlegend:false,cliponaxis:false}}];
602
- var layout={{width:wh[0],height:wh[1],autosize:false,
603
- template:'plotly_white',showlegend:false,font:{{family:'DM Sans',size:9}},
604
- margin:{{l:10,r:10,t:30,b:72}},plot_bgcolor:'rgba(248,249,250,0.5)',paper_bgcolor:'white',
605
- xaxis:{{tickmode:'array',tickvals:xVals,ticktext:item.labels,
606
- tickfont:{{size:8,family:'DM Sans',color:'#333'}},tickangle:-35,
607
- showgrid:false,zeroline:false,showline:false,ticks:'',automargin:true}},
608
- yaxis:{{showgrid:true,gridcolor:'rgba(200,200,200,0.35)',gridwidth:1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  zeroline:false,showline:false,showticklabels:false,ticks:'',
610
- range:[0,Math.max.apply(null,item.valores)*1.6]}},
611
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans'}}}};
612
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
613
- else Plotly.react(el,data,layout);
614
- }}
615
-
616
- function renderBarVSis(item, elId) {{
617
- if(!item.valores.length) return;
618
- var wh=gsize(elId), el=document.getElementById(elId);
619
- var total=item.valores.reduce(function(s,v){{return s+v;}},0);
620
- var maxVal=Math.max.apply(null,item.valores);
621
- var annotations=item.labels.map(function(l,i) {{
622
- var v=item.valores[i];
623
- var pct=total>0?(v/total*100).toFixed(1):'0.0';
624
- return {{x:l,y:v,xref:'x',yref:'y',showarrow:false,
625
- yanchor:'bottom',xanchor:'center',yshift:4,
 
 
 
 
626
  text:'<b>'+fmtDot(v)+'</b><br><span style="font-size:10px">'+pct+'%</span>',
627
- font:{{size:11,family:'DM Sans',color:'#1e3a5f'}}}};
628
- }});
629
- var data=[{{type:'bar',x:item.labels,y:item.valores,
630
- text:item.valores.map(function(){{return '';}}),
631
- marker:{{color:item.cores,line:{{color:'white',width:2}}}},
632
- hovertemplate:'<b>%{{x}}</b><br>%{{y:,.0f}}<extra></extra>',
633
- width:0.6,showlegend:false}}];
634
- var layout={{width:wh[0],height:wh[1],autosize:false,
635
- template:'plotly_white',showlegend:false,font:{{family:'DM Sans',size:11}},
636
- margin:{{l:30,r:18,t:40,b:72}},plot_bgcolor:'rgba(248,249,250,0.6)',
637
- paper_bgcolor:'white',bargap:0.3,annotations:annotations,
638
- xaxis:{{tickfont:{{size:9,family:'DM Sans',color:'#374151'}},showgrid:false,tickangle:-30,automargin:true}},
639
- yaxis:{{tickfont:{{size:9,family:'DM Sans'}},showgrid:true,
640
- gridcolor:'rgba(200,200,200,0.35)',range:[0,maxVal*1.35],showticklabels:false,zeroline:false}},
641
- hoverlabel:{{bgcolor:'white',font_size:12,font_family:'DM Sans'}}}};
642
- if(!initialized[elId]){{Plotly.newPlot(el,data,layout,{{displayModeBar:false,responsive:false}});initialized[elId]=true;}}
643
- else Plotly.react(el,data,layout);
644
- roundBarsV(el);
645
- }}
646
-
647
- function renderFrame(f) {{
648
- CARDS_CFG.forEach(function(c) {{
649
- var cd=f.cards[c.col];
650
- if(cd) document.getElementById('mv_'+c.col).textContent=cd.valor+cd.sufixo;
651
- }});
652
- f.linha1.forEach(function(item,i) {{
653
- if(item.tipo==='barra_h') renderBarH(item,'l1_'+i);
654
- else if(item.tipo==='barra_v') renderBarV(item,'l1_'+i);
655
- else if(item.tipo==='linha') renderLine(item,'l1_'+i);
656
- }});
657
- f.linha2.forEach(function(item,i) {{
658
- if(item.tipo==='donut') renderDonut(item,'l2_'+i);
659
- else if(item.tipo==='linha_sis') renderLineSis(item,'l2_'+i);
660
- else if(item.tipo==='barv_sis') renderBarVSis(item,'l2_'+i);
661
- }});
662
- }}
663
-
664
- function stepFrame() {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  renderFrame(FRAMES[frameIdx]);
666
  frameIdx++;
667
- if(frameIdx>=FRAMES.length) {{
668
  clearTimeout(animTimer);
669
- cdownTimer=setTimeout(function(){{frameIdx=0;startAnimation();}},PAUSE_MS);
670
- }} else {{
671
- animTimer=setTimeout(stepFrame,FRAME_MS);
672
- }}
673
- }}
674
 
675
- function startAnimation() {{
676
- clearTimeout(animTimer); clearTimeout(cdownTimer);
677
- animTimer=setTimeout(stepFrame,FRAME_MS);
678
- }}
 
679
 
680
  var _rt;
681
- window.addEventListener('resize',function(){{
682
  clearTimeout(_rt);
683
- _rt=setTimeout(function(){{
684
- document.querySelectorAll('.cp').forEach(function(box){{
685
- if(box.data) Plotly.relayout(box,{{width:box.clientWidth,height:box.clientHeight}});
686
- }});
687
- }},200);
688
- }});
689
-
690
- window.addEventListener('load',function(){{
691
  buildDOM();
692
- setTimeout(function(){{frameIdx=0;startAnimation();}},400);
693
- }});
694
  </script>
695
- </body></html>"""
 
 
 
 
 
 
 
 
 
 
696
 
697
- components.html(html, height=8000, scrolling=False)
698
 
699
  else:
700
- components.html("""<!DOCTYPE html><html><body style='display:flex;align-items:center;
701
- justify-content:center;height:100dvh;font-family:sans-serif;color:#003a70;background:#f5f7fa;'>
702
- <div style='text-align:center'><div style='font-size:2em'>📊</div>
703
  <p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
704
  height=4000, scrolling=False)
705
 
 
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard Expedição")
10
 
11
+ st.markdown("""<style>
12
+ #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
13
+ [data-testid="stStatusWidget"]{display:none!important}
14
+ .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
15
+ iframe{position:fixed!important;top:0!important;left:0!important;
16
+ width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
17
+ [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
18
+ height:0!important;overflow:visible!important}
19
+ .main,[data-testid="stAppViewContainer"]{background:#f5f7fa!important}
20
+ @media(max-width:900px){
21
+ iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
22
+ .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
23
+ .block-container{height:auto!important}
24
+ }
25
+ </style>""", unsafe_allow_html=True)
26
+
27
  # ── SESSION ───────────────────────────────────────────────────────────
28
  _cc = CookieController()
29
  COOKIE_NAME = "pmja_session"
 
54
  st.session_state.inicio_exibicao = time.time()
55
  st.switch_page("pages/full.py")
56
 
57
+ # ── LOADING ───────────────────────────────────────────────────────────
58
+ loading_placeholder = st.empty()
59
+ loading_placeholder.markdown("""
60
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=Sora:wght@800&display=swap" rel="stylesheet">
61
+ <style>
62
+ #ld{position:fixed;inset:0;z-index:999999;background:linear-gradient(135deg,#003a70,#0056A3);
63
+ display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:'DM Sans',sans-serif}
64
+ #ld .lg{display:flex;align-items:center;gap:24px;margin-bottom:36px}
65
+ #ld img{height:34px;filter:brightness(0) invert(1);opacity:.9}
66
+ #ld .dv{width:1px;height:32px;background:rgba(255,255,255,.25)}
67
+ #ld h2{font-family:'Sora',sans-serif;font-size:clamp(15px,4vw,21px);color:#fff;font-weight:800;margin-bottom:4px;text-align:center}
68
+ #ld p{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);font-weight:600;margin-bottom:40px;text-align:center}
69
+ #ld .sp{width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;
70
+ border-radius:50%;animation:sp .8s linear infinite;margin-bottom:24px}
71
+ @keyframes sp{to{transform:rotate(360deg)}}
72
+ #ld .bw{width:min(260px,70vw);height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:14px}
73
+ #ld .bf{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
74
+ #ld .st{font-size:11px;color:rgba(255,255,255,.6);font-weight:600;text-align:center}
75
+ #ld .dt span{animation:dt 1.2s infinite;opacity:0}
76
+ #ld .dt span:nth-child(2){animation-delay:.2s}
77
+ #ld .dt span:nth-child(3){animation-delay:.4s}
78
+ @keyframes dt{0%,100%{opacity:0}50%{opacity:1}}
79
+ </style>
80
+ <div id="ld">
81
+ <div class="lg">
82
+ <img src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
83
+ <div class="dv"></div>
84
+ <img src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
85
+ </div>
86
+ <div class="sp"></div>
87
+ <h2>PMJA — Dashboard Expedição</h2>
88
+ <p>Gestão de Materiais</p>
89
+ <div class="bw"><div class="bf" id="ldbf"></div></div>
90
+ <div class="st" id="ldst">Conectando ao Google Sheets<span class="dt"><span>.</span><span>.</span><span>.</span></span></div>
91
+ </div>
92
+ <script>
93
+ (function(){
94
+ var s=[{p:18,m:'Conectando ao Google Sheets'},{p:40,m:'Carregando dados de expedição'},
95
+ {p:62,m:'Processando métricas'},{p:80,m:'Montando gráficos'},{p:95,m:'Quase pronto'}];
96
+ var i=0;
97
+ function n(){
98
+ if(i>=s.length)return;
99
+ var x=s[i++];
100
+ var b=document.getElementById('ldbf'),t=document.getElementById('ldst');
101
+ if(b)b.style.width=x.p+'%';
102
+ if(t)t.innerHTML=x.m+'<span class="dt"><span>.</span><span>.</span><span>.</span></span>';
103
+ setTimeout(n,950+Math.random()*550);
104
+ }
105
+ setTimeout(n,300);
106
+ })();
107
+ </script>
108
+ """, unsafe_allow_html=True)
109
+
110
+ # ── LOAD DATA ─────────────────────────────────────────────────────────
111
+ df_exp_raw = read_ws("exp_dados")
112
+ loading_placeholder.empty()
113
 
114
  # ── HELPERS ───────────────────────────────────────────────────────────
115
  def fmt(n):
116
  if pd.isna(n): return "0"
117
+ return "{:,}".format(int(n)).replace(',', '.')
 
 
 
 
 
 
 
 
118
 
119
  def extrair_categoria(col):
120
+ for s in [' Requisições',' Requisiçoes',' Unid. Itens',' Unid Itens',
121
+ ' Itens por unidade',' Items por unidade']:
122
  col = col.replace(s, '')
123
  return col.strip()
124
 
 
131
  df = df.dropna(subset=['data']).copy()
132
  df['ano'] = df['data'].dt.year
133
  df['mes_num'] = df['data'].dt.month
 
134
  req, unid, itens = [], [], []
135
  for col in df.columns:
136
+ c = col.lower().strip().replace('.','').replace(' ',' ')
137
  if 'requisi' in c and 'unid' not in c and 'por' not in c: req.append(col)
138
  if 'unid' in c and 'iten' in c and 'por' not in c: unid.append(col)
139
  if 'iten' in c and 'por' in c and 'unid' in c: itens.append(col)
140
+ for col in req+unid+itens:
141
+ if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
 
 
 
142
  df['qtd_requisicoes'] = df[req].sum(axis=1)
143
  df['qtd_unidades_emitidas'] = df[unid].sum(axis=1)
144
  df['qtd_itens_total'] = df[itens].sum(axis=1) if itens else 0
145
+ df_final = df[['mes_num','ano','qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total']]\
146
+ .sort_values(['ano','mes_num']).reset_index(drop=True)
147
+ df_det = df[['mes_num','ano']+req+unid+itens]\
148
+ .sort_values(['ano','mes_num']).reset_index(drop=True)
149
  return df_final, df_det, req, unid, itens
150
 
151
  # ── BUILD FRAMES ──────────────────────────────────────────────────────
152
  def build_frames(df_plot, df_det, req_cols, unid_cols, itens_cols, anos_unicos, mes_max, CORES_EXP):
153
  MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
154
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
 
155
  metricas_linha = [
156
+ {'col':'qtd_requisicoes', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
157
+ {'col':'qtd_unidades_emitidas','cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
158
+ {'col':'qtd_itens_total', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef']},
159
  ]
160
+ tipos_l1 = ['barra_h','barra_v','linha']
 
161
  frames = []
162
+ for mes_atual in range(1, mes_max+1):
163
  frame = {"mes": mes_atual, "cards": {}, "linha1": [], "linha2": []}
 
164
  df_ate = df_plot[df_plot['mes_num'] <= mes_atual]
165
  df_det_ate = df_det[df_det['mes_num'] <= mes_atual]
 
 
166
  for col, tit, cor, suf in [
167
+ ('qtd_requisicoes', 'Requisições', '#003a70',''),
168
+ ('qtd_unidades_emitidas','Unidades Emitidas','#005a9c',''),
169
+ ('qtd_itens_total', 'Total de Itens', '#0075be',''),
170
  ]:
171
  frame["cards"][col] = {"valor": fmt(df_ate[col].sum()), "cor": cor, "titulo": tit, "sufixo": suf}
 
 
172
  meses_ex = sorted(df_plot[df_plot['mes_num'] <= mes_atual]['mes_num'].unique())
173
  for idx_m, cfg in enumerate(metricas_linha):
174
  col = cfg['col']
175
  traces = []
176
  for idx_ano, ano in enumerate(anos_unicos):
177
+ d = df_plot[(df_plot['ano']==ano)&(df_plot['mes_num']<=mes_atual)].sort_values('mes_num')
178
  if d.empty: continue
179
  cor = cfg['cores'][idx_ano % len(cfg['cores'])]
180
  vals, texts = [], []
181
  for m in meses_ex:
182
+ row = d[d['mes_num']==m]
183
  v = float(row[col].iloc[0]) if not row.empty else 0.0
184
+ vals.append(v); texts.append(fmt(v) if v > 0 else "")
 
185
  traces.append({
186
  "ano": str(ano), "cor": cor,
187
  "meses": meses_ex, "y": vals, "text": texts,
188
+ "fill_rgba": "rgba({},{},{},0.1)".format(int(cor[1:3],16),int(cor[3:5],16),int(cor[5:7],16))
189
  })
190
  frame["linha1"].append({"tipo": tipos_l1[idx_m], "traces": traces, "meses_ex": meses_ex})
191
+ for grp in [
 
 
192
  {'cols': req_cols, 'tipo': 'donut'},
193
  {'cols': unid_cols, 'tipo': 'linha_sis'},
194
  {'cols': itens_cols, 'tipo': 'barv_sis'},
195
+ ]:
 
196
  totais = {}
197
  for c in grp['cols']:
198
  cat = extrair_categoria(c)
199
  if cat not in totais:
200
  totais[cat] = {'v': 0.0, 'cor': CORES_EXP[len(totais) % len(CORES_EXP)]}
201
  totais[cat]['v'] += float(df_det_ate[c].sum()) if c in df_det_ate.columns else 0.0
202
+ dados = sorted([(k,v) for k,v in totais.items() if v['v']>0],
203
  key=lambda x: x[1]['v'], reverse=True)
204
  frame["linha2"].append({
205
  "tipo": grp['tipo'],
 
207
  "valores":[d[1]['v'] for d in dados],
208
  "cores": [d[1]['cor'] for d in dados],
209
  })
 
210
  frames.append(frame)
211
  return frames
212
 
213
+ # ── MAIN ──────────────────────────────────────────────────────────────
 
 
 
214
  result = proc_exp(df_exp_raw) if df_exp_raw is not None and not df_exp_raw.empty else None
215
 
216
  if result is not None:
217
  df_expedicao, df_det, req_cols, unid_cols, itens_cols = result
218
+ for col in ['qtd_requisicoes','qtd_unidades_emitidas','qtd_itens_total']:
 
219
  df_expedicao[col] = pd.to_numeric(df_expedicao[col], errors='coerce').fillna(0)
 
220
  mes_max = int(df_expedicao['mes_num'].max())
221
  anos_unicos = sorted(df_expedicao['ano'].unique())
222
+ CORES_EXP = ['#001a33','#002d4d','#003d66','#004d80','#005d99','#006db3',
223
+ '#007dcc','#1a8dd4','#3399dd','#4da6e6','#66b3ee','#80c0f5','#99ccff','#b3d9ff']
 
 
 
224
  MESES_PT = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
225
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
 
226
  metricas_cfg = [
227
+ {'col':'qtd_requisicoes', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
228
+ {'col':'qtd_unidades_emitidas','cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
229
+ {'col':'qtd_itens_total', 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'suf':''},
230
  ]
231
 
232
  def sub_anos(cfg):
233
  parts = []
234
  for i, ano in enumerate(anos_unicos):
235
  cor = cfg['cores'][i % len(cfg['cores'])]
236
+ total = fmt(df_expedicao[df_expedicao['ano']==ano][cfg['col']].sum())
237
+ parts.append("<span style='color:{};font-weight:700'>{}: {}{}</span>".format(cor, ano, total, cfg['suf']))
238
  return " <span style='color:#666'>|</span> ".join(parts)
239
 
240
  def sub_sistemas(cols):
241
  totais = {}
242
  for c in cols:
243
  cat = extrair_categoria(c)
244
+ if cat not in totais: totais[cat] = {'v':0.0,'cor':CORES_EXP[len(totais)%len(CORES_EXP)]}
 
245
  totais[cat]['v'] += float(df_det[c].sum()) if c in df_det.columns else 0.0
246
+ dados = sorted([(k,v) for k,v in totais.items() if v['v']>0],
247
  key=lambda x: x[1]['v'], reverse=True)
248
+ return " <span style='color:#666'>|</span> ".join(
249
+ ["<span style='color:{};font-weight:700'>{}: {}</span>".format(d[1]['cor'], d[0], fmt(d[1]['v']))
250
+ for d in dados])
 
251
 
252
+ frames = build_frames(df_expedicao, df_det, req_cols, unid_cols, itens_cols, anos_unicos, mes_max, CORES_EXP)
 
253
 
254
  import numpy as np
255
  class NpEncoder(json.JSONEncoder):
 
259
  if isinstance(obj, np.ndarray): return obj.tolist()
260
  return super().default(obj)
261
 
262
+ frames_json = json.dumps(frames, cls=NpEncoder, ensure_ascii=False)
263
+ meses_json = json.dumps(MESES_PT)
264
+ TITULOS_L1 = ['Requisições','Unidades Emitidas','Total de Itens']
265
+ TITULOS_L2 = ['Requisições por Sistema','Unidades Emitidas por Sistema','Total de Itens por Sistema']
266
+ subs_l1 = [sub_anos(c) for c in metricas_cfg]
267
+ subs_l2 = [sub_sistemas(req_cols), sub_sistemas(unid_cols), sub_sistemas(itens_cols)]
268
 
269
+ html = """<!DOCTYPE html>
270
+ <html>
271
+ <head>
272
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
273
+ <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">
 
 
274
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
275
  <style>
276
+ * { margin:0; padding:0; box-sizing:border-box; }
277
+ html, body { width:100%; height:100%; overflow:hidden;
278
  background:linear-gradient(135deg,#f5f7fa 0%,#e8eef5 100%);
279
+ font-family:'DM Sans',sans-serif; }
280
+ .root { display:grid; width:100%; height:100dvh; gap:3px; padding:4px; overflow:hidden;
281
+ grid-template-rows:auto auto 1fr 1fr; }
282
+ .hdr { background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
283
+ border-radius:8px; padding:4px 14px; display:flex; align-items:center;
284
+ justify-content:space-between; box-shadow:0 4px 12px rgba(0,58,112,.12); }
285
+ .logo { height:clamp(22px,3.2dvh,36px); filter:brightness(0) invert(1); opacity:.92; }
286
+ .ht { font-family:'Sora',sans-serif; font-size:clamp(12px,1.8dvh,20px);
287
+ color:#fff; font-weight:800; letter-spacing:-.5px; }
288
+ .hs { font-family:'DM Sans',sans-serif; font-size:clamp(9px,1.2dvh,14px);
289
+ color:rgba(255,255,255,.9); font-weight:700; }
290
+ .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:3px; }
291
+ .mc { background:linear-gradient(135deg,#ffffff 0%,#f8fafd 100%);
292
+ border-radius:8px; padding:clamp(4px,.6dvh,10px) clamp(6px,.7vw,14px);
293
+ border-left:4px solid; box-shadow:0 2px 12px rgba(0,58,112,.08); overflow:hidden; }
294
+ .ml { font-family:'DM Sans',sans-serif; font-size:clamp(7px,.85dvh,11px);
295
+ color:#666; font-weight:700; text-transform:uppercase;
296
+ letter-spacing:.3px; margin-bottom:2px; white-space:nowrap; }
297
+ .mv { font-family:'Sora',sans-serif; font-size:clamp(13px,2dvh,26px);
298
+ font-weight:800; line-height:1; white-space:nowrap; }
299
+ .row3 { display:grid; grid-template-columns:repeat(3,1fr); gap:3px; min-height:0; }
300
+ .cb { background:white; border-radius:10px;
301
  box-shadow:0 2px 12px rgba(0,58,112,.07),0 1px 3px rgba(0,58,112,.04);
302
+ overflow:visible; min-height:0; display:flex; flex-direction:column;
303
+ border:1px solid rgba(0,58,112,.04); }
304
+ .ct { background:linear-gradient(180deg,#fafbfd 0%,#ffffff 100%);
305
+ padding:4px 8px 3px; border-radius:10px 10px 0 0;
306
+ border-bottom:1px solid #eef1f6; flex-shrink:0; }
307
+ .ct-title { font-family:'Sora',sans-serif; font-size:clamp(9px,1.15dvh,15px);
308
+ color:#1a1a1a; font-weight:800; text-align:center; }
309
+ .ct-sub { text-align:center; font-size:clamp(7px,.8dvh,10px);
310
+ font-weight:700; margin-top:1px; line-height:1.3; font-family:'DM Sans',sans-serif; }
311
+ .cp { flex:1; min-height:0; overflow:visible; }
312
+ @media(max-width:900px){
313
+ html,body { overflow:hidden!important; }
314
+ .root { height:100dvh!important; overflow:hidden!important; }
315
+ .cards { grid-template-columns:repeat(3,1fr)!important; }
316
+ .row3 { grid-template-columns:repeat(2,1fr)!important; }
317
+ .cb { min-height:0!important; }
318
+ .cp { min-height:0!important; }
319
+ }
320
+ @media(max-width:600px){
321
+ html,body { overflow-y:auto!important; height:auto!important; }
322
+ .root { height:auto!important; overflow:visible!important; grid-template-rows:auto!important; }
323
+ .cards { grid-template-columns:1fr 1fr!important; }
324
+ .row3 { grid-template-columns:1fr!important; }
325
+ .cb { height:260px!important; }
326
+ .cp { height:210px!important; flex:none!important; }
327
+ .hs { display:none; }
328
+ .hdr { padding:4px 8px!important; }
329
+ }
330
  </style>
331
+ </head>
332
+ <body>
333
  <div class="root">
334
  <div class="hdr">
335
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
 
343
  <div class="row3" id="row1"></div>
344
  <div class="row3" id="row2"></div>
345
  </div>
 
346
  <script>
347
+ var FRAMES = __FRAMES__;
348
+ var MESES = __MESES__;
349
+ var TITULOS_L1 = __TITULOS_L1__;
350
+ var TITULOS_L2 = __TITULOS_L2__;
351
+ var SUBS_L1 = __SUBS_L1__;
352
+ var SUBS_L2 = __SUBS_L2__;
353
 
354
  var CARDS_CFG = [
355
+ {col:'qtd_requisicoes', titulo:'Requisições', cor:'#003a70', sufixo:''},
356
+ {col:'qtd_unidades_emitidas', titulo:'Unidades Emitidas', cor:'#005a9c', sufixo:''},
357
+ {col:'qtd_itens_total', titulo:'Total de Itens', cor:'#0075be', sufixo:''}
358
  ];
359
 
360
+ var FRAME_MS = 700;
361
+ var PAUSE_MS = 60000;
362
+ var frameIdx = 0;
363
+ var animTimer = null;
364
+ var cdownTimer= null;
365
+ var inited = {};
366
 
367
+ function buildDOM() {
368
  var cr = document.getElementById('cards-row');
369
+ CARDS_CFG.forEach(function(c) {
370
  cr.innerHTML += '<div class="mc" style="border-left-color:'+c.cor+'">'
371
+ + '<div class="ml">'+c.titulo+'</div>'
372
+ + '<div class="mv" id="mv_'+c.col+'" style="color:'+c.cor+'">0</div></div>';
373
+ });
374
  var r1 = document.getElementById('row1');
375
+ for (var i=0; i<3; i++) {
376
  r1.innerHTML += '<div class="cb"><div class="ct">'
377
+ + '<div class="ct-title">'+TITULOS_L1[i]+'</div>'
378
+ + '<div class="ct-sub">'+SUBS_L1[i]+'</div></div>'
379
+ + '<div class="cp" id="l1_'+i+'"></div></div>';
380
+ }
381
  var r2 = document.getElementById('row2');
382
+ for (var i=0; i<3; i++) {
383
  r2.innerHTML += '<div class="cb"><div class="ct">'
384
+ + '<div class="ct-title">'+TITULOS_L2[i]+'</div>'
385
+ + '<div class="ct-sub">'+SUBS_L2[i]+'</div></div>'
386
+ + '<div class="cp" id="l2_'+i+'"></div></div>';
387
+ }
388
+ }
389
 
390
+ function gsize(id) {
391
  var el = document.getElementById(id);
392
+ if (!el) return [200,150];
393
  return [el.clientWidth||200, el.clientHeight||150];
394
+ }
395
+
396
  var tickvals = Object.keys(MESES).map(Number);
397
+ var ticktext = Object.values(MESES);
398
+
399
+ function fmtDot(v) {
400
+ var n = Math.round(v);
401
+ if (n===0) return '0';
402
+ var s=n.toString(), r='', c=0;
403
+ for (var i=s.length-1; i>=0; i--) {
404
+ if (c>0 && c%3===0) r='.'+r;
405
+ r=s[i]+r; c++;
406
+ }
407
+ return r;
408
+ }
409
+
410
+ function renderBarH(item, elId) {
411
+ var wh = gsize(elId);
412
+ var el = document.getElementById(elId);
413
+ var meses = item.meses_ex || [];
414
+ var data = [];
415
+ var yOrder = meses.map(function(m){ return MESES[m]; }).slice().reverse();
416
+ var maxVal = 0;
417
+ item.traces.forEach(function(t){ t.y.forEach(function(v){ if(v>maxVal) maxVal=v; }); });
418
+ item.traces.forEach(function(t) {
419
+ var yV = t.y.slice().reverse();
420
+ data.push({
421
+ type:'bar', orientation:'h', name:t.ano,
422
+ x:yV, y:yOrder,
423
+ text:yV.map(function(){ return ''; }),
424
+ marker:{color:t.cor, line:{color:'white',width:1}},
425
+ hovertemplate:'<b>'+t.ano+' - %{y}</b><br>%{x:,.0f}<extra></extra>'
426
+ });
427
+ });
428
+ var annotations = [];
429
+ var accum = yOrder.map(function(){ return 0; });
430
+ item.traces.forEach(function(t) {
431
+ var yV = t.y.slice().reverse();
432
+ yV.forEach(function(v, i) {
433
+ if (!v) { accum[i]+=(v||0); return; }
434
+ var xStart = accum[i];
435
+ var inside = v >= maxVal*0.10;
436
+ annotations.push({
437
+ x: inside ? xStart+v/2 : xStart+v+maxVal*0.015,
438
  y: yOrder[i],
439
+ xref:'x', yref:'y', showarrow:false,
440
  xanchor: inside?'center':'left',
441
  yanchor:'middle',
442
  text:'<b>'+fmtDot(v)+'</b>',
443
+ font:{size:11,family:'DM Sans',color:inside?'white':'#1e3a5f'}
444
+ });
445
+ accum[i] += v;
446
+ });
447
+ });
448
+ var layout = {
449
+ width:wh[0], height:wh[1], autosize:true, barmode:'stack',
450
+ template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
451
+ margin:{l:70,r:56,t:8,b:18},
452
+ plot_bgcolor:'rgba(248,250,252,0.8)', paper_bgcolor:'white',
453
+ bargap:0.28, annotations:annotations,
454
+ xaxis:{tickfont:{size:9,family:'DM Sans',color:'#9ca3af'},
455
+ showgrid:true, gridcolor:'rgba(200,200,200,0.25)',
456
+ rangemode:'tozero', zeroline:false, range:[0,maxVal*1.25]},
457
+ yaxis:{tickfont:{size:11,family:'DM Sans',color:'#374151'},
458
+ showgrid:false, categoryorder:'array', categoryarray:yOrder, ticksuffix:' '},
459
+ hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
460
+ };
461
+ if (!inited[elId]) {
462
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
463
+ inited[elId] = true;
464
+ } else { Plotly.react(el, data, layout); }
465
+ roundBars(el, 8, 'h');
466
+ }
467
+
468
+ function renderBarV(item, elId) {
469
+ var wh = gsize(elId);
470
+ var el = document.getElementById(elId);
471
+ var meses = item.meses_ex || [];
472
+ var data = [];
473
+ var maxByMes = {};
474
+ meses.forEach(function(m,mi) {
475
+ var s=0; item.traces.forEach(function(t){ s+=t.y[mi]||0; }); maxByMes[mi]=s;
476
+ });
477
+ var globalMax = Math.max.apply(null, meses.map(function(_,mi){ return maxByMes[mi]; }));
478
+ var annotations = [];
479
+ meses.forEach(function(m,mi) {
480
+ var tot = maxByMes[mi];
481
+ if (!tot) return;
482
+ annotations.push({x:MESES[m],y:tot,xref:'x',yref:'y',showarrow:false,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  yanchor:'bottom',xanchor:'center',yshift:5,
484
+ text:'<b>'+fmtDot(tot)+'</b>',font:{size:11,family:'DM Sans',color:'#1e3a5f'}});
485
+ });
486
+ var accumV = meses.map(function(){ return 0; });
487
+ item.traces.forEach(function(t) {
488
+ var segAnnots = [];
489
+ t.y.forEach(function(v,mi) {
490
+ if (v>0) {
491
+ var yBase = accumV[mi];
492
+ if (v >= globalMax*0.08) {
493
+ segAnnots.push({x:MESES[meses[mi]],y:yBase+v/2,xref:'x',yref:'y',showarrow:false,
 
 
494
  xanchor:'center',yanchor:'middle',
495
+ text:'<b>'+fmtDot(v)+'</b>',font:{size:10,family:'DM Sans',color:'white'}});
496
+ }
497
+ accumV[mi] += v;
498
+ } else { accumV[mi]+=(v||0); }
499
+ });
500
+ segAnnots.forEach(function(a){ annotations.push(a); });
501
+ data.push({type:'bar',name:t.ano,
502
+ x:meses.map(function(m){ return MESES[m]; }), y:t.y,
503
+ text:t.y.map(function(){ return ''; }),
504
+ marker:{color:t.cor, line:{color:'white',width:1}},
505
+ hovertemplate:'<b>'+t.ano+' - %{x}</b><br>%{y:,.0f}<extra></extra>'});
506
+ });
507
+ var layout = {
508
+ width:wh[0], height:wh[1], autosize:true, barmode:'stack',
509
+ template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
510
+ margin:{l:34,r:16,t:30,b:32},
511
+ plot_bgcolor:'rgba(248,250,252,0.8)', paper_bgcolor:'white',
512
+ bargap:0.28, annotations:annotations,
513
+ xaxis:{tickfont:{size:11,family:'DM Sans',color:'#374151'},showgrid:false,tickangle:0},
514
+ yaxis:{tickfont:{size:9,family:'DM Sans',color:'#9ca3af'},showgrid:true,
515
+ gridcolor:'rgba(200,200,200,0.25)',zeroline:false,range:[0,globalMax*1.20]},
516
+ hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
517
+ };
518
+ if (!inited[elId]) {
519
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
520
+ inited[elId] = true;
521
+ } else { Plotly.react(el, data, layout); }
522
+ roundBars(el, 8, 'v');
523
+ }
524
+
525
+ function renderLine(item, elId) {
526
+ var wh = gsize(elId);
527
+ var el = document.getElementById(elId);
528
+ var data = [], annotations = [], yshifts = [18,32,46,60];
529
+ item.traces.forEach(function(t, i) {
530
+ data.push({
531
+ type:'scatter', mode:'lines+markers',
532
+ x:t.meses, y:t.y,
533
+ line:{width:3, color:t.cor, shape:'spline'},
534
+ marker:{size:8, color:t.cor, line:{width:2,color:'white'}},
535
+ fill: i>0?'tonexty':null, fillcolor:t.fill_rgba,
536
+ cliponaxis:false, showlegend:false, name:t.ano,
537
+ hovertemplate:'<b>'+t.ano+' - %{customdata}</b><br>%{y:,.0f}<extra></extra>',
538
+ customdata:t.meses.map(function(m){ return MESES[m]; })
539
+ });
540
+ var shift = yshifts[i % yshifts.length];
541
+ t.meses.forEach(function(mx, j) {
542
+ if (!t.text[j]) return;
543
+ annotations.push({x:mx,y:t.y[j],xref:'x',yref:'y',text:t.text[j],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  showarrow:false,yshift:shift,
545
+ font:{size:11,family:'DM Sans',color:'#1a1a1a'},bgcolor:'rgba(0,0,0,0)'});
546
+ });
547
+ });
548
+ var layout = {
549
+ autosize:true,
550
+ hovermode:'x unified', template:'plotly_white',
551
+ font:{family:'DM Sans',size:9}, showlegend:false,
552
+ margin:{l:38,r:18,t:30,b:38},
553
+ plot_bgcolor:'rgba(248,249,250,0.5)', annotations:annotations,
554
+ xaxis:{tickmode:'array',tickvals:tickvals,ticktext:ticktext,tickangle:0,
555
+ tickfont:{size:9,family:'DM Sans'},showgrid:true,
556
+ gridcolor:'rgba(200,200,200,0.2)',ticklabelstandoff:8},
557
+ yaxis:{tickfont:{size:9,family:'DM Sans'},rangemode:'tozero',
558
+ showgrid:true,gridcolor:'rgba(200,200,200,0.2)'},
559
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
560
+ };
561
+ if (!inited[elId]) {
562
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
563
+ inited[elId] = true;
564
+ } else { Plotly.react(el, data, layout); }
565
+ }
566
+
567
+ function renderDonut(item, elId) {
568
+ if (!item.valores.length) return;
569
+ var wh = gsize(elId);
570
+ var el = document.getElementById(elId);
571
+ var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
572
+ var labels = item.labels.map(function(l,i) {
573
+ var pct = total>0 ? (item.valores[i]/total*100).toFixed(1) : '0.0';
574
  return l+': '+fmtDot(item.valores[i])+' ('+pct+'%)';
575
+ });
576
+ var textF = item.valores.map(function(v){ return total>0?(v/total*100).toFixed(1)+'%':''; });
577
+ var data = [{
578
+ type:'pie', labels:labels, values:item.valores,
579
+ marker:{colors:item.cores, line:{color:'white',width:3}},
580
+ hole:0.4, text:textF, textinfo:'text',
581
+ textfont:{size:12,family:'Sora'}, insidetextorientation:'radial',
582
+ hovertemplate:'<b>%{label}</b><extra></extra>',
583
+ pull:item.valores.map(function(v){ return v===Math.max.apply(null,item.valores)?0.03:0; }),
584
+ rotation:90, direction:'clockwise', sort:false
585
+ }];
586
+ var layout = {
587
+ autosize:true,
588
+ template:'plotly_white', font:{family:'DM Sans',size:9}, showlegend:true,
589
+ legend:{orientation:'v',yanchor:'middle',y:0.5,xanchor:'left',x:1.02,font:{size:8,family:'DM Sans',color:'#333'}},
590
+ margin:{l:10,r:130,t:10,b:10},
591
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
592
+ };
593
+ if (!inited[elId]) {
594
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
595
+ inited[elId] = true;
596
+ } else { Plotly.react(el, data, layout); }
597
+ }
598
+
599
+ function renderLineSis(item, elId) {
600
+ if (!item.valores.length) return;
601
+ var wh = gsize(elId);
602
+ var el = document.getElementById(elId);
603
+ var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
604
+ var xVals = item.labels.map(function(_,i){ return i; });
605
+ var texts = item.valores.map(function(v) {
606
+ var pct = total>0 ? (v/total*100).toFixed(1) : '0.0';
607
+ return v>0 ? '<b>'+fmtDot(v)+'</b><br>'+pct+'%' : '';
608
+ });
609
+ var cor = item.cores[0] || '#0056A3';
610
+ var r_c = parseInt(cor.slice(1,3),16);
611
+ var g_c = parseInt(cor.slice(3,5),16);
612
+ var b_c = parseInt(cor.slice(5,7),16);
613
+ var fill = 'rgba('+r_c+','+g_c+','+b_c+',0.2)';
614
+ var data = [{
615
+ type:'scatter', mode:'lines+markers+text',
616
+ x:xVals, y:item.valores,
617
+ text:texts, textposition:'top center',
618
+ textfont:{size:11,family:'DM Sans',color:'#1a1a1a'},
619
+ line:{width:4, color:cor, shape:'spline'},
620
+ marker:{size:14, color:item.cores, symbol:'circle'},
621
+ fill:'tozeroy', fillcolor:fill,
622
+ hovertemplate:'<b>%{customdata}</b><br>%{y:,.0f}<extra></extra>',
623
+ customdata:item.labels, showlegend:false, cliponaxis:false
624
+ }];
625
+ var layout = {
626
+ autosize:true,
627
+ template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:9},
628
+ margin:{l:10,r:10,t:30,b:72},
629
+ plot_bgcolor:'rgba(248,249,250,0.5)', paper_bgcolor:'white',
630
+ xaxis:{tickmode:'array',tickvals:xVals,ticktext:item.labels,
631
+ tickfont:{size:8,family:'DM Sans',color:'#333'},tickangle:-35,
632
+ showgrid:false,zeroline:false,showline:false,ticks:'',automargin:true},
633
+ yaxis:{showgrid:true,gridcolor:'rgba(200,200,200,0.35)',gridwidth:1,
634
  zeroline:false,showline:false,showticklabels:false,ticks:'',
635
+ range:[0,Math.max.apply(null,item.valores)*1.6]},
636
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans'}
637
+ };
638
+ if (!inited[elId]) {
639
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
640
+ inited[elId] = true;
641
+ } else { Plotly.react(el, data, layout); }
642
+ }
643
+
644
+ function renderBarVSis(item, elId) {
645
+ if (!item.valores.length) return;
646
+ var wh = gsize(elId);
647
+ var el = document.getElementById(elId);
648
+ var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
649
+ var maxVal = Math.max.apply(null, item.valores);
650
+ var annotations = item.labels.map(function(l,i) {
651
+ var v = item.valores[i];
652
+ var pct = total>0 ? (v/total*100).toFixed(1) : '0.0';
653
+ return {x:l, y:v, xref:'x', yref:'y', showarrow:false,
654
+ yanchor:'bottom', xanchor:'center', yshift:4,
655
  text:'<b>'+fmtDot(v)+'</b><br><span style="font-size:10px">'+pct+'%</span>',
656
+ font:{size:11,family:'DM Sans',color:'#1e3a5f'}};
657
+ });
658
+ var data = [{
659
+ type:'bar', x:item.labels, y:item.valores,
660
+ text:item.valores.map(function(){ return ''; }),
661
+ marker:{color:item.cores, line:{color:'white',width:2}},
662
+ hovertemplate:'<b>%{x}</b><br>%{y:,.0f}<extra></extra>',
663
+ width:0.6, showlegend:false
664
+ }];
665
+ var layout = {
666
+ autosize:true,
667
+ template:'plotly_white', showlegend:false, font:{family:'DM Sans',size:11},
668
+ margin:{l:30,r:18,t:40,b:72},
669
+ plot_bgcolor:'rgba(248,249,250,0.6)', paper_bgcolor:'white',
670
+ bargap:0.3, annotations:annotations,
671
+ xaxis:{tickfont:{size:9,family:'DM Sans',color:'#374151'},showgrid:false,tickangle:-30,automargin:true},
672
+ yaxis:{tickfont:{size:9,family:'DM Sans'},showgrid:true,
673
+ gridcolor:'rgba(200,200,200,0.35)',range:[0,maxVal*1.35],showticklabels:false,zeroline:false},
674
+ hoverlabel:{bgcolor:'white',font_size:12,font_family:'DM Sans'}
675
+ };
676
+ if (!inited[elId]) {
677
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
678
+ inited[elId] = true;
679
+ } else { Plotly.react(el, data, layout); }
680
+ }
681
+
682
+ function roundBars(boxEl, r, orientation) {
683
+ setTimeout(function() {
684
+ boxEl.querySelectorAll('.bars path').forEach(function(p) {
685
+ var d = p.getAttribute('d');
686
+ if (!d) return;
687
+ var tok = d.match(/[A-Za-z][^A-Za-z]*/g) || [];
688
+ var cx=0, cy=0, aX=[], aY=[];
689
+ tok.forEach(function(t) {
690
+ var cmd = t[0];
691
+ var pts = t.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){ return !isNaN(n); });
692
+ if (cmd==='M'||cmd==='L') { aX.push(pts[0]); aY.push(pts[1]); cx=pts[0]; cy=pts[1]; }
693
+ else if (cmd==='H') { aX.push(pts[0]); aY.push(cy); cx=pts[0]; }
694
+ else if (cmd==='V') { aX.push(cx); aY.push(pts[0]); cy=pts[0]; }
695
+ });
696
+ if (aX.length < 2) return;
697
+ var x0=Math.min.apply(null,aX), x1=Math.max.apply(null,aX);
698
+ var y0=Math.min.apply(null,aY), y1=Math.max.apply(null,aY);
699
+ var w=x1-x0, h=y1-y0;
700
+ if (w<=0 || h<=0) return;
701
+ var rr = Math.min(r, w/2, h/2);
702
+ var nd;
703
+ if (orientation === 'h') {
704
+ nd = 'M '+x0+','+y0
705
+ + ' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
706
+ + ' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1
707
+ + ' H '+x0+' V '+y0+' Z';
708
+ } else {
709
+ nd = 'M '+x0+','+y1
710
+ + ' H '+x1
711
+ + ' V '+(y0+rr)
712
+ + ' Q '+x1+','+y0+' '+(x1-rr)+','+y0
713
+ + ' H '+(x0+rr)+' Q '+x0+','+y0+' '+x0+','+(y0+rr)
714
+ + ' V '+y1+' Z';
715
+ }
716
+ p.setAttribute('d', nd);
717
+ });
718
+ }, 80);
719
+ }
720
+
721
+ function renderFrame(f) {
722
+ CARDS_CFG.forEach(function(c) {
723
+ var cd = f.cards[c.col];
724
+ if (cd) document.getElementById('mv_'+c.col).textContent = cd.valor+cd.sufixo;
725
+ });
726
+ f.linha1.forEach(function(item, i) {
727
+ if (item.tipo==='barra_h') renderBarH(item, 'l1_'+i);
728
+ else if (item.tipo==='barra_v') renderBarV(item, 'l1_'+i);
729
+ else if (item.tipo==='linha') renderLine(item, 'l1_'+i);
730
+ });
731
+ f.linha2.forEach(function(item, i) {
732
+ if (item.tipo==='donut') renderDonut(item, 'l2_'+i);
733
+ else if (item.tipo==='linha_sis') renderLineSis(item, 'l2_'+i);
734
+ else if (item.tipo==='barv_sis') renderBarVSis(item, 'l2_'+i);
735
+ });
736
+ }
737
+
738
+ function stepFrame() {
739
  renderFrame(FRAMES[frameIdx]);
740
  frameIdx++;
741
+ if (frameIdx >= FRAMES.length) {
742
  clearTimeout(animTimer);
743
+ cdownTimer = setTimeout(function(){ frameIdx=0; startAnimation(); }, PAUSE_MS);
744
+ } else {
745
+ animTimer = setTimeout(stepFrame, FRAME_MS);
746
+ }
747
+ }
748
 
749
+ function startAnimation() {
750
+ clearTimeout(animTimer);
751
+ clearTimeout(cdownTimer);
752
+ animTimer = setTimeout(stepFrame, FRAME_MS);
753
+ }
754
 
755
  var _rt;
756
+ window.addEventListener('resize', function() {
757
  clearTimeout(_rt);
758
+ _rt = setTimeout(function() {
759
+ document.querySelectorAll('.cp').forEach(function(box) {
760
+ if (box.data) Plotly.relayout(box, {width:box.clientWidth, height:box.clientHeight});
761
+ });
762
+ }, 200);
763
+ });
764
+
765
+ window.addEventListener('load', function() {
766
  buildDOM();
767
+ setTimeout(function(){ frameIdx=0; startAnimation(); }, 400);
768
+ });
769
  </script>
770
+ </body>
771
+ </html>"""
772
+
773
+ html = (html
774
+ .replace('__FRAMES__', frames_json)
775
+ .replace('__MESES__', meses_json)
776
+ .replace('__TITULOS_L1__', json.dumps(TITULOS_L1))
777
+ .replace('__TITULOS_L2__', json.dumps(TITULOS_L2))
778
+ .replace('__SUBS_L1__', json.dumps(subs_l1))
779
+ .replace('__SUBS_L2__', json.dumps(subs_l2))
780
+ )
781
 
782
+ components.html(html, height=8000, scrolling=True)
783
 
784
  else:
785
+ components.html("""<!DOCTYPE html><html><body style="display:flex;align-items:center;
786
+ justify-content:center;height:100dvh;font-family:sans-serif;color:#003a70;background:#f5f7fa;">
787
+ <div style="text-align:center"><div style="font-size:2em">📊</div>
788
  <p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
789
  height=4000, scrolling=False)
790
 
pages/full.py CHANGED
@@ -8,6 +8,22 @@ from gs_client import read_ws
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard")
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # ── SESSION ───────────────────────────────────────────────────────────
12
  _cc = CookieController()
13
  COOKIE_NAME = "pmja_session"
@@ -38,33 +54,70 @@ if time.time() - st.session_state.inicio_exibicao >= 120:
38
  st.session_state.inicio_exibicao = time.time()
39
  st.switch_page("pages/atual.py")
40
 
41
- st.markdown("""<style>
42
- #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
43
- [data-testid="stStatusWidget"]{display:none!important}
44
- .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
45
- iframe{position:fixed!important;top:0!important;left:0!important;
46
- width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
47
- [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
48
- height:0!important;overflow:visible!important}
49
- .main,[data-testid="stAppViewContainer"]{background:#f0faf6!important}
50
- @media(max-width:900px){
51
- iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
52
- .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
53
- .block-container{height:auto!important}
54
- }
55
- </style>""", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  # ── HELPERS ───────────────────────────────────────────────────────────
58
  def fmt(n):
59
- return "0" if pd.isna(n) else f"{int(n):,}".replace(',','.')
60
-
61
- def fix_df(df):
62
- for col in df.columns:
63
- if df[col].dtype == 'object':
64
- num = pd.to_numeric(df[col], errors='coerce')
65
- df[col] = num if num.notna().sum() == df[col].notna().sum() \
66
- else df[col].fillna('').astype(str).replace('nan','')
67
- return df
68
 
69
  def proc_exp(df_raw):
70
  if df_raw is None or df_raw.empty: return None, None
@@ -114,23 +167,16 @@ COR_SIS = ['#001a33','#002d4d','#003d66','#004d80','#005d99','#006db3',
114
  COR_SIS_EXP = ['#064e3b','#065f46','#047857','#059669','#10b981','#34d399',
115
  '#4ade80','#6ee7b7','#86efac','#a7f3d0','#bbf7d0','#d1fae5','#ecfdf5','#f0fdf4']
116
 
117
- # ── BUILD ALL FRAMES ──────────────────────────────────────────────────
118
  def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
119
  anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis):
120
  frames = []
121
-
122
  for mes in range(1, mes_max+1):
123
  frame = {"mes": mes, "metrics": {}, "charts": {}}
124
-
125
  for col, key, src in [
126
- ('qtd_descarregamentos','desc','rec'),
127
- ('peso_recebido','peso','rec'),
128
- ('qtd_volumes_recebidos','vol','rec'),
129
- ('qtd_unidades_recebidos','unid','rec'),
130
- ('qtd_itens_por_unidade','itens','rec'),
131
- ('qtd_requisicoes','req','exp'),
132
- ('qtd_unidades_emitidas','uem','exp'),
133
- ('qtd_itens_total','itot','exp'),
134
  ]:
135
  if src=='rec':
136
  v = df_rec[df_rec['mes']<=mes][col].sum() if mes<=mes_max_rec else df_rec[col].sum()
@@ -139,17 +185,17 @@ def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
139
  frame["metrics"][key] = fmt(v)
140
 
141
  def line_traces(df, col, anos, cores, lim):
142
- traces, totais = [], {}
143
  m = mes if mes<=lim else lim
144
  for i,ano in enumerate(anos):
145
  d = df[(df['ano']==ano)&(df['mes']<=m)].sort_values('mes')
146
  if d.empty: continue
147
- totais[ano] = d[col].sum(); cor = cores[i%len(cores)]
148
  traces.append({
149
- "x": [int(x) for x in d['mes']],
150
- "y": [float(y) for y in d[col]],
151
- "text": [fmt(v) if v>0 else "" for v in d[col]],
152
- "line_color": cor, "ano": str(ano), "total": fmt(totais[ano])
153
  })
154
  return traces
155
 
@@ -159,14 +205,16 @@ def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
159
  frame["charts"]["r1_2"] = line_traces(df_rec,'qtd_volumes_recebidos',anos_rec,COR_REC,mes_max_rec)
160
 
161
  if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec:
162
- cols_map = {col:s for col in df_rec_sis.columns
163
- for s in sistemas_rec
164
- if 'Volumes' in col and not any(e in col for e in ['Unid.','unidade']) and s in col}
 
 
165
  slices = []
166
  for i,s in enumerate(sistemas_rec):
167
  col = next((c for c,v in cols_map.items() if v==s), None)
168
  if col and col in df_rec_sis.columns:
169
- t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes, col], errors='coerce').fillna(0).sum())
170
  if t>0: slices.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]})
171
  if slices: frame["charts"]["r1_3"] = slices
172
 
@@ -175,18 +223,17 @@ def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
175
  frame["charts"]["r2_2"] = line_traces(df_rec,'qtd_itens_por_unidade',anos_rec,COR_REC,mes_max_rec)
176
 
177
  if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec:
178
- for slot, palavra, excluir in [
179
- ('r2_1','Unid. Itens',['por unidade']),
180
- ('r2_3','Itens por unidade',['Unid. Itens']),
181
- ]:
182
- cols_map2 = {col:s for col in df_rec_sis.columns
183
- for s in sistemas_rec
184
- if palavra in col and not any(e in col for e in excluir) and s in col}
185
  bars = []
186
  for i,s in enumerate(sistemas_rec):
187
  col = next((c for c,v in cols_map2.items() if v==s), None)
188
  if col and col in df_rec_sis.columns:
189
- t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes, col], errors='coerce').fillna(0).sum())
190
  if t>0: bars.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]})
191
  if bars: frame["charts"][slot] = bars
192
 
@@ -196,32 +243,25 @@ def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
196
  frame["charts"]["e1_2"] = line_traces(df_exp,'qtd_itens_total',anos_exp,COR_EXP,mes_max_exp)
197
 
198
  if df_exp_det is not None and mes<=mes_max_exp:
199
- det = df_exp_det[df_exp_det['mes_num']<=mes].copy()
200
- 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()]
201
- unid_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()]
202
- itens_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()]
203
- for slot, cols in [('es_0',req_c),('es_1',unid_c),('es_2',itens_c)]:
204
  if not cols: continue
205
  tots = {}
206
  for col in cols:
207
  cat = ext_cat(col)
208
  if cat not in tots: tots[cat]={'v':0.0,'c':COR_SIS_EXP[len(tots)%len(COR_SIS_EXP)]}
209
- det.loc[:,col] = pd.to_numeric(det[col], errors='coerce').fillna(0)
210
  tots[cat]['v'] += float(det[col].sum())
211
- dados = sorted([(k,v) for k,v in tots.items() if v['v']>0], key=lambda x:x[1]['v'], reverse=True)
212
- if dados:
213
- frame["charts"][slot] = [{"label":k,"value":v['v'],"color":v['c']} for k,v in dados]
214
 
215
  frames.append(frame)
216
  return frames
217
 
218
-
219
- # ── LOAD DATA ─────────────────────────────────────────────────────────
220
- with st.spinner('📊 Carregando dados...'):
221
- df_recebimento = read_ws("recebimento") # ← gs_client (era: load_ws("recebimento"))
222
- df_rec_dados = read_ws("rec_dados") # ← gs_client (era: load_ws("rec_dados"))
223
- df_exp_raw = read_ws("exp_dados") # ← gs_client (era: load_ws("exp_dados"))
224
-
225
  df_exp_raw = df_exp_raw if df_exp_raw is not None and not df_exp_raw.empty else None
226
  df_recebimento = df_recebimento if df_recebimento is not None and not df_recebimento.empty else None
227
  df_rec_dados = df_rec_dados if df_rec_dados is not None and not df_rec_dados.empty else None
@@ -236,8 +276,7 @@ if df_recebimento is not None:
236
  'peso_recebido','qtd_unidades_recebidos','qtd_itens_por_unidade']
237
  df_rec['data'] = pd.to_datetime(df_rec['mes_ano'], format='%m/%Y', errors='coerce')
238
  df_rec = df_rec.dropna(subset=['data']).sort_values('data')
239
- df_rec['ano'] = df_rec['data'].dt.year
240
- df_rec['mes'] = df_rec['data'].dt.month
241
  for col in ['qtd_descarregamentos','qtd_volumes_recebidos','peso_recebido',
242
  'qtd_unidades_recebidos','qtd_itens_por_unidade']:
243
  if col in df_rec.columns:
@@ -262,85 +301,91 @@ if df_rec_dados is not None:
262
  sistemas_rec = get_sistemas(df_rec_sis)
263
  mes_max_sis = int(df_rec_sis['mes'].max())
264
 
265
- # ── BUILD & RENDER ────────────────────────────────────────────────────
266
  if df_rec is not None and df_exp is not None and mes_max_rec>0 and mes_max_exp>0:
267
  anos_rec = [int(a) for a in sorted(df_rec['ano'].unique())]
268
  anos_exp = [int(a) for a in sorted(df_exp['ano'].unique())]
269
  mes_max = max(mes_max_rec, mes_max_exp, mes_max_sis if mes_max_sis>0 else 0)
270
 
271
- frames = build_all_frames(
272
- df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
273
- anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis
274
- )
275
  frames_json = json.dumps(frames, ensure_ascii=False)
276
  meses_json = json.dumps(MESES)
277
 
278
- html = f"""<!DOCTYPE html><html><head>
 
 
279
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
280
  <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">
281
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
282
  <style>
283
- *{{margin:0;padding:0;box-sizing:border-box}}
284
- html,body{{width:100%;height:100%;overflow:hidden;
285
  background:linear-gradient(160deg,#f0faf6 0%,#e6f7f1 55%,#d1fae5 100%);
286
- font-family:'DM Sans',sans-serif;}}
287
- ::-webkit-scrollbar{{width:4px}}
288
- ::-webkit-scrollbar-thumb{{background:linear-gradient(#047857,#10b981);border-radius:10px}}
289
- .root{{
290
- display:grid;width:100%;height:100dvh;gap:2px;padding:3px 4px 2px;overflow:hidden;
291
- grid-template-rows:auto auto auto 1fr 1fr auto 1fr 1fr;
292
- }}
293
- .hdr{{background:linear-gradient(135deg,#003a70 0%,#0056A3 50%,#064e3b 100%);
294
- border-radius:6px;padding:5px 14px;display:flex;align-items:center;justify-content:space-between;
295
- box-shadow:0 3px 14px rgba(0,58,112,.18);position:relative;overflow:hidden;}}
296
- .hdr::before{{content:'';position:absolute;top:-50%;right:-3%;width:200px;height:200px;
297
- border-radius:50%;background:radial-gradient(circle,rgba(16,185,129,.18) 0%,transparent 70%);pointer-events:none;}}
298
- .logo{{height:clamp(20px,3.2dvh,36px);filter:brightness(0) invert(1);opacity:.92}}
299
- .ht{{font-family:'Sora',sans-serif;font-size:clamp(12px,1.9dvh,20px);color:#fff;font-weight:800;letter-spacing:-.3px}}
300
- .hs{{font-family:'DM Sans',sans-serif;font-size:clamp(9px,1.2dvh,13px);color:rgba(255,255,255,.82);font-weight:600}}
301
- .mrow{{display:grid;grid-template-columns:repeat(8,1fr);gap:3px;}}
302
- .mc{{background:#fff;border-radius:5px;padding:clamp(3px,.5dvh,7px) clamp(4px,.5vw,10px);
303
- border-left:3px solid;box-shadow:0 1px 6px rgba(4,120,87,.07);overflow:hidden;}}
304
- .ml{{font-family:'DM Sans',sans-serif;font-size:clamp(6px,.85dvh,10px);
305
- color:#6b7280;font-weight:700;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;}}
306
- .mv{{font-family:'Sora',sans-serif;font-size:clamp(11px,1.9dvh,22px);
307
- font-weight:800;line-height:1.1;letter-spacing:-.4px;white-space:nowrap;transition:all .3s;}}
308
- .div{{border-radius:5px;padding:clamp(2px,.35dvh,5px) 10px;
309
- display:flex;align-items:center;justify-content:center;}}
310
- .div-rec{{background:linear-gradient(135deg,#003a70 0%,#0075be 100%);box-shadow:0 2px 8px rgba(0,86,163,.15);}}
311
- .div-exp{{background:linear-gradient(135deg,#064e3b 0%,#10b981 100%);box-shadow:0 2px 8px rgba(6,78,59,.18);}}
312
- .div-lbl{{font-family:'Sora',sans-serif;font-size:clamp(9px,1.3dvh,15px);color:#fff;font-weight:800;letter-spacing:.5px}}
313
- .crow{{display:grid;gap:3px;min-height:0;}}
314
- .crow4{{grid-template-columns:repeat(4,1fr);}}
315
- .crow3{{grid-template-columns:repeat(3,1fr);}}
316
- .cb{{background:#fff;border-radius:6px;border:1px solid rgba(16,185,129,.12);
317
- box-shadow:0 2px 8px rgba(4,120,87,.07);overflow:hidden;min-height:0;position:relative;}}
318
- #countdown-badge{{
319
- position:fixed;bottom:10px;right:14px;z-index:99999;
320
- background:rgba(6,78,59,0.82);color:#fff;
321
- font-family:'DM Sans',sans-serif;font-size:11px;font-weight:700;
322
- padding:4px 10px;border-radius:20px;letter-spacing:.4px;
323
- opacity:0;transition:opacity .4s;pointer-events:none;
324
- }}
325
- #countdown-badge.visible{{opacity:1;}}
326
- @media(max-width:900px){{
327
- html,body{{overflow-y:auto!important;height:auto!important}}
328
- .root{{height:auto!important;overflow:visible!important;grid-template-rows:auto!important}}
329
- .mrow{{grid-template-columns:repeat(4,1fr)!important}}
330
- .crow4{{grid-template-columns:repeat(2,1fr)!important;min-height:200px}}
331
- .crow3{{grid-template-columns:repeat(2,1fr)!important;min-height:200px}}
332
- .cb{{min-height:220px!important}}
333
- }}
334
- @media(max-width:600px){{
335
- .mrow{{grid-template-columns:repeat(2,1fr)!important}}
336
- .crow4,.crow3{{grid-template-columns:1fr!important}}
337
- .cb{{min-height:200px!important}}
338
- .hs{{display:none}}
339
- .hdr{{padding:4px 8px!important}}
340
- .div-lbl{{font-size:12px!important}}
341
- }}
 
 
 
 
 
342
  </style>
343
- </head><body>
 
344
  <div class="root">
345
  <div class="hdr">
346
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
@@ -350,7 +395,6 @@ html,body{{width:100%;height:100%;overflow:hidden;
350
  </div>
351
  <img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
352
  </div>
353
-
354
  <div class="mrow">
355
  <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>
356
  <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>
@@ -361,7 +405,6 @@ html,body{{width:100%;height:100%;overflow:hidden;
361
  <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>
362
  <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>
363
  </div>
364
-
365
  <div class="div div-rec"><span class="div-lbl">📦 RECEBIMENTO</span></div>
366
  <div class="crow crow4">
367
  <div class="cb" id="r1_0"></div><div class="cb" id="r1_1"></div>
@@ -371,7 +414,6 @@ html,body{{width:100%;height:100%;overflow:hidden;
371
  <div class="cb" id="r2_0"></div><div class="cb" id="r2_1"></div>
372
  <div class="cb" id="r2_2"></div><div class="cb" id="r2_3"></div>
373
  </div>
374
-
375
  <div class="div div-exp"><span class="div-lbl">🚚 EXPEDIÇÃO</span></div>
376
  <div class="crow crow3">
377
  <div class="cb" id="e1_0"></div><div class="cb" id="e1_1"></div><div class="cb" id="e1_2"></div>
@@ -380,403 +422,411 @@ html,body{{width:100%;height:100%;overflow:hidden;
380
  <div class="cb" id="es_0"></div><div class="cb" id="es_1"></div><div class="cb" id="es_2"></div>
381
  </div>
382
  </div>
383
-
384
  <div id="countdown-badge">⏸ Reiniciando em <span id="cd-secs">60</span>s</div>
385
 
386
  <script>
387
- var FRAMES = {frames_json};
388
- var MESES = {meses_json};
389
- var REC_SLOTS = ['r1_0','r1_1','r1_2','r1_3','r2_0','r2_1','r2_2','r2_3'];
390
- var LINE_SLOTS = ['r1_0','r1_1','r1_2','r2_0','r2_2','e1_0','e1_1','e1_2'];
391
- var DONUT_SLOTS = ['r1_3','es_0'];
392
- var BARH_SLOTS = ['r2_1','es_1'];
393
- var BARV_SLOTS = ['r2_3','es_2'];
394
-
395
- var METRIC_IDS = {{
396
- 'desc':'m_desc','peso':'m_peso','vol':'m_vol','unid':'m_unid','itens':'m_itens',
397
- 'req':'m_req','uem':'m_uem','itot':'m_itot'
398
- }};
399
- var METRIC_SUF = {{peso:' kg'}};
400
- var initialized = {{}};
401
-
402
- var FRAME_INTERVAL_MS = 600;
403
- var PAUSE_AFTER_MS = 60000;
404
- var frameIdx = 0;
405
- var animTimer = null;
406
- var countdownTimer = null;
407
- var badge = null;
408
- var cdSecs = null;
409
-
410
- function gsize(id) {{
 
 
 
 
 
 
 
 
 
 
 
411
  var el = document.getElementById(id);
412
  if (!el) return [200,150];
413
- return [el.clientWidth || 200, el.clientHeight || 150];
414
- }}
415
 
416
  var ticktext = Object.values(MESES);
417
  var tickvals = Object.keys(MESES).map(Number);
418
 
419
- function fmtNum(v) {{
420
  var n = parseFloat(v);
421
  if (isNaN(n)) return v+'';
422
- if (Number.isInteger(n)) return n.toString().replace(/\\B(?=(\\d{{3}})+(?!\\d))/g,'.');
423
- return n.toFixed(2).replace('.',',').replace(/\\B(?=(\\d{{3}})+(?!\\d))/g,'.');
424
- }}
425
-
426
- function makeLine(traces, id) {{
427
- var wh = gsize(id);
428
- var isRec = REC_SLOTS.indexOf(id) >= 0;
429
- var gridCol = isRec ? 'rgba(0,86,163,0.10)' : 'rgba(16,185,129,0.10)';
430
- var bgCol = isRec ? 'rgba(240,246,255,0.4)' : 'rgba(240,250,246,0.4)';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  var data = [], anns = [];
432
- traces.forEach(function(t,i) {{
433
- data.push({{
434
- type:'scatter', mode:'lines+markers+text',
435
- x: t.x, y: t.y,
436
- text: t.text, textposition:'top center',
437
- textfont:{{size:8,family:'DM Sans',color:'#333'}},
438
- line:{{width:2.2,color:t.line_color,shape:'spline'}},
439
- marker:{{size:6,color:t.line_color,line:{{width:1.5,color:'white'}}}},
440
  cliponaxis:false, showlegend:false, name:t.ano
441
- }});
442
- anns.push({{
443
- x: i * 0.28, y: 1.13, xref:'paper', yref:'paper',
 
 
 
444
  showarrow:false, xanchor:'left', yanchor:'top',
445
- font:{{size:9,family:'DM Sans',color:t.line_color}},
446
- text:'<b>■ '+t.ano+'</b> '+t.total
447
- }});
448
- }});
449
- return {{
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  data: data,
451
- layout: {{
452
  width:wh[0], height:wh[1], autosize:false,
453
- margin:{{l:38,r:12,t:52,b:24}},
454
- paper_bgcolor:'white', plot_bgcolor:bgCol,
455
  showlegend:false, annotations:anns,
456
- xaxis:{{tickmode:'array',tickvals:tickvals,ticktext:ticktext,
457
- tickfont:{{size:8,family:'DM Sans'}},gridcolor:gridCol,zeroline:false}},
458
- yaxis:{{tickfont:{{size:8,family:'DM Sans'}},rangemode:'tozero',
459
- gridcolor:gridCol,zeroline:false}},
460
- }}
461
- }};
462
- }}
463
-
464
- function makePie(slices, id) {{
465
- var wh = gsize(id);
466
- var total = slices.reduce(function(s,x){{return s+x.value;}},0);
467
- var labels = slices.map(function(x){{
468
- return x.label+': '+(x.value+'').replace(/\\B(?=(\\d{{3}})+(?!\\d))/g,'.')
469
- +' ('+(x.value/total*100).toFixed(1)+'%)';
470
- }});
471
- return {{
472
- data:[{{type:'pie',labels:labels,values:slices.map(function(x){{return x.value;}}),hole:0.38,
473
- marker:{{colors:slices.map(function(x){{return x.color;}}),line:{{color:'white',width:2}}}},
474
- textfont:{{size:8,family:'DM Sans'}},textposition:'inside'}}],
475
- layout:{{
476
- width:wh[0],height:wh[1],autosize:false,
477
- margin:{{l:6,r:90,t:36,b:8}},paper_bgcolor:'white',showlegend:true,
478
- legend:{{orientation:'v',y:0.5,x:1.01,font:{{size:7,family:'DM Sans'}}}},
479
- }}
480
- }};
481
- }}
482
-
483
- function roundBarPaths(boxEl, r, orientation) {{
484
- setTimeout(function() {{
485
- boxEl.querySelectorAll('.bars path').forEach(function(p) {{
486
- var d = p.getAttribute('d');
487
- if (!d) return;
488
- var tokens = d.match(/[A-Za-z][^A-Za-z]*/g) || [];
489
- var cx=0, cy=0, allX=[], allY=[];
490
- tokens.forEach(function(tok) {{
491
- var cmd = tok[0];
492
- var pts = tok.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){{return !isNaN(n);}});
493
- if (cmd==='M'||cmd==='L') {{ allX.push(pts[0]); allY.push(pts[1]); cx=pts[0]; cy=pts[1]; }}
494
- else if (cmd==='H') {{ allX.push(pts[0]); allY.push(cy); cx=pts[0]; }}
495
- else if (cmd==='V') {{ allX.push(cx); allY.push(pts[0]); cy=pts[0]; }}
496
- }});
497
- if (allX.length < 2) return;
498
- var x0=Math.min.apply(null,allX), x1=Math.max.apply(null,allX);
499
- var y0=Math.min.apply(null,allY), y1=Math.max.apply(null,allY);
500
- var w=x1-x0, h=y1-y0;
501
- if (w<=0 || h<=0) return;
502
- var rr = Math.min(r, w/2, h/2);
503
- var nd;
504
- if (orientation==='h') {{
505
- nd = 'M '+x0+','+y0
506
- + ' H '+(x1-rr)
507
- + ' Q '+x1+','+y0+' '+x1+','+(y0+rr)
508
- + ' V '+(y1-rr)
509
- + ' Q '+x1+','+y1+' '+(x1-rr)+','+y1
510
- + ' H '+x0
511
- + ' V '+y0
512
- + ' Z';
513
- }} else {{
514
- nd = 'M '+x0+','+y1
515
- + ' H '+x1
516
- + ' V '+(y0+rr)
517
- + ' Q '+x1+','+y0+' '+(x1-rr)+','+y0
518
- + ' H '+(x0+rr)
519
- + ' Q '+x0+','+y0+' '+x0+','+(y0+rr)
520
- + ' V '+y1
521
- + ' Z';
522
- }}
523
- p.setAttribute('d', nd);
524
- }});
525
- }}, 60);
526
- }}
527
-
528
- function makeBarH(bars, id) {{
529
- var wh = gsize(id);
530
- var maxVal = Math.max.apply(null, bars.map(function(b){{return b.value;}}));
531
- var isRec = REC_SLOTS.indexOf(id) >= 0;
532
- var gridCol = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)';
 
 
 
 
 
 
 
533
  var thresh = maxVal * 0.15;
534
- var anns = bars.map(function(b) {{
535
- var inside = b.value >= thresh;
536
- return {{
537
- x: inside ? b.value * 0.96 : b.value + maxVal * 0.02,
538
  y: b.label,
539
  xref:'x', yref:'y', showarrow:false,
540
- xanchor: inside ? 'right' : 'left',
541
- yanchor:'middle',
542
  text:'<b>'+fmtNum(b.value)+'</b>',
543
- font:{{size:9,family:'DM Sans',color: inside?'white':'#374151'}}
544
- }};
545
- }});
546
- return {{
547
- data:[{{
548
  type:'bar', orientation:'h',
549
- x: bars.map(function(b){{return b.value;}}),
550
- y: bars.map(function(b){{return b.label;}}),
551
- text: bars.map(function(){{return '';}}),
552
- marker:{{
553
- color: bars.map(function(b){{return b.color;}}),
554
- opacity: 0.90,
555
- line:{{color:'rgba(255,255,255,0)',width:0}}
556
- }}
557
- }}],
558
- layout:{{
559
  width:wh[0], height:wh[1], autosize:false,
560
- margin:{{l:96,r:60,t:36,b:12}},
561
  paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)',
562
- showlegend:false, bargap: 0.28, annotations: anns,
563
- yaxis:{{tickfont:{{size:8,family:'DM Sans',color:'#4b5563'}},automargin:true,gridcolor:'rgba(0,0,0,0)',zeroline:false}},
564
- xaxis:{{tickfont:{{size:8,family:'DM Sans',color:'#9ca3af'}},gridcolor:gridCol,zeroline:false,range:[0, maxVal * 1.25]}},
565
- shapes:[{{type:'rect',xref:'paper',yref:'paper',x0:0,y0:0,x1:1,y1:1,
566
- line:{{color:'rgba(16,185,129,0.12)',width:1}},fillcolor:'rgba(0,0,0,0)'}}]
567
- }},
568
- _orientation: 'h'
569
- }};
570
- }}
571
-
572
- function makeBarV(bars, id) {{
573
- var wh = gsize(id);
574
- var maxVal = Math.max.apply(null, bars.map(function(b){{return b.value;}}));
575
- var isRec = REC_SLOTS.indexOf(id) >= 0;
576
- var gridCol = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)';
577
- var anns = bars.map(function(b) {{
578
- return {{
579
- x: b.label, y: b.value,
580
- xref:'x', yref:'y', showarrow:false,
581
- xanchor:'center', yanchor:'bottom', yshift:4,
582
  text:'<b>'+fmtNum(b.value)+'</b>',
583
- font:{{size:9,family:'DM Sans',color:'#374151'}}
584
- }};
585
- }});
586
- return {{
587
- data:[{{
588
  type:'bar',
589
- x: bars.map(function(b){{return b.label;}}),
590
- y: bars.map(function(b){{return b.value;}}),
591
- text: bars.map(function(){{return '';}}),
592
- marker:{{
593
- color: bars.map(function(b){{return b.color;}}),
594
- opacity: 0.90,
595
- line:{{color:'rgba(255,255,255,0)',width:0}}
596
- }}
597
- }}],
598
- layout:{{
599
  width:wh[0], height:wh[1], autosize:false,
600
- margin:{{l:38,r:12,t:42,b:100}},
601
  paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)',
602
- showlegend:false, bargap: 0.30, annotations: anns,
603
- xaxis:{{tickfont:{{size:7,family:'DM Sans',color:'#4b5563'}},tickangle:-45,zeroline:false,automargin:true,ticklabelstandoff:8}},
604
- yaxis:{{tickfont:{{size:8,family:'DM Sans',color:'#9ca3af'}},gridcolor:gridCol,zeroline:false,range:[0, maxVal * 1.30]}},
605
- shapes:[{{type:'rect',xref:'paper',yref:'paper',x0:0,y0:0,x1:1,y1:1,
606
- line:{{color:'rgba(16,185,129,0.12)',width:1}},fillcolor:'rgba(0,0,0,0)'}}]
607
- }},
608
- _orientation: 'v'
609
- }};
610
- }}
611
-
612
- function addTitle(layout, title, isRec) {{
613
- var titleColor = isRec ? '#003a70' : '#064e3b';
614
- layout.title = {{
615
- text: '<b>'+title+'</b>',
616
- font:{{family:'Sora',size:11,color:titleColor}},
617
- x:0.5, xanchor:'center'
618
- }};
619
- return layout;
620
- }}
621
 
622
- var TITLES = {{
623
- r1_0:'Descarregamentos', r1_1:'Peso Recebido (kg)', r1_2:'Volumes Recebidos',
624
- r1_3:'Volumes por Sistema',
625
- r2_0:'Unidades Recebidas', r2_1:'Unidades Itens por Sistema',
626
- r2_2:'Itens por Unidade', r2_3:'Itens/Unidade por Sistema',
627
- e1_0:'Requisições', e1_1:'Unidades Emitidas', e1_2:'Total de Itens',
628
- es_0:'Requisições por Sistema', es_1:'Unidades Emitidas por Sistema', es_2:'Itens por Sistema'
629
- }};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
 
631
- function makeLine_exp(traces, id) {{
632
- var wh = gsize(id);
633
- var data = [], anns = [];
634
- var yshifts = [18, 32, 46, 60];
635
- traces.forEach(function(t,i) {{
636
- data.push({{
637
- type:'scatter', mode:'lines+markers',
638
- x: t.x, y: t.y,
639
- line:{{width:2.2,color:t.line_color,shape:'spline'}},
640
- marker:{{size:6,color:t.line_color,line:{{width:1.5,color:'white'}}}},
641
- cliponaxis:false, showlegend:false, name:t.ano
642
- }});
643
- anns.push({{
644
- x: i * 0.28, y: 1.13, xref:'paper', yref:'paper',
645
- showarrow:false, xanchor:'left', yanchor:'top',
646
- font:{{size:9,family:'DM Sans',color:t.line_color}},
647
- text:'<b>■ '+t.ano+'</b> '+t.total
648
- }});
649
- var shift = yshifts[i % yshifts.length];
650
- t.x.forEach(function(mx, j) {{
651
- if (!t.text[j]) return;
652
- anns.push({{
653
- x:mx, y:t.y[j], xref:'x', yref:'y',
654
- showarrow:false, yshift:shift,
655
- font:{{size:8,family:'DM Sans',color:'#1a1a1a'}},
656
- bgcolor:'rgba(0,0,0,0)',
657
- xanchor:'center', yanchor:'bottom',
658
- text:t.text[j]
659
- }});
660
- }});
661
- }});
662
- return {{
663
- data: data,
664
- layout: {{
665
- width:wh[0], height:wh[1], autosize:false,
666
- margin:{{l:38,r:12,t:52,b:36}},
667
- paper_bgcolor:'white', plot_bgcolor:'rgba(240,250,246,0.4)',
668
- showlegend:false, annotations:anns,
669
- xaxis:{{tickmode:'array',tickvals:tickvals,ticktext:ticktext,
670
- tickfont:{{size:8,family:'DM Sans'}},
671
- gridcolor:'rgba(16,185,129,0.10)',zeroline:false,
672
- ticklabelstandoff:8}},
673
- yaxis:{{tickfont:{{size:8,family:'DM Sans'}},rangemode:'tozero',
674
- gridcolor:'rgba(16,185,129,0.10)',zeroline:false}},
675
- }}
676
- }};
677
- }}
678
-
679
- function renderFrame(f) {{
680
- Object.keys(METRIC_IDS).forEach(function(k) {{
681
  var el = document.getElementById(METRIC_IDS[k]);
682
- if (el && f.metrics[k] !== undefined) {{
683
- el.textContent = f.metrics[k] + (METRIC_SUF[k]||'');
684
- }}
685
- }});
686
 
687
- var slots = Object.keys(f.charts);
688
- slots.forEach(function(id) {{
689
  var box = document.getElementById(id);
690
  if (!box) return;
691
- var d = f.charts[id];
692
- var spec;
693
  var isRec = REC_SLOTS.indexOf(id) >= 0;
694
- var expLineSlots = ['e1_0','e1_1','e1_2'];
695
-
696
- if (LINE_SLOTS.indexOf(id) >= 0) {{
697
- spec = expLineSlots.indexOf(id) >= 0 ? makeLine_exp(d, id) : makeLine(d, id);
698
- }}
699
- else if (DONUT_SLOTS.indexOf(id) >= 0) spec = makePie(d, id);
700
- else if (BARH_SLOTS.indexOf(id) >= 0) spec = makeBarH(d, id);
701
- else if (BARV_SLOTS.indexOf(id) >= 0) spec = makeBarV(d, id);
 
 
 
702
  if (!spec) return;
703
 
704
- addTitle(spec.layout, TITLES[id]||id, isRec);
705
  var isBar = (BARH_SLOTS.indexOf(id)>=0 || BARV_SLOTS.indexOf(id)>=0);
706
- var orient = spec._orientation || 'v';
707
 
708
- if (!initialized[id]) {{
709
- Plotly.newPlot(box, spec.data, spec.layout, {{displayModeBar:false, responsive:false}});
710
- initialized[id] = true;
711
- }} else {{
712
  Plotly.react(box, spec.data, spec.layout);
713
- }}
714
- if (isBar) roundBarPaths(box, 7, orient);
715
- }});
716
- }}
717
 
718
- function showCountdown(seconds) {{
719
  badge.classList.add('visible');
720
- var remaining = seconds;
721
- cdSecs.textContent = remaining;
722
- countdownTimer = setInterval(function() {{
723
- remaining -= 1;
724
- cdSecs.textContent = remaining;
725
- if (remaining <= 0) {{
726
- clearInterval(countdownTimer);
727
  badge.classList.remove('visible');
728
  frameIdx = 0;
729
  startAnimation();
730
- }}
731
- }}, 1000);
732
- }}
733
 
734
- function stepFrame() {{
735
  renderFrame(FRAMES[frameIdx]);
736
  frameIdx++;
737
- if (frameIdx >= FRAMES.length) {{
738
  clearTimeout(animTimer);
739
  showCountdown(PAUSE_AFTER_MS / 1000);
740
- }} else {{
741
  animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS);
742
- }}
743
- }}
744
 
745
- function startAnimation() {{
746
  clearTimeout(animTimer);
747
- clearInterval(countdownTimer);
748
  animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS);
749
- }}
750
 
751
- window.addEventListener('load', function() {{
752
- badge = document.getElementById('countdown-badge');
753
- cdSecs = document.getElementById('cd-secs');
754
- setTimeout(function() {{
755
- frameIdx = 0;
756
- startAnimation();
757
- }}, 300);
758
- }});
759
 
760
  var _rt;
761
- window.addEventListener('resize', function() {{
762
  clearTimeout(_rt);
763
- _rt = setTimeout(function() {{
764
- document.querySelectorAll('.cb').forEach(function(box) {{
765
- if (box.data) {{
766
- Plotly.relayout(box, {{width: box.clientWidth, height: box.clientHeight}});
767
- }}
768
- }});
769
- }}, 200);
770
- }});
771
  </script>
772
- </body></html>"""
 
 
 
 
 
 
773
 
774
  components.html(html, height=8000, scrolling=False)
775
 
776
  else:
777
- 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;'>
778
- <div style='text-align:center'><div style='font-size:2em'>📊</div><p>Aguardando dados... Recarregue a página.</p></div>
779
- </body></html>""", height=4000, scrolling=False)
 
 
780
 
781
  time.sleep(60)
782
- st.rerun()
 
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard")
10
 
11
+ st.markdown("""<style>
12
+ #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
13
+ [data-testid="stStatusWidget"]{display:none!important}
14
+ .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
15
+ iframe{position:fixed!important;top:0!important;left:0!important;
16
+ width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
17
+ [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
18
+ height:0!important;overflow:visible!important}
19
+ .main,[data-testid="stAppViewContainer"]{background:#f0faf6!important}
20
+ @media(max-width:900px){
21
+ iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
22
+ .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
23
+ .block-container{height:auto!important}
24
+ }
25
+ </style>""", unsafe_allow_html=True)
26
+
27
  # ── SESSION ───────────────────────────────────────────────────────────
28
  _cc = CookieController()
29
  COOKIE_NAME = "pmja_session"
 
54
  st.session_state.inicio_exibicao = time.time()
55
  st.switch_page("pages/atual.py")
56
 
57
+ # ── LOADING ───────────────────────────────────────────────────────────
58
+ loading_placeholder = st.empty()
59
+ loading_placeholder.markdown("""
60
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=Sora:wght@800&display=swap" rel="stylesheet">
61
+ <style>
62
+ #ld{position:fixed;inset:0;z-index:999999;
63
+ background:linear-gradient(135deg,#003a70 0%,#0056A3 50%,#064e3b 100%);
64
+ display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:'DM Sans',sans-serif}
65
+ #ld .lg{display:flex;align-items:center;gap:24px;margin-bottom:36px}
66
+ #ld img{height:34px;filter:brightness(0) invert(1);opacity:.9}
67
+ #ld .dv{width:1px;height:32px;background:rgba(255,255,255,.25)}
68
+ #ld h2{font-family:'Sora',sans-serif;font-size:clamp(15px,4vw,21px);color:#fff;font-weight:800;margin-bottom:4px;text-align:center}
69
+ #ld p{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);font-weight:600;margin-bottom:40px;text-align:center}
70
+ #ld .sp{width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;
71
+ border-radius:50%;animation:sp .8s linear infinite;margin-bottom:24px}
72
+ @keyframes sp{to{transform:rotate(360deg)}}
73
+ #ld .bw{width:min(260px,70vw);height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:14px}
74
+ #ld .bf{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
75
+ #ld .st{font-size:11px;color:rgba(255,255,255,.6);font-weight:600;text-align:center}
76
+ #ld .dt span{animation:dt 1.2s infinite;opacity:0}
77
+ #ld .dt span:nth-child(2){animation-delay:.2s}
78
+ #ld .dt span:nth-child(3){animation-delay:.4s}
79
+ @keyframes dt{0%,100%{opacity:0}50%{opacity:1}}
80
+ </style>
81
+ <div id="ld">
82
+ <div class="lg">
83
+ <img src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
84
+ <div class="dv"></div>
85
+ <img src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
86
+ </div>
87
+ <div class="sp"></div>
88
+ <h2>PMJA — Dashboard Gestão de Materiais</h2>
89
+ <p>Visão Completa · Recebimento &amp; Expedição</p>
90
+ <div class="bw"><div class="bf" id="ldbf"></div></div>
91
+ <div class="st" id="ldst">Conectando ao Google Sheets<span class="dt"><span>.</span><span>.</span><span>.</span></span></div>
92
+ </div>
93
+ <script>
94
+ (function(){
95
+ var s=[{p:15,m:'Conectando ao Google Sheets'},{p:35,m:'Carregando dados de recebimento'},
96
+ {p:55,m:'Carregando dados de expedição'},{p:72,m:'Processando métricas'},
97
+ {p:88,m:'Montando gráficos'},{p:96,m:'Quase pronto'}];
98
+ var i=0;
99
+ function n(){
100
+ if(i>=s.length)return;
101
+ var x=s[i++];
102
+ var b=document.getElementById('ldbf'),t=document.getElementById('ldst');
103
+ if(b)b.style.width=x.p+'%';
104
+ if(t)t.innerHTML=x.m+'<span class="dt"><span>.</span><span>.</span><span>.</span></span>';
105
+ setTimeout(n,950+Math.random()*550);
106
+ }
107
+ setTimeout(n,300);
108
+ })();
109
+ </script>
110
+ """, unsafe_allow_html=True)
111
+
112
+ # ── LOAD DATA ─────────────────────────────────────────────────────────
113
+ df_recebimento = read_ws("recebimento")
114
+ df_rec_dados = read_ws("rec_dados")
115
+ df_exp_raw = read_ws("exp_dados")
116
+ loading_placeholder.empty()
117
 
118
  # ── HELPERS ───────────────────────────────────────────────────────────
119
  def fmt(n):
120
+ return "0" if pd.isna(n) else "{:,}".format(int(n)).replace(',','.')
 
 
 
 
 
 
 
 
121
 
122
  def proc_exp(df_raw):
123
  if df_raw is None or df_raw.empty: return None, None
 
167
  COR_SIS_EXP = ['#064e3b','#065f46','#047857','#059669','#10b981','#34d399',
168
  '#4ade80','#6ee7b7','#86efac','#a7f3d0','#bbf7d0','#d1fae5','#ecfdf5','#f0fdf4']
169
 
 
170
  def build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
171
  anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis):
172
  frames = []
 
173
  for mes in range(1, mes_max+1):
174
  frame = {"mes": mes, "metrics": {}, "charts": {}}
 
175
  for col, key, src in [
176
+ ('qtd_descarregamentos','desc','rec'),('peso_recebido','peso','rec'),
177
+ ('qtd_volumes_recebidos','vol','rec'),('qtd_unidades_recebidos','unid','rec'),
178
+ ('qtd_itens_por_unidade','itens','rec'),('qtd_requisicoes','req','exp'),
179
+ ('qtd_unidades_emitidas','uem','exp'),('qtd_itens_total','itot','exp'),
 
 
 
 
180
  ]:
181
  if src=='rec':
182
  v = df_rec[df_rec['mes']<=mes][col].sum() if mes<=mes_max_rec else df_rec[col].sum()
 
185
  frame["metrics"][key] = fmt(v)
186
 
187
  def line_traces(df, col, anos, cores, lim):
188
+ traces = []
189
  m = mes if mes<=lim else lim
190
  for i,ano in enumerate(anos):
191
  d = df[(df['ano']==ano)&(df['mes']<=m)].sort_values('mes')
192
  if d.empty: continue
193
+ cor = cores[i%len(cores)]
194
  traces.append({
195
+ "x":[int(x) for x in d['mes']],
196
+ "y":[float(y) for y in d[col]],
197
+ "text":[fmt(v) if v>0 else "" for v in d[col]],
198
+ "line_color":cor, "ano":str(ano), "total":fmt(d[col].sum())
199
  })
200
  return traces
201
 
 
205
  frame["charts"]["r1_2"] = line_traces(df_rec,'qtd_volumes_recebidos',anos_rec,COR_REC,mes_max_rec)
206
 
207
  if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec:
208
+ cols_map = {}
209
+ for col in df_rec_sis.columns:
210
+ for s in sistemas_rec:
211
+ if 'Volumes' in col and not any(e in col for e in ['Unid.','unidade']) and s in col:
212
+ cols_map[col] = s
213
  slices = []
214
  for i,s in enumerate(sistemas_rec):
215
  col = next((c for c,v in cols_map.items() if v==s), None)
216
  if col and col in df_rec_sis.columns:
217
+ t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes,col],errors='coerce').fillna(0).sum())
218
  if t>0: slices.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]})
219
  if slices: frame["charts"]["r1_3"] = slices
220
 
 
223
  frame["charts"]["r2_2"] = line_traces(df_rec,'qtd_itens_por_unidade',anos_rec,COR_REC,mes_max_rec)
224
 
225
  if df_rec_sis is not None and mes<=mes_max_sis and sistemas_rec:
226
+ for slot, palavra, excluir in [('r2_1','Unid. Itens',['por unidade']),('r2_3','Itens por unidade',['Unid. Itens'])]:
227
+ cols_map2 = {}
228
+ for col in df_rec_sis.columns:
229
+ for s in sistemas_rec:
230
+ if palavra in col and not any(e in col for e in excluir) and s in col:
231
+ cols_map2[col] = s
 
232
  bars = []
233
  for i,s in enumerate(sistemas_rec):
234
  col = next((c for c,v in cols_map2.items() if v==s), None)
235
  if col and col in df_rec_sis.columns:
236
+ t = float(pd.to_numeric(df_rec_sis.loc[df_rec_sis['mes']<=mes,col],errors='coerce').fillna(0).sum())
237
  if t>0: bars.append({"label":s,"value":t,"color":COR_SIS[i%len(COR_SIS)]})
238
  if bars: frame["charts"][slot] = bars
239
 
 
243
  frame["charts"]["e1_2"] = line_traces(df_exp,'qtd_itens_total',anos_exp,COR_EXP,mes_max_exp)
244
 
245
  if df_exp_det is not None and mes<=mes_max_exp:
246
+ det = df_exp_det[df_exp_det['mes_num']<=mes].copy()
247
+ 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()]
248
+ 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()]
249
+ 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()]
250
+ for slot, cols in [('es_0',req_c),('es_1',uni_c),('es_2',it_c)]:
251
  if not cols: continue
252
  tots = {}
253
  for col in cols:
254
  cat = ext_cat(col)
255
  if cat not in tots: tots[cat]={'v':0.0,'c':COR_SIS_EXP[len(tots)%len(COR_SIS_EXP)]}
256
+ det.loc[:,col] = pd.to_numeric(det[col],errors='coerce').fillna(0)
257
  tots[cat]['v'] += float(det[col].sum())
258
+ dados = sorted([(k,v) for k,v in tots.items() if v['v']>0],key=lambda x:x[1]['v'],reverse=True)
259
+ if dados: frame["charts"][slot] = [{"label":k,"value":v['v'],"color":v['c']} for k,v in dados]
 
260
 
261
  frames.append(frame)
262
  return frames
263
 
264
+ # ── PROCESS DATA ──────────────────────────────────────────────────────
 
 
 
 
 
 
265
  df_exp_raw = df_exp_raw if df_exp_raw is not None and not df_exp_raw.empty else None
266
  df_recebimento = df_recebimento if df_recebimento is not None and not df_recebimento.empty else None
267
  df_rec_dados = df_rec_dados if df_rec_dados is not None and not df_rec_dados.empty else None
 
276
  'peso_recebido','qtd_unidades_recebidos','qtd_itens_por_unidade']
277
  df_rec['data'] = pd.to_datetime(df_rec['mes_ano'], format='%m/%Y', errors='coerce')
278
  df_rec = df_rec.dropna(subset=['data']).sort_values('data')
279
+ df_rec['ano'] = df_rec['data'].dt.year; df_rec['mes'] = df_rec['data'].dt.month
 
280
  for col in ['qtd_descarregamentos','qtd_volumes_recebidos','peso_recebido',
281
  'qtd_unidades_recebidos','qtd_itens_por_unidade']:
282
  if col in df_rec.columns:
 
301
  sistemas_rec = get_sistemas(df_rec_sis)
302
  mes_max_sis = int(df_rec_sis['mes'].max())
303
 
 
304
  if df_rec is not None and df_exp is not None and mes_max_rec>0 and mes_max_exp>0:
305
  anos_rec = [int(a) for a in sorted(df_rec['ano'].unique())]
306
  anos_exp = [int(a) for a in sorted(df_exp['ano'].unique())]
307
  mes_max = max(mes_max_rec, mes_max_exp, mes_max_sis if mes_max_sis>0 else 0)
308
 
309
+ frames = build_all_frames(df_rec, df_exp, df_rec_sis, df_exp_det, sistemas_rec,
310
+ anos_rec, anos_exp, mes_max, mes_max_rec, mes_max_exp, mes_max_sis)
311
+
 
312
  frames_json = json.dumps(frames, ensure_ascii=False)
313
  meses_json = json.dumps(MESES)
314
 
315
+ html = """<!DOCTYPE html>
316
+ <html>
317
+ <head>
318
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
319
  <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">
320
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
321
  <style>
322
+ * { margin:0; padding:0; box-sizing:border-box; }
323
+ html, body { width:100%; height:100%; overflow:hidden;
324
  background:linear-gradient(160deg,#f0faf6 0%,#e6f7f1 55%,#d1fae5 100%);
325
+ font-family:'DM Sans',sans-serif; }
326
+ ::-webkit-scrollbar { width:4px; }
327
+ ::-webkit-scrollbar-thumb { background:linear-gradient(#047857,#10b981); border-radius:10px; }
328
+ .root { display:grid; width:100%; height:100dvh; gap:2px; padding:3px 4px 2px; overflow:hidden;
329
+ grid-template-rows:auto auto auto 1fr 1fr auto 1fr 1fr; }
330
+ .hdr { background:linear-gradient(135deg,#003a70 0%,#0056A3 50%,#064e3b 100%);
331
+ border-radius:6px; padding:5px 14px; display:flex; align-items:center;
332
+ justify-content:space-between; box-shadow:0 3px 14px rgba(0,58,112,.18);
333
+ position:relative; overflow:hidden; }
334
+ .hdr::before { content:''; position:absolute; top:-50%; right:-3%; width:200px; height:200px;
335
+ border-radius:50%; background:radial-gradient(circle,rgba(16,185,129,.18) 0%,transparent 70%);
336
+ pointer-events:none; }
337
+ .logo { height:clamp(20px,3.2dvh,36px); filter:brightness(0) invert(1); opacity:.92; }
338
+ .ht { font-family:'Sora',sans-serif; font-size:clamp(12px,1.9dvh,20px);
339
+ color:#fff; font-weight:800; letter-spacing:-.3px; }
340
+ .hs { font-family:'DM Sans',sans-serif; font-size:clamp(9px,1.2dvh,13px);
341
+ color:rgba(255,255,255,.82); font-weight:600; }
342
+ .mrow { display:grid; grid-template-columns:repeat(8,1fr); gap:3px; }
343
+ .mc { background:#fff; border-radius:5px;
344
+ padding:clamp(3px,.5dvh,7px) clamp(4px,.5vw,10px);
345
+ border-left:3px solid; box-shadow:0 1px 6px rgba(4,120,87,.07); overflow:hidden; }
346
+ .ml { font-family:'DM Sans',sans-serif; font-size:clamp(6px,.85dvh,10px);
347
+ color:#6b7280; font-weight:700; text-transform:uppercase;
348
+ letter-spacing:.4px; white-space:nowrap; }
349
+ .mv { font-family:'Sora',sans-serif; font-size:clamp(11px,1.9dvh,22px);
350
+ font-weight:800; line-height:1.1; letter-spacing:-.4px;
351
+ white-space:nowrap; transition:all .3s; }
352
+ .div { border-radius:5px; padding:clamp(2px,.35dvh,5px) 10px;
353
+ display:flex; align-items:center; justify-content:center; }
354
+ .div-rec { background:linear-gradient(135deg,#003a70 0%,#0075be 100%);
355
+ box-shadow:0 2px 8px rgba(0,86,163,.15); }
356
+ .div-exp { background:linear-gradient(135deg,#064e3b 0%,#10b981 100%);
357
+ box-shadow:0 2px 8px rgba(6,78,59,.18); }
358
+ .div-lbl { font-family:'Sora',sans-serif; font-size:clamp(9px,1.3dvh,15px);
359
+ color:#fff; font-weight:800; letter-spacing:.5px; }
360
+ .crow { display:grid; gap:3px; min-height:0; }
361
+ .crow4 { grid-template-columns:repeat(4,1fr); }
362
+ .crow3 { grid-template-columns:repeat(3,1fr); }
363
+ .cb { background:#fff; border-radius:6px; border:1px solid rgba(16,185,129,.12);
364
+ box-shadow:0 2px 8px rgba(4,120,87,.07); overflow:hidden; min-height:0; position:relative; }
365
+ #countdown-badge { position:fixed; bottom:10px; right:14px; z-index:99999;
366
+ background:rgba(6,78,59,0.82); color:#fff; font-family:'DM Sans',sans-serif;
367
+ font-size:11px; font-weight:700; padding:4px 10px; border-radius:20px;
368
+ letter-spacing:.4px; opacity:0; transition:opacity .4s; pointer-events:none; }
369
+ #countdown-badge.visible { opacity:1; }
370
+ @media(max-width:900px){
371
+ html,body { overflow-y:auto!important; height:auto!important; }
372
+ .root { height:auto!important; overflow:visible!important; grid-template-rows:auto!important; }
373
+ .mrow { grid-template-columns:repeat(4,1fr)!important; }
374
+ .crow4 { grid-template-columns:repeat(2,1fr)!important; min-height:200px; }
375
+ .crow3 { grid-template-columns:repeat(2,1fr)!important; min-height:200px; }
376
+ .cb { min-height:220px!important; }
377
+ }
378
+ @media(max-width:600px){
379
+ .mrow { grid-template-columns:repeat(2,1fr)!important; }
380
+ .crow4,.crow3 { grid-template-columns:1fr!important; }
381
+ .cb { min-height:200px!important; }
382
+ .hs { display:none; }
383
+ .hdr { padding:4px 8px!important; }
384
+ .div-lbl { font-size:12px!important; }
385
+ }
386
  </style>
387
+ </head>
388
+ <body>
389
  <div class="root">
390
  <div class="hdr">
391
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
 
395
  </div>
396
  <img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
397
  </div>
 
398
  <div class="mrow">
399
  <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>
400
  <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>
 
405
  <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>
406
  <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>
407
  </div>
 
408
  <div class="div div-rec"><span class="div-lbl">📦 RECEBIMENTO</span></div>
409
  <div class="crow crow4">
410
  <div class="cb" id="r1_0"></div><div class="cb" id="r1_1"></div>
 
414
  <div class="cb" id="r2_0"></div><div class="cb" id="r2_1"></div>
415
  <div class="cb" id="r2_2"></div><div class="cb" id="r2_3"></div>
416
  </div>
 
417
  <div class="div div-exp"><span class="div-lbl">🚚 EXPEDIÇÃO</span></div>
418
  <div class="crow crow3">
419
  <div class="cb" id="e1_0"></div><div class="cb" id="e1_1"></div><div class="cb" id="e1_2"></div>
 
422
  <div class="cb" id="es_0"></div><div class="cb" id="es_1"></div><div class="cb" id="es_2"></div>
423
  </div>
424
  </div>
 
425
  <div id="countdown-badge">⏸ Reiniciando em <span id="cd-secs">60</span>s</div>
426
 
427
  <script>
428
+ var FRAMES = __FRAMES__;
429
+ var MESES = __MESES__;
430
+
431
+ var REC_SLOTS = ['r1_0','r1_1','r1_2','r1_3','r2_0','r2_1','r2_2','r2_3'];
432
+ var LINE_SLOTS = ['r1_0','r1_1','r1_2','r2_0','r2_2','e1_0','e1_1','e1_2'];
433
+ var DONUT_SLOTS = ['r1_3','es_0'];
434
+ var BARH_SLOTS = ['r2_1','es_1'];
435
+ var BARV_SLOTS = ['r2_3','es_2'];
436
+ var EXP_LINE = ['e1_0','e1_1','e1_2'];
437
+
438
+ var METRIC_IDS = {
439
+ desc:'m_desc', peso:'m_peso', vol:'m_vol', unid:'m_unid', itens:'m_itens',
440
+ req:'m_req', uem:'m_uem', itot:'m_itot'
441
+ };
442
+ var METRIC_SUF = { peso:' kg' };
443
+
444
+ var TITLES = {
445
+ r1_0:'Descarregamentos', r1_1:'Peso Recebido (kg)', r1_2:'Volumes Recebidos',
446
+ r1_3:'Volumes por Sistema',
447
+ r2_0:'Unidades Recebidas', r2_1:'Unidades Itens por Sistema',
448
+ r2_2:'Itens por Unidade', r2_3:'Itens/Unidade por Sistema',
449
+ e1_0:'Requisições', e1_1:'Unidades Emitidas', e1_2:'Total de Itens',
450
+ es_0:'Requisições por Sistema', es_1:'Unidades Emitidas por Sistema', es_2:'Itens por Sistema'
451
+ };
452
+
453
+ var inited = {};
454
+ var FRAME_INTERVAL_MS = 600;
455
+ var PAUSE_AFTER_MS = 60000;
456
+ var frameIdx = 0;
457
+ var animTimer = null;
458
+ var cdTimer = null;
459
+ var badge = null;
460
+ var cdSecs = null;
461
+
462
+ function gsize(id) {
463
  var el = document.getElementById(id);
464
  if (!el) return [200,150];
465
+ return [el.clientWidth||200, el.clientHeight||150];
466
+ }
467
 
468
  var ticktext = Object.values(MESES);
469
  var tickvals = Object.keys(MESES).map(Number);
470
 
471
+ function fmtNum(v) {
472
  var n = parseFloat(v);
473
  if (isNaN(n)) return v+'';
474
+ var i = Math.round(n);
475
+ return i.toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g,'.');
476
+ }
477
+
478
+ function addTitle(layout, id, isRec) {
479
+ var col = isRec ? '#003a70' : '#064e3b';
480
+ layout.title = {
481
+ text:'<b>'+(TITLES[id]||id)+'</b>',
482
+ font:{family:'Sora',size:11,color:col},
483
+ x:0.5, xanchor:'center'
484
+ };
485
+ }
486
+
487
+ // ── CORRECTED makeLine ────────────────────────────────────────────────
488
+ // Uses per-series yshift annotations (like makeLineExp) to prevent
489
+ // value labels from overlapping when multiple years share the same x points.
490
+ function makeLine(traces, id) {
491
+ var wh = gsize(id);
492
+ var isRec = REC_SLOTS.indexOf(id) >= 0;
493
+ var grid = isRec ? 'rgba(0,86,163,0.10)' : 'rgba(16,185,129,0.10)';
494
+ var bg = isRec ? 'rgba(240,246,255,0.4)' : 'rgba(240,250,246,0.4)';
495
+
496
+ // Alternate label offsets per series so they don't stack on top of each other
497
+ var yShifts = [16, 30, 44, 58];
498
+
499
  var data = [], anns = [];
500
+
501
+ traces.forEach(function(t, i) {
502
+ data.push({
503
+ type:'scatter', mode:'lines+markers',
504
+ x:t.x, y:t.y,
505
+ line:{width:2.2, color:t.line_color, shape:'spline'},
506
+ marker:{size:6, color:t.line_color, line:{width:1.5, color:'white'}},
 
507
  cliponaxis:false, showlegend:false, name:t.ano
508
+ });
509
+
510
+ // Legend row for this series (stacked vertically, one per line)
511
+ anns.push({
512
+ x: i * 0.28, y: 1.13,
513
+ xref:'paper', yref:'paper',
514
  showarrow:false, xanchor:'left', yanchor:'top',
515
+ font:{size:9, family:'DM Sans', color:t.line_color},
516
+ text:'<b>■ ' + t.ano + '</b> ' + t.total
517
+ });
518
+
519
+ // Per-point value annotations with staggered yshift to avoid overlap
520
+ var sh = yShifts[i % yShifts.length];
521
+ t.x.forEach(function(mx, j) {
522
+ if (!t.text[j]) return;
523
+ anns.push({
524
+ x: mx, y: t.y[j],
525
+ xref:'x', yref:'y',
526
+ showarrow: false,
527
+ yshift: sh,
528
+ font:{size:8, family:'DM Sans', color: t.line_color},
529
+ bgcolor:'rgba(0,0,0,0)',
530
+ xanchor:'center', yanchor:'bottom',
531
+ text: '<b>' + t.text[j] + '</b>'
532
+ });
533
+ });
534
+ });
535
+
536
+ return {
537
  data: data,
538
+ layout: {
539
  width:wh[0], height:wh[1], autosize:false,
540
+ margin:{l:38, r:12, t:52, b:24},
541
+ paper_bgcolor:'white', plot_bgcolor:bg,
542
  showlegend:false, annotations:anns,
543
+ xaxis:{
544
+ tickmode:'array', tickvals:tickvals, ticktext:ticktext,
545
+ tickfont:{size:8, family:'DM Sans'},
546
+ gridcolor:grid, zeroline:false
547
+ },
548
+ yaxis:{
549
+ tickfont:{size:8, family:'DM Sans'},
550
+ rangemode:'tozero',
551
+ gridcolor:grid, zeroline:false
552
+ }
553
+ }
554
+ };
555
+ }
556
+ // ─────────────────────────────────────────────────────────────────────
557
+
558
+ function makeLineExp(traces, id) {
559
+ var wh = gsize(id);
560
+ var data = [], anns = [];
561
+ var shifts = [18,32,46,60];
562
+ traces.forEach(function(t, i) {
563
+ data.push({
564
+ type:'scatter', mode:'lines+markers',
565
+ x:t.x, y:t.y,
566
+ line:{width:2.2,color:t.line_color,shape:'spline'},
567
+ marker:{size:6,color:t.line_color,line:{width:1.5,color:'white'}},
568
+ cliponaxis:false, showlegend:false, name:t.ano
569
+ });
570
+ anns.push({
571
+ x:i*0.28, y:1.13, xref:'paper', yref:'paper',
572
+ showarrow:false, xanchor:'left', yanchor:'top',
573
+ font:{size:9,family:'DM Sans',color:t.line_color},
574
+ text:'<b>■ '+t.ano+'</b> '+t.total
575
+ });
576
+ var sh = shifts[i % shifts.length];
577
+ t.x.forEach(function(mx,j) {
578
+ if (!t.text[j]) return;
579
+ anns.push({x:mx,y:t.y[j],xref:'x',yref:'y',showarrow:false,yshift:sh,
580
+ font:{size:8,family:'DM Sans',color:'#1a1a1a'},bgcolor:'rgba(0,0,0,0)',
581
+ xanchor:'center',yanchor:'bottom',text:t.text[j]});
582
+ });
583
+ });
584
+ return {
585
+ data:data,
586
+ layout:{
587
+ width:wh[0], height:wh[1], autosize:false,
588
+ margin:{l:38,r:12,t:52,b:36},
589
+ paper_bgcolor:'white', plot_bgcolor:'rgba(240,250,246,0.4)',
590
+ showlegend:false, annotations:anns,
591
+ xaxis:{tickmode:'array',tickvals:tickvals,ticktext:ticktext,
592
+ tickfont:{size:8,family:'DM Sans'},
593
+ gridcolor:'rgba(16,185,129,0.10)',zeroline:false,ticklabelstandoff:8},
594
+ yaxis:{tickfont:{size:8,family:'DM Sans'},rangemode:'tozero',
595
+ gridcolor:'rgba(16,185,129,0.10)',zeroline:false}
596
+ }
597
+ };
598
+ }
599
+
600
+ function makePie(slices, id) {
601
+ var wh = gsize(id);
602
+ var total = slices.reduce(function(s,x){ return s+x.value; }, 0);
603
+ var labels = slices.map(function(x) {
604
+ return x.label+': '+fmtNum(x.value)+' ('+(x.value/total*100).toFixed(1)+'%)';
605
+ });
606
+ return {
607
+ data:[{
608
+ type:'pie', labels:labels, values:slices.map(function(x){ return x.value; }),
609
+ hole:0.38,
610
+ marker:{colors:slices.map(function(x){ return x.color; }), line:{color:'white',width:2}},
611
+ textfont:{size:8,family:'DM Sans'}, textposition:'inside'
612
+ }],
613
+ layout:{
614
+ width:wh[0], height:wh[1], autosize:false,
615
+ margin:{l:6,r:90,t:36,b:8}, paper_bgcolor:'white',
616
+ showlegend:true,
617
+ legend:{orientation:'v',y:0.5,x:1.01,font:{size:7,family:'DM Sans'}}
618
+ }
619
+ };
620
+ }
621
+
622
+ function makeBarH(bars, id) {
623
+ var wh = gsize(id);
624
+ var isRec = REC_SLOTS.indexOf(id) >= 0;
625
+ var grid = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)';
626
+ var maxVal = Math.max.apply(null, bars.map(function(b){ return b.value; }));
627
  var thresh = maxVal * 0.15;
628
+ var anns = bars.map(function(b) {
629
+ var ins = b.value >= thresh;
630
+ return {
631
+ x: ins ? b.value*0.96 : b.value+maxVal*0.02,
632
  y: b.label,
633
  xref:'x', yref:'y', showarrow:false,
634
+ xanchor: ins?'right':'left', yanchor:'middle',
 
635
  text:'<b>'+fmtNum(b.value)+'</b>',
636
+ font:{size:9,family:'DM Sans',color:ins?'white':'#374151'}
637
+ };
638
+ });
639
+ return {
640
+ data:[{
641
  type:'bar', orientation:'h',
642
+ x:bars.map(function(b){ return b.value; }),
643
+ y:bars.map(function(b){ return b.label; }),
644
+ text:bars.map(function(){ return ''; }),
645
+ marker:{color:bars.map(function(b){ return b.color; }),opacity:0.90,line:{color:'rgba(255,255,255,0)',width:0}}
646
+ }],
647
+ layout:{
 
 
 
 
648
  width:wh[0], height:wh[1], autosize:false,
649
+ margin:{l:96,r:60,t:36,b:12},
650
  paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)',
651
+ showlegend:false, bargap:0.28, annotations:anns,
652
+ yaxis:{tickfont:{size:8,family:'DM Sans',color:'#4b5563'},automargin:true,
653
+ gridcolor:'rgba(0,0,0,0)',zeroline:false},
654
+ xaxis:{tickfont:{size:8,family:'DM Sans',color:'#9ca3af'},
655
+ gridcolor:grid,zeroline:false,range:[0,maxVal*1.25]}
656
+ },
657
+ _orient:'h'
658
+ };
659
+ }
660
+
661
+ function makeBarV(bars, id) {
662
+ var wh = gsize(id);
663
+ var isRec = REC_SLOTS.indexOf(id) >= 0;
664
+ var grid = isRec ? 'rgba(0,86,163,0.08)' : 'rgba(16,185,129,0.08)';
665
+ var maxVal = Math.max.apply(null, bars.map(function(b){ return b.value; }));
666
+ var anns = bars.map(function(b) {
667
+ return {x:b.label,y:b.value,xref:'x',yref:'y',showarrow:false,
668
+ xanchor:'center',yanchor:'bottom',yshift:4,
 
 
669
  text:'<b>'+fmtNum(b.value)+'</b>',
670
+ font:{size:9,family:'DM Sans',color:'#374151'}};
671
+ });
672
+ return {
673
+ data:[{
 
674
  type:'bar',
675
+ x:bars.map(function(b){ return b.label; }),
676
+ y:bars.map(function(b){ return b.value; }),
677
+ text:bars.map(function(){ return ''; }),
678
+ marker:{color:bars.map(function(b){ return b.color; }),opacity:0.90,line:{color:'rgba(255,255,255,0)',width:0}}
679
+ }],
680
+ layout:{
 
 
 
 
681
  width:wh[0], height:wh[1], autosize:false,
682
+ margin:{l:38,r:12,t:42,b:100},
683
  paper_bgcolor:'white', plot_bgcolor:'rgba(248,252,250,0.6)',
684
+ showlegend:false, bargap:0.30, annotations:anns,
685
+ xaxis:{tickfont:{size:7,family:'DM Sans',color:'#4b5563'},
686
+ tickangle:-45,zeroline:false,automargin:true,ticklabelstandoff:8},
687
+ yaxis:{tickfont:{size:8,family:'DM Sans',color:'#9ca3af'},
688
+ gridcolor:grid,zeroline:false,range:[0,maxVal*1.30]}
689
+ },
690
+ _orient:'v'
691
+ };
692
+ }
 
 
 
 
 
 
 
 
 
 
693
 
694
+ function roundBars(boxEl, r, orient) {
695
+ setTimeout(function() {
696
+ boxEl.querySelectorAll('.bars path').forEach(function(p) {
697
+ var d = p.getAttribute('d');
698
+ if (!d) return;
699
+ var tok = d.match(/[A-Za-z][^A-Za-z]*/g)||[];
700
+ var cx=0,cy=0,aX=[],aY=[];
701
+ tok.forEach(function(t) {
702
+ var cmd = t[0];
703
+ var pts = t.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){ return !isNaN(n); });
704
+ if (cmd==='M'||cmd==='L') { aX.push(pts[0]); aY.push(pts[1]); cx=pts[0]; cy=pts[1]; }
705
+ else if (cmd==='H') { aX.push(pts[0]); aY.push(cy); cx=pts[0]; }
706
+ else if (cmd==='V') { aX.push(cx); aY.push(pts[0]); cy=pts[0]; }
707
+ });
708
+ if (aX.length<2) return;
709
+ var x0=Math.min.apply(null,aX), x1=Math.max.apply(null,aX);
710
+ var y0=Math.min.apply(null,aY), y1=Math.max.apply(null,aY);
711
+ var w=x1-x0, h=y1-y0;
712
+ if (w<=0||h<=0) return;
713
+ var rr=Math.min(r,w/2,h/2), nd;
714
+ if (orient==='h') {
715
+ nd='M '+x0+','+y0+' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
716
+ +' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1+' H '+x0+' V '+y0+' Z';
717
+ } else {
718
+ nd='M '+x0+','+y1+' H '+x1+' V '+(y0+rr)
719
+ +' Q '+x1+','+y0+' '+(x1-rr)+','+y0
720
+ +' H '+(x0+rr)+' Q '+x0+','+y0+' '+x0+','+(y0+rr)+' V '+y1+' Z';
721
+ }
722
+ p.setAttribute('d', nd);
723
+ });
724
+ }, 60);
725
+ }
726
 
727
+ function renderFrame(f) {
728
+ Object.keys(METRIC_IDS).forEach(function(k) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  var el = document.getElementById(METRIC_IDS[k]);
730
+ if (el && f.metrics[k] !== undefined) el.textContent = f.metrics[k]+(METRIC_SUF[k]||'');
731
+ });
 
 
732
 
733
+ Object.keys(f.charts).forEach(function(id) {
 
734
  var box = document.getElementById(id);
735
  if (!box) return;
736
+ var d = f.charts[id];
 
737
  var isRec = REC_SLOTS.indexOf(id) >= 0;
738
+ var spec;
739
+
740
+ if (LINE_SLOTS.indexOf(id) >= 0) {
741
+ spec = EXP_LINE.indexOf(id) >= 0 ? makeLineExp(d,id) : makeLine(d,id);
742
+ } else if (DONUT_SLOTS.indexOf(id) >= 0) {
743
+ spec = makePie(d, id);
744
+ } else if (BARH_SLOTS.indexOf(id) >= 0) {
745
+ spec = makeBarH(d, id);
746
+ } else if (BARV_SLOTS.indexOf(id) >= 0) {
747
+ spec = makeBarV(d, id);
748
+ }
749
  if (!spec) return;
750
 
751
+ addTitle(spec.layout, id, isRec);
752
  var isBar = (BARH_SLOTS.indexOf(id)>=0 || BARV_SLOTS.indexOf(id)>=0);
753
+ var orient = spec._orient || 'v';
754
 
755
+ if (!inited[id]) {
756
+ Plotly.newPlot(box, spec.data, spec.layout, {displayModeBar:false, responsive:false});
757
+ inited[id] = true;
758
+ } else {
759
  Plotly.react(box, spec.data, spec.layout);
760
+ }
761
+ if (isBar) roundBars(box, 7, orient);
762
+ });
763
+ }
764
 
765
+ function showCountdown(secs) {
766
  badge.classList.add('visible');
767
+ var rem = secs;
768
+ cdSecs.textContent = rem;
769
+ cdTimer = setInterval(function() {
770
+ rem--;
771
+ cdSecs.textContent = rem;
772
+ if (rem <= 0) {
773
+ clearInterval(cdTimer);
774
  badge.classList.remove('visible');
775
  frameIdx = 0;
776
  startAnimation();
777
+ }
778
+ }, 1000);
779
+ }
780
 
781
+ function stepFrame() {
782
  renderFrame(FRAMES[frameIdx]);
783
  frameIdx++;
784
+ if (frameIdx >= FRAMES.length) {
785
  clearTimeout(animTimer);
786
  showCountdown(PAUSE_AFTER_MS / 1000);
787
+ } else {
788
  animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS);
789
+ }
790
+ }
791
 
792
+ function startAnimation() {
793
  clearTimeout(animTimer);
794
+ clearInterval(cdTimer);
795
  animTimer = setTimeout(stepFrame, FRAME_INTERVAL_MS);
796
+ }
797
 
798
+ window.addEventListener('load', function() {
799
+ badge = document.getElementById('countdown-badge');
800
+ cdSecs = document.getElementById('cd-secs');
801
+ setTimeout(function(){ frameIdx=0; startAnimation(); }, 300);
802
+ });
 
 
 
803
 
804
  var _rt;
805
+ window.addEventListener('resize', function() {
806
  clearTimeout(_rt);
807
+ _rt = setTimeout(function() {
808
+ document.querySelectorAll('.cb').forEach(function(box) {
809
+ if (box.data) Plotly.relayout(box, {width:box.clientWidth, height:box.clientHeight});
810
+ });
811
+ }, 200);
812
+ });
 
 
813
  </script>
814
+ </body>
815
+ </html>"""
816
+
817
+ html = (html
818
+ .replace('__FRAMES__', frames_json)
819
+ .replace('__MESES__', meses_json)
820
+ )
821
 
822
  components.html(html, height=8000, scrolling=False)
823
 
824
  else:
825
+ components.html("""<!DOCTYPE html><html><body style="display:flex;align-items:center;
826
+ justify-content:center;height:100dvh;font-family:sans-serif;color:#064e3b;background:#f0faf6;">
827
+ <div style="text-align:center"><div style="font-size:2em">📊</div>
828
+ <p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
829
+ height=4000, scrolling=False)
830
 
831
  time.sleep(60)
832
+ st.rerun()
pages/rec.py CHANGED
@@ -7,7 +7,23 @@ import streamlit.components.v1 as components
7
  from gs_client import read_ws
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard Recebimento")
10
- #carregando
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # ── SESSION ───────────────────────────────────────────────────────────
12
  _cc = CookieController()
13
  COOKIE_NAME = "pmja_session"
@@ -38,21 +54,63 @@ if time.time() - st.session_state.inicio_exibicao >= 120:
38
  st.session_state.inicio_exibicao = time.time()
39
  st.switch_page("pages/exp.py")
40
 
41
- st.markdown("""<style>
42
- #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
43
- [data-testid="stStatusWidget"]{display:none!important}
44
- .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
45
- iframe{position:fixed!important;top:0!important;left:0!important;
46
- width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
47
- [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
48
- height:0!important;overflow:visible!important}
49
- .main,[data-testid="stAppViewContainer"]{background:#f5f7fa!important}
50
- @media(max-width:900px){
51
- iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
52
- .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
53
- .block-container{height:auto!important}
54
- }
55
- </style>""", unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  # ── HELPERS ───────────────────────────────────────────────────────────
58
  def fmt(n):
@@ -72,24 +130,15 @@ def extrair_sistemas(df):
72
  break
73
  return sorted(list(sistemas_set))
74
 
75
- def formatar_numero(num):
76
- if pd.isna(num): return "0"
77
- return f"{int(num):,}".replace(',', '.')
78
-
79
- # ── BUILD ALL FRAMES ──────────────────────────────────────────────────
80
  def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
81
  mes_max_dados, mes_max_rec, metricas_linha1, metricas_linha2,
82
  metricas_rec, mapeamentos_rec):
83
-
84
  meses_pt = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
85
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
86
-
87
  frames = []
88
-
89
  for mes_atual in range(1, mes_max_dados + 1):
90
  frame = {"mes": mes_atual, "cards": {}, "linha1": [], "linha2": [], "linha3": []}
91
-
92
- # ── cards ─────────────────────────────────────────────────────
93
  df_ate = df_plot[df_plot['mes'] <= mes_atual]
94
  for coluna, titulo, cor, sufixo in [
95
  ('qtd_descarregamentos', 'Descarregamentos', '#003a70', ''),
@@ -98,12 +147,8 @@ def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
98
  ('qtd_unidades_recebidos','Unidades Recebidas','#4d9fd6',''),
99
  ('qtd_itens_por_unidade','Itens por Unidade','#80b9e5', ''),
100
  ]:
101
- frame["cards"][coluna] = {
102
- "valor": formatar_numero(df_ate[coluna].sum()),
103
- "sufixo": sufixo, "cor": cor, "titulo": titulo
104
- }
105
-
106
- # ── linha 1: 3 line charts ────────────────────────────────────
107
  for config in metricas_linha1:
108
  col = config['coluna']
109
  traces = []
@@ -112,16 +157,13 @@ def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
112
  if d.empty: continue
113
  cor = config['cores'][idx_ano % len(config['cores'])]
114
  traces.append({
115
- "ano": str(ano),
116
- "cor": cor,
117
  "x": [int(x) for x in d['mes']],
118
  "y": [float(v) for v in d[col]],
119
- "text": [formatar_numero(v) if not pd.isna(v) and v > 0 else "" for v in d[col]],
120
- "fill_rgba": f"rgba({int(cor[1:3],16)},{int(cor[3:5],16)},{int(cor[5:7],16)},0.1)"
121
  })
122
  frame["linha1"].append({"titulo": config['titulo'], "sufixo": config['sufixo'], "traces": traces})
123
-
124
- # ── linha 2: 2 line charts ────────────────────────────────────
125
  for config in metricas_linha2:
126
  col = config['coluna']
127
  traces = []
@@ -130,16 +172,13 @@ def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
130
  if d.empty: continue
131
  cor = config['cores'][idx_ano % len(config['cores'])]
132
  traces.append({
133
- "ano": str(ano),
134
- "cor": cor,
135
  "x": [int(x) for x in d['mes']],
136
  "y": [float(v) for v in d[col]],
137
- "text": [formatar_numero(v) if not pd.isna(v) and v > 0 else "" for v in d[col]],
138
- "fill_rgba": f"rgba({int(cor[1:3],16)},{int(cor[3:5],16)},{int(cor[5:7],16)},0.1)"
139
  })
140
  frame["linha2"].append({"titulo": config['titulo'], "sufixo": config['sufixo'], "traces": traces})
141
-
142
- # ── linha 3: pizza + barH + barV ─────────────────────────────
143
  if df_rec is not None and mes_atual <= mes_max_rec:
144
  df_ate_rec = df_rec[df_rec['mes'] <= mes_atual]
145
  for idx_m, metrica_info in enumerate(metricas_rec):
@@ -149,105 +188,17 @@ def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
149
  col_nome = next((c for c, s in mapeamento.items() if s == sistema), None)
150
  if col_nome and col_nome in df_rec.columns:
151
  total = float(df_ate_rec[col_nome].fillna(0).sum())
152
- valores.append(total)
153
- labels.append(sistema)
154
- cores.append(cores_sistemas[cat_idx])
155
- frame["linha3"].append({
156
- "titulo": metrica_info['titulo'],
157
- "tipo": idx_m,
158
- "valores": valores, "labels": labels, "cores": cores
159
- })
160
  else:
161
- for metrica_info in metricas_rec:
162
- frame["linha3"].append({"titulo": metrica_info['titulo'], "tipo": metricas_rec.index(metrica_info), "valores": [], "labels": [], "cores": []})
163
-
164
  frames.append(frame)
165
-
166
  return frames, meses_pt
167
 
168
- # ── LOAD DATA ─────────────────────────────────────────────────────────
169
- # ── LOADING SCREEN ─────────────────────────────────────────────────
170
- loading_html = """
171
- <style>
172
- *{margin:0;padding:0;box-sizing:border-box}
173
- .overlay{
174
- position:fixed;inset:0;
175
- background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
176
- display:flex;flex-direction:column;align-items:center;justify-content:center;
177
- z-index:99999;font-family:'DM Sans',sans-serif;
178
- transition:opacity .5s;
179
- }
180
- .logos{display:flex;align-items:center;gap:32px;margin-bottom:48px}
181
- .logo-img{height:40px;filter:brightness(0) invert(1);opacity:.9}
182
- .divider{width:1px;height:40px;background:rgba(255,255,255,.25)}
183
- .title{font-size:22px;color:#fff;font-weight:800;letter-spacing:-.5px;
184
- margin-bottom:4px;text-align:center;font-family:'Sora',sans-serif}
185
- .subtitle{font-size:13px;color:rgba(255,255,255,.7);font-weight:600;
186
- margin-bottom:52px;text-align:center}
187
- .bar-wrap{width:280px;height:4px;background:rgba(255,255,255,.15);
188
- border-radius:99px;overflow:hidden;margin-bottom:20px}
189
- .bar-fill{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
190
- .status{font-size:12px;color:rgba(255,255,255,.65);font-weight:600;
191
- letter-spacing:.3px;min-height:18px;text-align:center}
192
- .dots span{animation:blink 1.2s infinite;opacity:0}
193
- .dots span:nth-child(2){animation-delay:.2s}
194
- .dots span:nth-child(3){animation-delay:.4s}
195
- @keyframes blink{0%,100%{opacity:0}50%{opacity:1}}
196
- .spinner{width:36px;height:36px;border:3px solid rgba(255,255,255,.2);
197
- border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite;
198
- margin-bottom:32px}
199
- @keyframes spin{to{transform:rotate(360deg)}}
200
- </style>
201
- <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;600;700;800&family=Sora:wght@700;800&display=swap" rel="stylesheet">
202
-
203
- <div class="overlay" id="overlay">
204
- <div class="logos">
205
- <img class="logo-img" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
206
- <div class="divider"></div>
207
- <img class="logo-img" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
208
- </div>
209
- <div class="spinner"></div>
210
- <div class="title">PMJA — Dashboard Recebimento</div>
211
- <div class="subtitle">Gestão de Materiais</div>
212
- <div class="bar-wrap"><div class="bar-fill" id="bar"></div></div>
213
- <div class="status" id="status">
214
- Conectando ao Google Sheets
215
- <span class="dots"><span>.</span><span>.</span><span>.</span></span>
216
- </div>
217
- </div>
218
-
219
- <script>
220
- var steps=[
221
- {p:18, msg:'Conectando ao Google Sheets'},
222
- {p:40, msg:'Carregando dados de recebimento'},
223
- {p:62, msg:'Processando métricas'},
224
- {p:80, msg:'Montando gráficos'},
225
- {p:95, msg:'Quase pronto'}
226
- ];
227
- var i=0;
228
- function next(){
229
- if(i>=steps.length) return;
230
- var s=steps[i++];
231
- document.getElementById('bar').style.width=s.p+'%';
232
- document.getElementById('status').innerHTML=
233
- s.msg+'<span class="dots"><span>.</span><span>.</span><span>.</span></span>';
234
- setTimeout(next, 950+Math.random()*550);
235
- }
236
- setTimeout(next, 300);
237
- </script>
238
- """
239
-
240
- loading_placeholder = st.empty()
241
-
242
- with loading_placeholder:
243
- components.html(loading_html, height=0)
244
-
245
- df_recebimento = read_ws("recebimento")
246
- df_rec_dados = read_ws("rec_dados")
247
-
248
- loading_placeholder.empty()
249
-
250
-
251
  if df_recebimento is not None and not df_recebimento.empty:
252
  df_plot = df_recebimento.copy()
253
  colunas = ['mes_ano','qtd_descarregamentos','qtd_volumes_recebidos',
@@ -267,9 +218,9 @@ if df_recebimento is not None and not df_recebimento.empty:
267
  df_rec, sistemas, cores_sistemas, mes_max_rec = None, [], [], 0
268
  mapeamentos_rec = [{}, {}, {}]
269
  metricas_rec = [
270
- {'palavra_chave': 'Volumes', 'titulo': 'Volumes Recebidos por Sistema', 'excluir': ['Unid.','unidade']},
271
- {'palavra_chave': 'Unid. Itens', 'titulo': 'Unidades de Itens por Sistema', 'excluir': ['por unidade']},
272
- {'palavra_chave': 'Itens por unidade', 'titulo': 'Itens por Unidade por Sistema', 'excluir': ['Unid. Itens']}
273
  ]
274
  if df_rec_dados is not None and not df_rec_dados.empty:
275
  df_rec = df_rec_dados.copy()
@@ -295,25 +246,25 @@ if df_recebimento is not None and not df_recebimento.empty:
295
 
296
  metricas_linha1 = [
297
  {'coluna':'qtd_descarregamentos','titulo':'Descarregamentos',
298
- 'cores':['#003a70','#1a5a8f','#3379ae','#4d99cd'],'sufixo':''},
299
  {'coluna':'peso_recebido','titulo':'Peso Recebido',
300
- 'cores':['#0075be','#3390cc','#66abd9','#99c6e6'],'sufixo':' kg'},
301
  {'coluna':'qtd_volumes_recebidos','titulo':'Volumes Recebidos',
302
- 'cores':['#005a9c','#3377b3','#6694c9','#99b1df'],'sufixo':''}
303
  ]
304
  metricas_linha2 = [
305
  {'coluna':'qtd_unidades_recebidos','titulo':'Unidades Recebidas',
306
- 'cores':['#003a70','#266190','#4d88b0','#73afd0'],'sufixo':''},
307
  {'coluna':'qtd_itens_por_unidade','titulo':'Itens por Unidade',
308
- 'cores':['#0075be','#3390cc','#66abd9','#99c6e6'],'sufixo':''}
309
  ]
310
 
311
  def titulo_anos(config):
312
  parts = []
313
  for idx_ano, ano in enumerate(anos_unicos):
314
  cor = config['cores'][idx_ano % len(config['cores'])]
315
- total = formatar_numero(df_plot[df_plot['ano']==ano][config['coluna']].sum())
316
- parts.append(f"<span style='color:{cor};font-weight:700'>{ano}: {total}{config['sufixo']}</span>")
317
  return " <span style='color:#666'>|</span> ".join(parts)
318
 
319
  def titulo_sistemas(idx_m):
@@ -323,9 +274,9 @@ if df_recebimento is not None and not df_recebimento.empty:
323
  for cat_idx, sistema in enumerate(sistemas):
324
  col_nome = next((c for c,s in mapa.items() if s==sistema), None)
325
  if col_nome:
326
- total = formatar_numero(df_rec[col_nome].sum())
327
  cor = cores_sistemas[cat_idx]
328
- parts.append(f"<span style='color:{cor};font-weight:700'>{sistema}: {total}</span>")
329
  return " <span style='color:#666'>|</span> ".join(parts)
330
 
331
  frames, meses_pt = build_frames(
@@ -334,69 +285,77 @@ if df_recebimento is not None and not df_recebimento.empty:
334
  metricas_rec, mapeamentos_rec
335
  )
336
 
337
- frames_json = json.dumps(frames, ensure_ascii=False)
338
- meses_json = json.dumps(meses_pt)
339
- anos_json = json.dumps([int(a) for a in anos_unicos])
340
-
341
  sub_l1 = json.dumps([titulo_anos(c) for c in metricas_linha1])
342
  sub_l2 = json.dumps([titulo_anos(c) for c in metricas_linha2])
343
  sub_l3 = json.dumps([titulo_sistemas(i) for i in range(3)])
344
 
345
- html = f"""<!DOCTYPE html><html><head>
 
 
346
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
347
  <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">
348
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
349
  <style>
350
- *{{margin:0;padding:0;box-sizing:border-box}}
351
- html,body{{width:100%;height:100%;overflow:hidden;
352
  background:linear-gradient(135deg,#f5f7fa 0%,#e8eef5 100%);
353
- font-family:'DM Sans',sans-serif;}}
354
- .root{{display:grid;width:100%;height:100dvh;gap:3px;padding:4px;overflow:hidden;
355
- grid-template-rows:auto auto 1fr 1fr 1fr;}}
356
- .hdr{{background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
357
- border-radius:8px;padding:4px 14px;display:flex;align-items:center;justify-content:space-between;
358
- box-shadow:0 4px 12px rgba(0,58,112,.12);}}
359
- .logo{{height:clamp(22px,3.2dvh,36px);filter:brightness(0) invert(1);opacity:.92}}
360
- .ht{{font-family:'Sora',sans-serif;font-size:clamp(12px,1.8dvh,20px);color:#fff;font-weight:800;letter-spacing:-.5px}}
361
- .hs{{font-family:'DM Sans',sans-serif;font-size:clamp(9px,1.2dvh,14px);color:rgba(255,255,255,.9);font-weight:700}}
362
- .cards{{display:grid;grid-template-columns:repeat(5,1fr);gap:3px;}}
363
- .mc{{background:white;border-radius:8px;padding:clamp(3px,.5dvh,7px) clamp(5px,.6vw,12px);
364
- border-left:3px solid;box-shadow:0 2px 8px rgba(0,58,112,.06);overflow:hidden;}}
365
- .ml{{font-family:'DM Sans',sans-serif;font-size:clamp(7px,.85dvh,11px);color:#6b7280;
366
- font-weight:700;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px;white-space:nowrap;}}
367
- .mv{{font-family:'Sora',sans-serif;font-size:clamp(12px,1.9dvh,22px);
368
- font-weight:800;line-height:1;letter-spacing:-.4px;white-space:nowrap;}}
369
- .row3{{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;min-height:0;}}
370
- .row2{{display:grid;grid-template-columns:repeat(2,1fr);gap:3px;min-height:0;}}
371
- .cb{{background:white;border-radius:8px;box-shadow:0 2px 8px rgba(0,58,112,.06);
372
- overflow:visible;min-height:0;display:flex;flex-direction:column;}}
373
- .ct{{background:white;padding:3px 6px 0;border-radius:8px 8px 0 0;
374
- border-bottom:1px solid #f0f0f0;flex-shrink:0;}}
375
- .ct-title{{font-family:'Sora',sans-serif;font-size:clamp(9px,1.1dvh,14px);
376
- color:#1a1a1a;font-weight:800;text-align:center;}}
377
- .ct-sub{{text-align:center;font-size:clamp(7px,.8dvh,10px);font-weight:700;
378
- margin-top:1px;line-height:1.3;}}
379
- .cp{{flex:1;min-height:0;overflow:visible;}}
380
- @media(max-width:900px){{
381
- html,body{{overflow-y:auto!important;height:auto!important}}
382
- .root{{height:auto!important;overflow:visible!important;grid-template-rows:auto!important}}
383
- .cards{{grid-template-columns:repeat(3,1fr)!important}}
384
- .row3{{grid-template-columns:repeat(2,1fr)!important;min-height:200px}}
385
- .row2{{grid-template-columns:1fr 1fr!important;min-height:200px}}
386
- .cb{{min-height:220px!important}}
387
- .cp{{min-height:180px!important}}
388
- }}
389
- @media(max-width:600px){{
390
- .cards{{grid-template-columns:repeat(2,1fr)!important}}
391
- .row3,.row2{{grid-template-columns:1fr!important}}
392
- .cb{{min-height:200px!important}}
393
- .hs{{display:none}}
394
- .hdr{{padding:4px 8px!important}}
395
- }}
 
 
 
 
 
 
 
396
  </style>
397
- </head><body>
 
398
  <div class="root">
399
-
400
  <div class="hdr">
401
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
402
  <div style="text-align:center;flex:1">
@@ -405,509 +364,431 @@ html,body{{width:100%;height:100%;overflow:hidden;
405
  </div>
406
  <img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
407
  </div>
408
-
409
  <div class="cards" id="cards-row"></div>
410
  <div class="row3" id="row1"></div>
411
  <div class="row2" id="row2"></div>
412
  <div class="row3" id="row3"></div>
413
-
414
  </div>
415
-
416
  <script>
417
- var FRAMES = {frames_json};
418
- var MESES = {meses_json};
419
- var ANOS = {anos_json};
420
- var SUB_L1 = {sub_l1};
421
- var SUB_L2 = {sub_l2};
422
- var SUB_L3 = {sub_l3};
423
 
424
  var CARDS_CFG = [
425
- {{col:'qtd_descarregamentos', titulo:'Descarregamentos', cor:'#003a70', sufixo:''}},
426
- {{col:'peso_recebido', titulo:'Peso Recebido', cor:'#005a9c', sufixo:' kg'}},
427
- {{col:'qtd_volumes_recebidos',titulo:'Volumes Recebidos', cor:'#0075be', sufixo:''}},
428
- {{col:'qtd_unidades_recebidos',titulo:'Unidades Recebidas',cor:'#4d9fd6',sufixo:''}},
429
- {{col:'qtd_itens_por_unidade', titulo:'Itens por Unidade', cor:'#80b9e5', sufixo:''}}
430
  ];
431
 
432
  var TITULOS_L1 = ['Descarregamentos','Peso Recebido','Volumes Recebidos'];
433
  var TITULOS_L2 = ['Unidades Recebidas','Itens por Unidade'];
434
  var TITULOS_L3 = ['Volumes Recebidos por Sistema','Unidades de Itens por Sistema','Itens por Unidade por Sistema'];
435
 
436
- var FRAME_MS = 900;
437
- var PAUSE_MS = 60000;
438
- var frameIdx = 0, animTimer = null, cdownTimer = null;
439
- var initialized = {{}};
 
 
440
 
441
- function buildDOM() {{
442
  var cr = document.getElementById('cards-row');
443
- CARDS_CFG.forEach(function(c) {{
444
- cr.innerHTML += '<div class="mc" style="border-left-color:'+c.cor+'" id="card_'+c.col+'">'
445
- +'<div class="ml">'+c.titulo+'</div>'
446
- +'<div class="mv" id="mv_'+c.col+'" style="color:'+c.cor+'">0'+c.sufixo+'</div></div>';
447
- }});
448
-
449
  var r1 = document.getElementById('row1');
450
- for(var i=0;i<3;i++) {{
451
  r1.innerHTML += '<div class="cb"><div class="ct">'
452
- +'<div class="ct-title">'+TITULOS_L1[i]+'</div>'
453
- +'<div class="ct-sub" id="sub_l1_'+i+'">'+SUB_L1[i]+'</div></div>'
454
- +'<div class="cp" id="l1_'+i+'"></div></div>';
455
- }}
456
-
457
  var r2 = document.getElementById('row2');
458
- for(var i=0;i<2;i++) {{
459
  r2.innerHTML += '<div class="cb"><div class="ct">'
460
- +'<div class="ct-title">'+TITULOS_L2[i]+'</div>'
461
- +'<div class="ct-sub" id="sub_l2_'+i+'">'+SUB_L2[i]+'</div></div>'
462
- +'<div class="cp" id="l2_'+i+'"></div></div>';
463
- }}
464
-
465
  var r3 = document.getElementById('row3');
466
- for(var i=0;i<3;i++) {{
467
  r3.innerHTML += '<div class="cb"><div class="ct">'
468
- +'<div class="ct-title">'+TITULOS_L3[i]+'</div>'
469
- +'<div class="ct-sub" id="sub_l3_'+i+'">'+SUB_L3[i]+'</div></div>'
470
- +'<div class="cp" id="l3_'+i+'"></div></div>';
471
- }}
472
- }}
473
 
474
- function gsize(id) {{
475
  var el = document.getElementById(id);
476
- if(!el) return [200,150];
477
  return [el.clientWidth||200, el.clientHeight||150];
478
- }}
479
- var tickvals = Object.keys(MESES).map(Number);
480
- var ticktext = Object.values(MESES);
481
-
482
- // ── Line chart ─────────────────────────────────────────────────────
483
- // Labels em coordenadas Y de dados — anti-colisão real por mês.
484
- // Para cada mês agrupa todos os pontos, ordena por Y,
485
- // e empilha as annotations com offset mínimo garantido em unidades de dados.
486
- function renderLine(traces, elId, sufixo) {{
487
- var wh = gsize(elId), data = [];
488
- var n = traces.length;
489
 
 
 
 
 
490
  var allY = [];
491
- traces.forEach(function(t){{ t.y.forEach(function(v){{if(v>=0) allY.push(v);}}); }});
492
  var yMax = allY.length ? Math.max.apply(null,allY) : 1;
493
-
494
  var allX = [];
495
- traces.forEach(function(t){{ t.x.forEach(function(v){{ allX.push(v); }}); }});
496
  var xMin = allX.length ? Math.min.apply(null,allX) : 1;
497
  var xMax2 = allX.length ? Math.max.apply(null,allX) : 12;
498
-
499
- // headroom: espaço acima do yMax para acomodar labels empilhados
500
- // cada ano extra adiciona 8% — 1 ano=40%, 7 anos=88%
501
  var headroom = 1.40 + Math.max(0, n-1) * 0.08;
502
  var yTop = yMax * headroom;
503
-
504
- // offset mínimo entre labels adjacentes = 5% do range total visível
505
  var MIN_OFFSET = yTop * 0.055;
506
 
507
- traces.forEach(function(t, i) {{
508
- data.push({{
 
509
  type:'scatter', mode:'lines+markers',
510
  x:t.x, y:t.y,
511
  cliponaxis:false, showlegend:false, name:t.ano,
512
- line:{{width:2.5, color:t.cor, shape:'spline'}},
513
- marker:{{size:6, color:t.cor, line:{{width:1.5, color:'white'}}}},
514
  fill: i>0 ? 'tonexty' : null,
515
- fillcolor: t.fill_rgba,
516
- hovertemplate:'<b>'+t.ano+'</b> %{{customdata}}: <b>%{{text}}</b>'+sufixo+'<extra></extra>',
517
- customdata: t.x.map(function(m){{return MESES[m];}},),
518
  text: t.text
519
- }});
520
- }});
521
-
522
- // ── Coleta todos os pontos com label agrupados por mês ──
523
- var byX = {{}};
524
- traces.forEach(function(t) {{
525
- t.x.forEach(function(mx, j) {{
526
- if(!t.text[j]) return;
527
- if(!byX[mx]) byX[mx] = [];
528
- byX[mx].push({{ yv: t.y[j], txt: t.text[j], cor: t.cor }});
529
- }});
530
- }});
531
 
532
  var annotations = [];
533
-
534
- Object.keys(byX).forEach(function(mxStr) {{
535
  var mx = parseInt(mxStr);
536
  var pts = byX[mx];
537
-
538
- // Ordena crescente por valor Y
539
- pts.sort(function(a,b){{ return a.yv - b.yv; }});
540
-
541
- // Calcula posição Y da annotation para cada ponto:
542
- // começa em yv + MIN_OFFSET e garante que cada próximo
543
- // está pelo menos MIN_OFFSET acima do anterior
544
  var labelY = [];
545
- pts.forEach(function(p, k) {{
546
  var candidate = p.yv + MIN_OFFSET;
547
- if(k > 0 && candidate < labelY[k-1] + MIN_OFFSET) {{
548
- candidate = labelY[k-1] + MIN_OFFSET;
549
- }}
550
  labelY.push(candidate);
551
- }});
552
-
553
- // xanchor nas bordas para não vazar
554
- var xanchor = mx === xMin ? 'left' : (mx === xMax2 ? 'right' : 'center');
555
-
556
- pts.forEach(function(p, k) {{
557
- annotations.push({{
558
- x: mx, y: labelY[k],
559
- xref:'x', yref:'y',
560
- text: p.txt,
561
- showarrow: false,
562
- xanchor: xanchor,
563
- yanchor: 'middle',
564
- font: {{size:9, family:'DM Sans', color:'#111827'}},
565
- bgcolor: 'rgba(0,0,0,0)',
566
- borderpad: 0
567
- }});
568
- }});
569
- }});
570
-
571
- var layout = {{
572
- width:wh[0], height:wh[1], autosize:false,
573
  hovermode:'x unified', template:'plotly_white',
574
- font:{{family:'DM Sans',size:10}}, showlegend:false,
575
- margin:{{l:42,r:18,t:24,b:36}},
576
  plot_bgcolor:'rgba(248,249,250,0.5)',
577
- xaxis:{{
578
  tickmode:'array', tickvals:tickvals, ticktext:ticktext,
579
- tickangle:0, tickfont:{{size:9,family:'DM Sans',color:'#555'}},
580
  showgrid:true, gridwidth:1, gridcolor:'rgba(200,200,200,0.25)',
581
  fixedrange:true, range:[xMin-0.4, xMax2+0.4]
582
- }},
583
- yaxis:{{
584
- tickfont:{{size:9,family:'DM Sans'}},
585
  showgrid:true, gridwidth:1, gridcolor:'rgba(200,200,200,0.25)',
586
  range:[0, yTop], fixedrange:true,
587
- tickformat: yMax >= 100000 ? '.2s' : null
588
- }},
589
- annotations: annotations,
590
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#ddd',namelength:-1}}
591
- }};
 
592
  var el = document.getElementById(elId);
593
- if(!initialized[elId]) {{
594
- Plotly.newPlot(el, data, layout, {{displayModeBar:false,responsive:false}});
595
- initialized[elId] = true;
596
- }} else {{
597
  Plotly.react(el, data, layout);
598
- }}
599
- }}
600
-
601
 
602
- // ── Pizza ──────────────────────────────────────────────────────────
603
- function renderPizza(item, elId) {{
604
- if(!item.valores.length) return;
605
- var wh = gsize(elId);
606
- var total = item.valores.reduce(function(s,v){{return s+v;}},0);
607
- var labels = item.labels.map(function(l,i){{
608
  var pct = total>0 ? (item.valores[i]/total*100).toFixed(1) : '0.0';
609
- var vf = item.valores[i].toString().replace(/\\B(?=(\\d{{3}})+(?!\\d))/g,'.');
610
  return l+': '+vf+' ('+pct+'%)';
611
- }});
612
- var data = [{{
613
  type:'pie', labels:labels, values:item.valores,
614
- marker:{{colors:item.cores, line:{{color:'white',width:2}}}},
615
  hole:0.3, textposition:'inside',
616
- hovertemplate:'<b>%{{label}}</b><br>Volumes: %{{value:,.0f}}<br>Percentual: %{{percent}}<extra></extra>'
617
- }}];
618
- var layout = {{
619
- width:wh[0],height:wh[1],autosize:false,
620
- template:'plotly_white',
621
- font:{{family:'DM Sans',size:9}}, showlegend:true,
622
- legend:{{orientation:'v',yanchor:'middle',y:0.5,xanchor:'left',x:1.02,font:{{size:8.5}}}},
623
- margin:{{l:20,r:150,t:8,b:8}},
624
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}}
625
- }};
626
  var el = document.getElementById(elId);
627
- if(!initialized[elId]) {{
628
- Plotly.newPlot(el, data, layout, {{displayModeBar:false,responsive:false}});
629
- initialized[elId] = true;
630
- }} else {{
631
  Plotly.react(el, data, layout);
632
- }}
633
- }}
634
-
635
- // ── Bar H ──────────────────────────────────────────────────────────
636
- // FIX: removida linha vertical esquerda (showline:false, zeroline:false)
637
- // FIX: margem esquerda maior para labels não ficarem grudados
638
- function renderBarH(item, elId) {{
639
- if(!item.valores.length) return;
640
- var wh = gsize(elId);
641
- var maxVal = Math.max.apply(null, item.valores);
 
 
 
 
642
 
643
- function fmtDot(v) {{
644
- if(v===null||v===undefined) return '0';
645
- var n=Math.round(v); if(n===0) return '0';
646
- var s=n.toString(),r='',c=0;
647
- for(var i=s.length-1;i>=0;i--){{if(c>0&&c%3===0)r='.'+r;r=s[i]+r;c++;}}
648
- return r;
649
- }}
650
-
651
- var pairs = item.labels.map(function(l,i){{return {{l:l,v:item.valores[i],c:item.cores[i]}}; }});
652
- pairs.sort(function(a,b){{return b.v-a.v;}});
653
- var sL = pairs.map(function(p){{return p.l;}});
654
- var sV = pairs.map(function(p){{return p.v;}});
655
- var sC = pairs.map(function(p){{return p.c;}});
656
-
657
- var trackData = {{
 
658
  type:'bar', orientation:'h',
659
- x: sV.map(function(){{return maxVal;}}), y: sL,
660
- marker:{{color:sC.map(function(c){{
661
- var r=parseInt(c.slice(1,3),16),g=parseInt(c.slice(3,5),16),b=parseInt(c.slice(5,7),16);
662
- return 'rgba('+r+','+g+','+b+',0.12)';
663
- }}),line:{{width:0}}}},
664
  hoverinfo:'skip', showlegend:false,
665
- text:sL.map(function(){{return '';}})
666
- }};
667
-
668
- var barData = {{
669
  type:'bar', orientation:'h',
670
  x:sV, y:sL,
671
- marker:{{color:sC, line:{{width:0}}}},
672
- hovertemplate:'<b>%{{y}}</b><br>%{{x:,.0f}}<extra></extra>',
673
  showlegend:false,
674
- text:sL.map(function(){{return '';}})
675
- }};
676
 
677
  var threshold = maxVal * 0.20;
678
- var annotations = sV.map(function(v,i){{
679
  var isShort = v < threshold;
680
- return {{
681
- x: isShort ? v + maxVal*0.015 : v - maxVal*0.015,
682
  y: sL[i],
683
- text: '<b>'+fmtDot(v)+'</b>',
684
- xref:'x', yref:'y',
685
- showarrow:false,
686
  xanchor: isShort ? 'left' : 'right',
687
  yanchor:'middle',
688
- font:{{size:11,family:'DM Sans',color: isShort ? '#003a70' : 'white'}}
689
- }};
690
- }});
691
-
692
- var layout = {{
693
- width:wh[0], height:wh[1], autosize:false,
694
- barmode:'overlay',
695
- template:'plotly_white', showlegend:false,
696
- font:{{family:'DM Sans',size:9}},
697
- // FIX: margem esquerda aumentada para afastar labels do início do gráfico
698
- margin:{{l:150,r:70,t:6,b:20}},
699
- plot_bgcolor:'rgba(0,0,0,0)',
700
- paper_bgcolor:'rgba(0,0,0,0)',
701
- xaxis:{{visible:false, range:[0, maxVal*1.20], fixedrange:true}},
702
- yaxis:{{
703
- tickfont:{{size:10,family:'DM Sans',color:'#333',weight:700}},
704
- showgrid:false,
705
- // FIX: linha vertical esquerda removida
706
- showline:false,
707
- zeroline:false,
708
- ticksuffix:' ',
709
- fixedrange:true,
710
- automargin:true
711
- }},
712
- annotations: annotations,
713
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}}
714
- }};
715
 
716
  var el = document.getElementById(elId);
717
- if(!initialized[elId]) {{
718
- Plotly.newPlot(el, [trackData, barData], layout, {{displayModeBar:false,responsive:false}});
719
- initialized[elId] = true;
720
- }} else {{
721
  Plotly.react(el, [trackData, barData], layout);
722
- }}
723
-
724
- setTimeout(function() {{
725
- el.querySelectorAll('.bars path').forEach(function(p) {{
726
- var d=p.getAttribute('d'); if(!d) return;
727
- var toks=d.match(/[A-Za-z][^A-Za-z]*/g)||[],cx=0,cy=0,aX=[],aY=[];
728
- toks.forEach(function(tok){{
729
- var cmd=tok[0];
730
- var pts=tok.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){{return !isNaN(n);}});
731
- if(cmd==='M'||cmd==='L'){{aX.push(pts[0]);aY.push(pts[1]);cx=pts[0];cy=pts[1];}}
732
- else if(cmd==='H'){{aX.push(pts[0]);aY.push(cy);cx=pts[0];}}
733
- else if(cmd==='V'){{aX.push(cx);aY.push(pts[0]);cy=pts[0];}}
734
- }});
735
- if(aX.length<2) return;
736
- var x0=Math.min.apply(null,aX),x1=Math.max.apply(null,aX);
737
- var y0=Math.min.apply(null,aY),y1=Math.max.apply(null,aY);
738
- var w=x1-x0,h=y1-y0; if(w<=0||h<=0) return;
739
- var r=Math.min(7,h/2);
740
- var nd='M '+x0+','+y0
741
- +' H '+(x1-r)+' Q '+x1+','+y0+' '+x1+','+(y0+r)
742
- +' V '+(y1-r)+' Q '+x1+','+y1+' '+(x1-r)+','+y1
743
- +' H '+x0+' Z';
744
- p.setAttribute('d',nd);
745
- }});
746
- }},80);
747
- }}
748
-
749
- // ── Bar V ──────────────────────────────────────────────────────────
750
- // FIX: margem esquerda e inferior maiores para labels não grudarem
751
- function renderBarV(item, elId) {{
752
- if(!item.valores.length) return;
753
- var el = document.getElementById(elId);
754
- var wh = gsize(elId);
755
- var maxVal = Math.max.apply(null, item.valores);
756
-
757
- function fmtDot(v) {{
758
- if(!v || v===0) return '';
759
- var s = Math.round(v).toString(), r='', c=0;
760
- for(var i=s.length-1;i>=0;i--){{ if(c>0&&c%3===0) r='.'+r; r=s[i]+r; c++; }}
761
- return r;
762
- }}
763
 
764
- var texts = item.valores.map(function(v){{ return v>0 ? fmtDot(v) : ''; }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
 
766
- var data = [{{
 
 
 
 
 
 
767
  type:'bar',
768
  x:item.labels, y:item.valores,
769
- text:texts,
770
- textposition:'outside',
771
- cliponaxis:false,
772
- textfont:{{size:10,family:'DM Sans',color:'#333',weight:800}},
773
- marker:{{color:item.cores, line:{{width:0}}, cornerradius:8}},
774
- hovertemplate:'<b>%{{x}}</b><br>%{{y:,.0f}}<extra></extra>'
775
- }}];
776
-
777
- var layout = {{
778
- width:wh[0], height:wh[1], autosize:false,
779
  template:'plotly_white', showlegend:false,
780
- font:{{family:'DM Sans',size:9}},
781
- // FIX: margens ajustadas — esquerda maior, bottom maior para labels não grudarem
782
- margin:{{l:30,r:20,t:40,b:90}},
783
- plot_bgcolor:'rgba(248,249,250,0.5)',
784
- paper_bgcolor:'white',
785
- xaxis:{{
786
- tickfont:{{size:8,family:'DM Sans',weight:700}},
787
- tickangle:-35,
788
- showgrid:false,
789
- fixedrange:true,
790
- // FIX: automargin para labels não serem cortados
791
- automargin:true,
792
- // FIX: standoff para afastar labels do eixo
793
- ticklabelstandoff: 8
794
- }},
795
- yaxis:{{
796
- tickfont:{{size:9,family:'DM Sans'}},
797
- showgrid:true, gridwidth:1,
798
- gridcolor:'rgba(200,200,200,0.2)',
799
- range:[0, maxVal*1.35],
800
- fixedrange:true
801
- }},
802
- hoverlabel:{{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}}
803
- }};
804
-
805
  el.style.overflow = 'visible';
806
- if(el.parentElement) el.parentElement.style.overflow = 'visible';
807
-
808
- if(!initialized[elId]) {{
809
- Plotly.newPlot(el, data, layout, {{displayModeBar:false,responsive:false}});
810
- initialized[elId] = true;
811
- }} else {{
812
  Plotly.react(el, data, layout);
813
- }}
814
  roundBars(el, 8, 'v');
815
- }}
816
-
817
- // ── Rounded bars ──────────────────────────────────────────────────
818
- function roundBars(boxEl, r, orientation) {{
819
- setTimeout(function() {{
820
- boxEl.querySelectorAll('.bars path').forEach(function(p) {{
821
- var d=p.getAttribute('d'); if(!d) return;
822
- var tokens=d.match(/[A-Za-z][^A-Za-z]*/g)||[];
823
- var cx=0,cy=0,aX=[],aY=[];
824
- tokens.forEach(function(tok) {{
825
- var cmd=tok[0];
826
- var pts=tok.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){{return !isNaN(n);}});
827
- if(cmd==='M'||cmd==='L'){{aX.push(pts[0]);aY.push(pts[1]);cx=pts[0];cy=pts[1];}}
828
- else if(cmd==='H'){{aX.push(pts[0]);aY.push(cy);cx=pts[0];}}
829
- else if(cmd==='V'){{aX.push(cx);aY.push(pts[0]);cy=pts[0];}}
830
- }});
831
- if(aX.length<2) return;
832
- var x0=Math.min.apply(null,aX),x1=Math.max.apply(null,aX);
833
- var y0=Math.min.apply(null,aY),y1=Math.max.apply(null,aY);
834
- var w=x1-x0,h=y1-y0; if(w<=0||h<=0) return;
835
- var rr=Math.min(r,w/2,h/2), nd;
836
- if(orientation==='h') {{
837
- nd='M '+x0+','+(y0+rr)+' Q '+x0+','+y0+' '+(x0+rr)+','+y0
838
- +' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
839
- +' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1
840
- +' H '+(x0+rr)+' Q '+x0+','+y1+' '+x0+','+(y1-rr)+' Z';
841
- }} else {{
842
- nd='M '+x0+','+y1+' V '+(y0+rr)
843
- +' Q '+x0+','+y0+' '+(x0+rr)+','+y0
844
- +' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
845
- +' V '+y1+' Z';
846
- }}
847
- p.setAttribute('d',nd);
848
- }});
849
- }},60);
850
- }}
851
-
852
- // ── Render frame ──────────────────────────────────────────────────
853
- function renderFrame(f) {{
854
- CARDS_CFG.forEach(function(c) {{
855
  var cd = f.cards[c.col];
856
- if(cd) document.getElementById('mv_'+c.col).textContent = cd.valor + cd.sufixo;
857
- }});
858
- f.linha1.forEach(function(item, i) {{ renderLine(item.traces, 'l1_'+i, item.sufixo); }});
859
- f.linha2.forEach(function(item, i) {{ renderLine(item.traces, 'l2_'+i, item.sufixo); }});
860
- f.linha3.forEach(function(item, i) {{
861
- if(item.tipo===0) renderPizza(item,'l3_'+i);
862
- else if(item.tipo===1) renderBarH(item,'l3_'+i);
863
- else if(item.tipo===2) renderBarV(item,'l3_'+i);
864
- }});
865
- }}
866
-
867
- // ── Animation ─────────────────────────────────────────────────────
868
- function stepFrame() {{
869
  renderFrame(FRAMES[frameIdx]);
870
  frameIdx++;
871
- if(frameIdx >= FRAMES.length) {{
872
  clearTimeout(animTimer);
873
- cdownTimer = setTimeout(function() {{
874
- frameIdx = 0;
875
- startAnimation();
876
- }}, PAUSE_MS);
877
- }} else {{
878
  animTimer = setTimeout(stepFrame, FRAME_MS);
879
- }}
880
- }}
881
 
882
- function startAnimation() {{
883
  clearTimeout(animTimer);
884
  clearTimeout(cdownTimer);
885
  animTimer = setTimeout(stepFrame, FRAME_MS);
886
- }}
887
 
888
  var _rt;
889
- window.addEventListener('resize', function() {{
890
  clearTimeout(_rt);
891
- _rt = setTimeout(function() {{
892
- document.querySelectorAll('.cp').forEach(function(box) {{
893
- if(box.data) Plotly.relayout(box, {{width:box.clientWidth, height:box.clientHeight}});
894
- }});
895
- }}, 200);
896
- }});
897
-
898
- window.addEventListener('load', function() {{
899
  buildDOM();
900
- setTimeout(function() {{ frameIdx=0; startAnimation(); }}, 400);
901
- }});
902
  </script>
903
- </body></html>"""
 
 
 
 
 
 
 
 
 
 
904
 
905
- components.html(html, height=8000, scrolling=False)
906
 
907
  else:
908
- components.html("""<!DOCTYPE html><html><body style='display:flex;align-items:center;
909
- justify-content:center;height:100dvh;font-family:sans-serif;color:#003a70;background:#f5f7fa;'>
910
- <div style='text-align:center'><div style='font-size:2em'>📊</div>
911
  <p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
912
  height=4000, scrolling=False)
913
 
 
7
  from gs_client import read_ws
8
 
9
  st.set_page_config(layout="wide", initial_sidebar_state="collapsed", page_title="PMJA - Dashboard Recebimento")
10
+
11
+ st.markdown("""<style>
12
+ #MainMenu,footer,header,[data-testid="stToolbar"],[data-testid="stDecoration"],
13
+ [data-testid="stStatusWidget"]{display:none!important}
14
+ .block-container,.element-container,.stMarkdown{padding:0!important;margin:0!important;max-width:100%!important}
15
+ iframe{position:fixed!important;top:0!important;left:0!important;
16
+ width:100vw!important;height:100dvh!important;border:none!important;z-index:9999!important}
17
+ [data-testid="stHorizontalBlock"]{gap:0!important;margin:0!important;padding:0!important;
18
+ height:0!important;overflow:visible!important}
19
+ .main,[data-testid="stAppViewContainer"]{background:#f5f7fa!important}
20
+ @media(max-width:900px){
21
+ iframe{position:static!important;width:100%!important;height:7500px!important;z-index:1!important}
22
+ .main,[data-testid="stAppViewContainer"]{overflow-y:auto!important;height:auto!important}
23
+ .block-container{height:auto!important}
24
+ }
25
+ </style>""", unsafe_allow_html=True)
26
+
27
  # ── SESSION ───────────────────────────────────────────────────────────
28
  _cc = CookieController()
29
  COOKIE_NAME = "pmja_session"
 
54
  st.session_state.inicio_exibicao = time.time()
55
  st.switch_page("pages/exp.py")
56
 
57
+ # ── LOADING ───────────────────────────────────────────────────────────
58
+ loading_placeholder = st.empty()
59
+ loading_placeholder.markdown("""
60
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700;800&family=Sora:wght@800&display=swap" rel="stylesheet">
61
+ <style>
62
+ #ld{position:fixed;inset:0;z-index:999999;background:linear-gradient(135deg,#003a70,#0056A3);
63
+ display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:'DM Sans',sans-serif}
64
+ #ld .lg{display:flex;align-items:center;gap:24px;margin-bottom:36px}
65
+ #ld img{height:34px;filter:brightness(0) invert(1);opacity:.9}
66
+ #ld .dv{width:1px;height:32px;background:rgba(255,255,255,.25)}
67
+ #ld h2{font-family:'Sora',sans-serif;font-size:clamp(15px,4vw,21px);color:#fff;font-weight:800;margin-bottom:4px;text-align:center}
68
+ #ld p{font-size:clamp(11px,3vw,13px);color:rgba(255,255,255,.7);font-weight:600;margin-bottom:40px;text-align:center}
69
+ #ld .sp{width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;
70
+ border-radius:50%;animation:sp .8s linear infinite;margin-bottom:24px}
71
+ @keyframes sp{to{transform:rotate(360deg)}}
72
+ #ld .bw{width:min(260px,70vw);height:4px;background:rgba(255,255,255,.15);border-radius:99px;overflow:hidden;margin-bottom:14px}
73
+ #ld .bf{height:100%;width:0%;background:#fff;border-radius:99px;transition:width .4s ease}
74
+ #ld .st{font-size:11px;color:rgba(255,255,255,.6);font-weight:600;text-align:center}
75
+ #ld .dt span{animation:dt 1.2s infinite;opacity:0}
76
+ #ld .dt span:nth-child(2){animation-delay:.2s}
77
+ #ld .dt span:nth-child(3){animation-delay:.4s}
78
+ @keyframes dt{0%,100%{opacity:0}50%{opacity:1}}
79
+ </style>
80
+ <div id="ld">
81
+ <div class="lg">
82
+ <img src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490">
83
+ <div class="dv"></div>
84
+ <img src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg">
85
+ </div>
86
+ <div class="sp"></div>
87
+ <h2>PMJA — Dashboard Recebimento</h2>
88
+ <p>Gestão de Materiais</p>
89
+ <div class="bw"><div class="bf" id="ldbf"></div></div>
90
+ <div class="st" id="ldst">Conectando ao Google Sheets<span class="dt"><span>.</span><span>.</span><span>.</span></span></div>
91
+ </div>
92
+ <script>
93
+ (function(){
94
+ var s=[{p:18,m:'Conectando ao Google Sheets'},{p:40,m:'Carregando dados de recebimento'},
95
+ {p:62,m:'Processando métricas'},{p:80,m:'Montando gráficos'},{p:95,m:'Quase pronto'}];
96
+ var i=0;
97
+ function n(){
98
+ if(i>=s.length)return;
99
+ var x=s[i++];
100
+ var b=document.getElementById('ldbf'),t=document.getElementById('ldst');
101
+ if(b)b.style.width=x.p+'%';
102
+ if(t)t.innerHTML=x.m+'<span class="dt"><span>.</span><span>.</span><span>.</span></span>';
103
+ setTimeout(n,950+Math.random()*550);
104
+ }
105
+ setTimeout(n,300);
106
+ })();
107
+ </script>
108
+ """, unsafe_allow_html=True)
109
+
110
+ # ── LOAD DATA ─────────────────────────────────────────────────────────
111
+ df_recebimento = read_ws("recebimento")
112
+ df_rec_dados = read_ws("rec_dados")
113
+ loading_placeholder.empty()
114
 
115
  # ── HELPERS ───────────────────────────────────────────────────────────
116
  def fmt(n):
 
130
  break
131
  return sorted(list(sistemas_set))
132
 
133
+ # ── BUILD FRAMES ──────────────────────────────────────────────────────
 
 
 
 
134
  def build_frames(df_plot, df_rec, sistemas, cores_sistemas, anos_unicos,
135
  mes_max_dados, mes_max_rec, metricas_linha1, metricas_linha2,
136
  metricas_rec, mapeamentos_rec):
 
137
  meses_pt = {1:'Jan',2:'Fev',3:'Mar',4:'Abr',5:'Mai',6:'Jun',
138
  7:'Jul',8:'Ago',9:'Set',10:'Out',11:'Nov',12:'Dez'}
 
139
  frames = []
 
140
  for mes_atual in range(1, mes_max_dados + 1):
141
  frame = {"mes": mes_atual, "cards": {}, "linha1": [], "linha2": [], "linha3": []}
 
 
142
  df_ate = df_plot[df_plot['mes'] <= mes_atual]
143
  for coluna, titulo, cor, sufixo in [
144
  ('qtd_descarregamentos', 'Descarregamentos', '#003a70', ''),
 
147
  ('qtd_unidades_recebidos','Unidades Recebidas','#4d9fd6',''),
148
  ('qtd_itens_por_unidade','Itens por Unidade','#80b9e5', ''),
149
  ]:
150
+ frame["cards"][coluna] = {"valor": fmt(df_ate[coluna].sum()),
151
+ "sufixo": sufixo, "cor": cor, "titulo": titulo}
 
 
 
 
152
  for config in metricas_linha1:
153
  col = config['coluna']
154
  traces = []
 
157
  if d.empty: continue
158
  cor = config['cores'][idx_ano % len(config['cores'])]
159
  traces.append({
160
+ "ano": str(ano), "cor": cor,
 
161
  "x": [int(x) for x in d['mes']],
162
  "y": [float(v) for v in d[col]],
163
+ "text": [fmt(v) if not pd.isna(v) and v > 0 else "" for v in d[col]],
164
+ "fill_rgba": "rgba({},{},{},0.1)".format(int(cor[1:3],16),int(cor[3:5],16),int(cor[5:7],16))
165
  })
166
  frame["linha1"].append({"titulo": config['titulo'], "sufixo": config['sufixo'], "traces": traces})
 
 
167
  for config in metricas_linha2:
168
  col = config['coluna']
169
  traces = []
 
172
  if d.empty: continue
173
  cor = config['cores'][idx_ano % len(config['cores'])]
174
  traces.append({
175
+ "ano": str(ano), "cor": cor,
 
176
  "x": [int(x) for x in d['mes']],
177
  "y": [float(v) for v in d[col]],
178
+ "text": [fmt(v) if not pd.isna(v) and v > 0 else "" for v in d[col]],
179
+ "fill_rgba": "rgba({},{},{},0.1)".format(int(cor[1:3],16),int(cor[3:5],16),int(cor[5:7],16))
180
  })
181
  frame["linha2"].append({"titulo": config['titulo'], "sufixo": config['sufixo'], "traces": traces})
 
 
182
  if df_rec is not None and mes_atual <= mes_max_rec:
183
  df_ate_rec = df_rec[df_rec['mes'] <= mes_atual]
184
  for idx_m, metrica_info in enumerate(metricas_rec):
 
188
  col_nome = next((c for c, s in mapeamento.items() if s == sistema), None)
189
  if col_nome and col_nome in df_rec.columns:
190
  total = float(df_ate_rec[col_nome].fillna(0).sum())
191
+ valores.append(total); labels.append(sistema); cores.append(cores_sistemas[cat_idx])
192
+ frame["linha3"].append({"titulo": metrica_info['titulo'], "tipo": idx_m,
193
+ "valores": valores, "labels": labels, "cores": cores})
 
 
 
 
 
194
  else:
195
+ for idx_m, metrica_info in enumerate(metricas_rec):
196
+ frame["linha3"].append({"titulo": metrica_info['titulo'], "tipo": idx_m,
197
+ "valores": [], "labels": [], "cores": []})
198
  frames.append(frame)
 
199
  return frames, meses_pt
200
 
201
+ # ── MAIN ──────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  if df_recebimento is not None and not df_recebimento.empty:
203
  df_plot = df_recebimento.copy()
204
  colunas = ['mes_ano','qtd_descarregamentos','qtd_volumes_recebidos',
 
218
  df_rec, sistemas, cores_sistemas, mes_max_rec = None, [], [], 0
219
  mapeamentos_rec = [{}, {}, {}]
220
  metricas_rec = [
221
+ {'palavra_chave': 'Volumes', 'titulo': 'Volumes Recebidos por Sistema', 'excluir': ['Unid.','unidade']},
222
+ {'palavra_chave': 'Unid. Itens', 'titulo': 'Unidades de Itens por Sistema', 'excluir': ['por unidade']},
223
+ {'palavra_chave': 'Itens por unidade', 'titulo': 'Itens por Unidade por Sistema', 'excluir': ['Unid. Itens']}
224
  ]
225
  if df_rec_dados is not None and not df_rec_dados.empty:
226
  df_rec = df_rec_dados.copy()
 
246
 
247
  metricas_linha1 = [
248
  {'coluna':'qtd_descarregamentos','titulo':'Descarregamentos',
249
+ 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'sufixo':''},
250
  {'coluna':'peso_recebido','titulo':'Peso Recebido',
251
+ 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'sufixo':' kg'},
252
  {'coluna':'qtd_volumes_recebidos','titulo':'Volumes Recebidos',
253
+ 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'sufixo':''}
254
  ]
255
  metricas_linha2 = [
256
  {'coluna':'qtd_unidades_recebidos','titulo':'Unidades Recebidas',
257
+ 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'sufixo':''},
258
  {'coluna':'qtd_itens_por_unidade','titulo':'Itens por Unidade',
259
+ 'cores':['#001f3f','#0056A3','#00a8e8','#7ecfef'],'sufixo':''}
260
  ]
261
 
262
  def titulo_anos(config):
263
  parts = []
264
  for idx_ano, ano in enumerate(anos_unicos):
265
  cor = config['cores'][idx_ano % len(config['cores'])]
266
+ total = fmt(df_plot[df_plot['ano']==ano][config['coluna']].sum())
267
+ parts.append("<span style='color:{};font-weight:700'>{}: {}{}</span>".format(cor, ano, total, config['sufixo']))
268
  return " <span style='color:#666'>|</span> ".join(parts)
269
 
270
  def titulo_sistemas(idx_m):
 
274
  for cat_idx, sistema in enumerate(sistemas):
275
  col_nome = next((c for c,s in mapa.items() if s==sistema), None)
276
  if col_nome:
277
+ total = fmt(df_rec[col_nome].sum())
278
  cor = cores_sistemas[cat_idx]
279
+ parts.append("<span style='color:{};font-weight:700'>{}: {}</span>".format(cor, sistema, total))
280
  return " <span style='color:#666'>|</span> ".join(parts)
281
 
282
  frames, meses_pt = build_frames(
 
285
  metricas_rec, mapeamentos_rec
286
  )
287
 
288
+ frames_json = json.dumps(frames, ensure_ascii=False)
289
+ meses_json = json.dumps(meses_pt)
290
+ anos_json = json.dumps([int(a) for a in anos_unicos])
 
291
  sub_l1 = json.dumps([titulo_anos(c) for c in metricas_linha1])
292
  sub_l2 = json.dumps([titulo_anos(c) for c in metricas_linha2])
293
  sub_l3 = json.dumps([titulo_sistemas(i) for i in range(3)])
294
 
295
+ html = """<!DOCTYPE html>
296
+ <html>
297
+ <head>
298
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
299
  <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">
300
  <script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
301
  <style>
302
+ * { margin:0; padding:0; box-sizing:border-box; }
303
+ html, body { width:100%; height:100%; overflow:hidden;
304
  background:linear-gradient(135deg,#f5f7fa 0%,#e8eef5 100%);
305
+ font-family:'DM Sans',sans-serif; }
306
+ .root { display:grid; width:100%; height:100dvh; gap:3px; padding:4px; overflow:hidden;
307
+ grid-template-rows:auto auto 1fr 1fr 1fr; }
308
+ .hdr { background:linear-gradient(135deg,#003a70 0%,#0056A3 100%);
309
+ border-radius:8px; padding:4px 14px; display:flex; align-items:center;
310
+ justify-content:space-between; box-shadow:0 4px 12px rgba(0,58,112,.12); }
311
+ .logo { height:clamp(22px,3.2dvh,36px); filter:brightness(0) invert(1); opacity:.92; }
312
+ .ht { font-family:'Sora',sans-serif; font-size:clamp(12px,1.8dvh,20px);
313
+ color:#fff; font-weight:800; letter-spacing:-.5px; }
314
+ .hs { font-family:'DM Sans',sans-serif; font-size:clamp(9px,1.2dvh,14px);
315
+ color:rgba(255,255,255,.9); font-weight:700; }
316
+ .cards { display:grid; grid-template-columns:repeat(5,1fr); gap:3px; }
317
+ .mc { background:white; border-radius:8px;
318
+ padding:clamp(3px,.5dvh,7px) clamp(5px,.6vw,12px);
319
+ border-left:3px solid; box-shadow:0 2px 8px rgba(0,58,112,.06); overflow:hidden; }
320
+ .ml { font-family:'DM Sans',sans-serif; font-size:clamp(7px,.85dvh,11px);
321
+ color:#6b7280; font-weight:700; text-transform:uppercase;
322
+ letter-spacing:.4px; margin-bottom:2px; white-space:nowrap; }
323
+ .mv { font-family:'Sora',sans-serif; font-size:clamp(12px,1.9dvh,22px);
324
+ font-weight:800; line-height:1; letter-spacing:-.4px; white-space:nowrap; }
325
+ .row3 { display:grid; grid-template-columns:repeat(3,1fr); gap:3px; min-height:0; }
326
+ .row2 { display:grid; grid-template-columns:repeat(2,1fr); gap:3px; min-height:0; }
327
+ .cb { background:white; border-radius:8px; box-shadow:0 2px 8px rgba(0,58,112,.06);
328
+ overflow:visible; min-height:0; display:flex; flex-direction:column; }
329
+ .ct { background:white; padding:3px 6px 0; border-radius:8px 8px 0 0;
330
+ border-bottom:1px solid #f0f0f0; flex-shrink:0; }
331
+ .ct-title { font-family:'Sora',sans-serif; font-size:clamp(9px,1.1dvh,14px);
332
+ color:#1a1a1a; font-weight:800; text-align:center; }
333
+ .ct-sub { text-align:center; font-size:clamp(7px,.8dvh,10px);
334
+ font-weight:700; margin-top:1px; line-height:1.3; }
335
+ .cp { flex:1; min-height:0; overflow:visible; }
336
+ @media(max-width:900px){
337
+ html,body { overflow:hidden!important; }
338
+ .root { height:100dvh!important; overflow:hidden!important; }
339
+ .cards { grid-template-columns:repeat(3,1fr)!important; }
340
+ .row3 { grid-template-columns:repeat(2,1fr)!important; }
341
+ .row2 { grid-template-columns:1fr 1fr!important; }
342
+ .cb { min-height:0!important; }
343
+ .cp { min-height:0!important; }
344
+ }
345
+ @media(max-width:600px){
346
+ html,body { overflow-y:auto!important; height:auto!important; }
347
+ .root { height:auto!important; overflow:visible!important; grid-template-rows:auto!important; }
348
+ .cards { grid-template-columns:repeat(2,1fr)!important; }
349
+ .row3,.row2 { grid-template-columns:1fr!important; }
350
+ .cb { height:260px!important; }
351
+ .cp { height:210px!important; flex:none!important; }
352
+ .hs { display:none; }
353
+ .hdr { padding:4px 8px!important; }
354
+ }
355
  </style>
356
+ </head>
357
+ <body>
358
  <div class="root">
 
359
  <div class="hdr">
360
  <img class="logo" src="https://companieslogo.com/img/orig/AZ2.F-d26946db.png?t=1720244490" alt="AZ">
361
  <div style="text-align:center;flex:1">
 
364
  </div>
365
  <img class="logo" src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Logo-engie.svg" alt="Engie">
366
  </div>
 
367
  <div class="cards" id="cards-row"></div>
368
  <div class="row3" id="row1"></div>
369
  <div class="row2" id="row2"></div>
370
  <div class="row3" id="row3"></div>
 
371
  </div>
 
372
  <script>
373
+ var FRAMES = __FRAMES__;
374
+ var MESES = __MESES__;
375
+ var ANOS = __ANOS__;
376
+ var SUB_L1 = __SUB_L1__;
377
+ var SUB_L2 = __SUB_L2__;
378
+ var SUB_L3 = __SUB_L3__;
379
 
380
  var CARDS_CFG = [
381
+ {col:'qtd_descarregamentos', titulo:'Descarregamentos', cor:'#003a70', sufixo:''},
382
+ {col:'peso_recebido', titulo:'Peso Recebido', cor:'#005a9c', sufixo:' kg'},
383
+ {col:'qtd_volumes_recebidos',titulo:'Volumes Recebidos', cor:'#0075be', sufixo:''},
384
+ {col:'qtd_unidades_recebidos',titulo:'Unidades Recebidas',cor:'#4d9fd6', sufixo:''},
385
+ {col:'qtd_itens_por_unidade', titulo:'Itens por Unidade', cor:'#80b9e5', sufixo:''}
386
  ];
387
 
388
  var TITULOS_L1 = ['Descarregamentos','Peso Recebido','Volumes Recebidos'];
389
  var TITULOS_L2 = ['Unidades Recebidas','Itens por Unidade'];
390
  var TITULOS_L3 = ['Volumes Recebidos por Sistema','Unidades de Itens por Sistema','Itens por Unidade por Sistema'];
391
 
392
+ var FRAME_MS = 900;
393
+ var PAUSE_MS = 60000;
394
+ var frameIdx = 0;
395
+ var animTimer = null;
396
+ var cdownTimer= null;
397
+ var inited = {};
398
 
399
+ function buildDOM() {
400
  var cr = document.getElementById('cards-row');
401
+ CARDS_CFG.forEach(function(c) {
402
+ cr.innerHTML += '<div class="mc" style="border-left-color:'+c.cor+'">'
403
+ + '<div class="ml">'+c.titulo+'</div>'
404
+ + '<div class="mv" id="mv_'+c.col+'" style="color:'+c.cor+'">0'+c.sufixo+'</div></div>';
405
+ });
 
406
  var r1 = document.getElementById('row1');
407
+ for (var i=0; i<3; i++) {
408
  r1.innerHTML += '<div class="cb"><div class="ct">'
409
+ + '<div class="ct-title">'+TITULOS_L1[i]+'</div>'
410
+ + '<div class="ct-sub" id="sub_l1_'+i+'">'+SUB_L1[i]+'</div></div>'
411
+ + '<div class="cp" id="l1_'+i+'"></div></div>';
412
+ }
 
413
  var r2 = document.getElementById('row2');
414
+ for (var i=0; i<2; i++) {
415
  r2.innerHTML += '<div class="cb"><div class="ct">'
416
+ + '<div class="ct-title">'+TITULOS_L2[i]+'</div>'
417
+ + '<div class="ct-sub" id="sub_l2_'+i+'">'+SUB_L2[i]+'</div></div>'
418
+ + '<div class="cp" id="l2_'+i+'"></div></div>';
419
+ }
 
420
  var r3 = document.getElementById('row3');
421
+ for (var i=0; i<3; i++) {
422
  r3.innerHTML += '<div class="cb"><div class="ct">'
423
+ + '<div class="ct-title">'+TITULOS_L3[i]+'</div>'
424
+ + '<div class="ct-sub" id="sub_l3_'+i+'">'+SUB_L3[i]+'</div></div>'
425
+ + '<div class="cp" id="l3_'+i+'"></div></div>';
426
+ }
427
+ }
428
 
429
+ function gsize(id) {
430
  var el = document.getElementById(id);
431
+ if (!el) return [200,150];
432
  return [el.clientWidth||200, el.clientHeight||150];
433
+ }
434
+
435
+ var tickvals = Object.keys(MESES).map(Number);
436
+ var ticktext = Object.values(MESES);
 
 
 
 
 
 
 
437
 
438
+ function renderLine(traces, elId, sufixo) {
439
+ var wh = gsize(elId);
440
+ var data = [];
441
+ var n = traces.length;
442
  var allY = [];
443
+ traces.forEach(function(t){ t.y.forEach(function(v){ if(v>=0) allY.push(v); }); });
444
  var yMax = allY.length ? Math.max.apply(null,allY) : 1;
 
445
  var allX = [];
446
+ traces.forEach(function(t){ t.x.forEach(function(v){ allX.push(v); }); });
447
  var xMin = allX.length ? Math.min.apply(null,allX) : 1;
448
  var xMax2 = allX.length ? Math.max.apply(null,allX) : 12;
 
 
 
449
  var headroom = 1.40 + Math.max(0, n-1) * 0.08;
450
  var yTop = yMax * headroom;
 
 
451
  var MIN_OFFSET = yTop * 0.055;
452
 
453
+ traces.forEach(function(t, i) {
454
+ var rgba = t.fill_rgba;
455
+ data.push({
456
  type:'scatter', mode:'lines+markers',
457
  x:t.x, y:t.y,
458
  cliponaxis:false, showlegend:false, name:t.ano,
459
+ line:{width:2.5, color:t.cor, shape:'spline'},
460
+ marker:{size:6, color:t.cor, line:{width:1.5, color:'white'}},
461
  fill: i>0 ? 'tonexty' : null,
462
+ fillcolor: rgba,
463
+ hovertemplate:'<b>'+t.ano+'</b> %{customdata}: <b>%{text}</b>'+sufixo+'<extra></extra>',
464
+ customdata: t.x.map(function(m){ return MESES[m]; }),
465
  text: t.text
466
+ });
467
+ });
468
+
469
+ var byX = {};
470
+ traces.forEach(function(t) {
471
+ t.x.forEach(function(mx, j) {
472
+ if (!t.text[j]) return;
473
+ if (!byX[mx]) byX[mx] = [];
474
+ byX[mx].push({yv:t.y[j], txt:t.text[j], cor:t.cor});
475
+ });
476
+ });
 
477
 
478
  var annotations = [];
479
+ Object.keys(byX).forEach(function(mxStr) {
 
480
  var mx = parseInt(mxStr);
481
  var pts = byX[mx];
482
+ pts.sort(function(a,b){ return a.yv-b.yv; });
 
 
 
 
 
 
483
  var labelY = [];
484
+ pts.forEach(function(p, k) {
485
  var candidate = p.yv + MIN_OFFSET;
486
+ if (k > 0 && candidate < labelY[k-1] + MIN_OFFSET) candidate = labelY[k-1] + MIN_OFFSET;
 
 
487
  labelY.push(candidate);
488
+ });
489
+ var xanchor = mx===xMin ? 'left' : (mx===xMax2 ? 'right' : 'center');
490
+ pts.forEach(function(p, k) {
491
+ annotations.push({
492
+ x:mx, y:labelY[k], xref:'x', yref:'y',
493
+ text:p.txt, showarrow:false,
494
+ xanchor:xanchor, yanchor:'middle',
495
+ font:{size:9, family:'DM Sans', color:'#111827'},
496
+ bgcolor:'rgba(0,0,0,0)', borderpad:0
497
+ });
498
+ });
499
+ });
500
+
501
+ var layout = {
502
+ autosize:true,
 
 
 
 
 
 
 
503
  hovermode:'x unified', template:'plotly_white',
504
+ font:{family:'DM Sans', size:10}, showlegend:false,
505
+ margin:{l:42,r:18,t:24,b:36},
506
  plot_bgcolor:'rgba(248,249,250,0.5)',
507
+ xaxis:{
508
  tickmode:'array', tickvals:tickvals, ticktext:ticktext,
509
+ tickangle:0, tickfont:{size:9,family:'DM Sans',color:'#555'},
510
  showgrid:true, gridwidth:1, gridcolor:'rgba(200,200,200,0.25)',
511
  fixedrange:true, range:[xMin-0.4, xMax2+0.4]
512
+ },
513
+ yaxis:{
514
+ tickfont:{size:9,family:'DM Sans'},
515
  showgrid:true, gridwidth:1, gridcolor:'rgba(200,200,200,0.25)',
516
  range:[0, yTop], fixedrange:true,
517
+ tickformat: yMax>=100000 ? '.2s' : null
518
+ },
519
+ annotations:annotations,
520
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#ddd',namelength:-1}
521
+ };
522
+
523
  var el = document.getElementById(elId);
524
+ if (!inited[elId]) {
525
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
526
+ inited[elId] = true;
527
+ } else {
528
  Plotly.react(el, data, layout);
529
+ }
530
+ }
 
531
 
532
+ function renderPizza(item, elId) {
533
+ if (!item.valores.length) return;
534
+ var wh = gsize(elId);
535
+ var total = item.valores.reduce(function(s,v){ return s+v; }, 0);
536
+ var labels = item.labels.map(function(l,i) {
 
537
  var pct = total>0 ? (item.valores[i]/total*100).toFixed(1) : '0.0';
538
+ var vf = item.valores[i].toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g,'.');
539
  return l+': '+vf+' ('+pct+'%)';
540
+ });
541
+ var data = [{
542
  type:'pie', labels:labels, values:item.valores,
543
+ marker:{colors:item.cores, line:{color:'white',width:2}},
544
  hole:0.3, textposition:'inside',
545
+ hovertemplate:'<b>%{label}</b><br>Volumes: %{value:,.0f}<br>Percentual: %{percent}<extra></extra>'
546
+ }];
547
+ var layout = {
548
+ autosize:true,
549
+ template:'plotly_white', font:{family:'DM Sans',size:9}, showlegend:true,
550
+ legend:{orientation:'v',yanchor:'middle',y:0.5,xanchor:'left',x:1.02,font:{size:8.5}},
551
+ margin:{l:20,r:150,t:8,b:8},
552
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}
553
+ };
 
554
  var el = document.getElementById(elId);
555
+ if (!inited[elId]) {
556
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
557
+ inited[elId] = true;
558
+ } else {
559
  Plotly.react(el, data, layout);
560
+ }
561
+ }
562
+
563
+ function fmtDot(v) {
564
+ if (v===null||v===undefined) return '0';
565
+ var n = Math.round(v);
566
+ if (n===0) return '0';
567
+ var s=n.toString(), r='', c=0;
568
+ for (var i=s.length-1; i>=0; i--) {
569
+ if (c>0 && c%3===0) r='.'+r;
570
+ r=s[i]+r; c++;
571
+ }
572
+ return r;
573
+ }
574
 
575
+ function renderBarH(item, elId) {
576
+ if (!item.valores.length) return;
577
+ var wh = gsize(elId);
578
+ var maxVal = Math.max.apply(null, item.valores);
579
+ var pairs = item.labels.map(function(l,i){ return {l:l,v:item.valores[i],c:item.cores[i]}; });
580
+ pairs.sort(function(a,b){ return b.v-a.v; });
581
+ var sL = pairs.map(function(p){ return p.l; });
582
+ var sV = pairs.map(function(p){ return p.v; });
583
+ var sC = pairs.map(function(p){ return p.c; });
584
+
585
+ var trackColors = sC.map(function(c) {
586
+ var r=parseInt(c.slice(1,3),16), g=parseInt(c.slice(3,5),16), b=parseInt(c.slice(5,7),16);
587
+ return 'rgba('+r+','+g+','+b+',0.12)';
588
+ });
589
+
590
+ var trackData = {
591
  type:'bar', orientation:'h',
592
+ x:sV.map(function(){ return maxVal; }), y:sL,
593
+ marker:{color:trackColors, line:{width:0}},
 
 
 
594
  hoverinfo:'skip', showlegend:false,
595
+ text:sL.map(function(){ return ''; })
596
+ };
597
+ var barData = {
 
598
  type:'bar', orientation:'h',
599
  x:sV, y:sL,
600
+ marker:{color:sC, line:{width:0}},
601
+ hovertemplate:'<b>%{y}</b><br>%{x:,.0f}<extra></extra>',
602
  showlegend:false,
603
+ text:sL.map(function(){ return ''; })
604
+ };
605
 
606
  var threshold = maxVal * 0.20;
607
+ var annotations = sV.map(function(v,i) {
608
  var isShort = v < threshold;
609
+ return {
610
+ x: isShort ? v+maxVal*0.015 : v-maxVal*0.015,
611
  y: sL[i],
612
+ text:'<b>'+fmtDot(v)+'</b>',
613
+ xref:'x', yref:'y', showarrow:false,
 
614
  xanchor: isShort ? 'left' : 'right',
615
  yanchor:'middle',
616
+ font:{size:11, family:'DM Sans', color: isShort?'#003a70':'white'}
617
+ };
618
+ });
619
+
620
+ var layout = {
621
+ autosize:true,
622
+ barmode:'overlay', template:'plotly_white', showlegend:false,
623
+ font:{family:'DM Sans',size:9},
624
+ margin:{l:150,r:70,t:6,b:20},
625
+ plot_bgcolor:'rgba(0,0,0,0)', paper_bgcolor:'rgba(0,0,0,0)',
626
+ xaxis:{visible:false, range:[0,maxVal*1.20], fixedrange:true},
627
+ yaxis:{
628
+ tickfont:{size:10,family:'DM Sans',color:'#333'},
629
+ showgrid:false, showline:false, zeroline:false,
630
+ ticksuffix:' ', fixedrange:true, automargin:true
631
+ },
632
+ annotations:annotations,
633
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}
634
+ };
 
 
 
 
 
 
 
 
635
 
636
  var el = document.getElementById(elId);
637
+ if (!inited[elId]) {
638
+ Plotly.newPlot(el, [trackData, barData], layout, {displayModeBar:false, responsive:true});
639
+ inited[elId] = true;
640
+ } else {
641
  Plotly.react(el, [trackData, barData], layout);
642
+ }
643
+ roundBars(el, 8, 'h');
644
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
645
 
646
+ function roundBars(boxEl, r, orientation) {
647
+ setTimeout(function() {
648
+ boxEl.querySelectorAll('.bars path').forEach(function(p) {
649
+ var d = p.getAttribute('d');
650
+ if (!d) return;
651
+ var tok = d.match(/[A-Za-z][^A-Za-z]*/g) || [];
652
+ var cx=0, cy=0, aX=[], aY=[];
653
+ tok.forEach(function(t) {
654
+ var cmd = t[0];
655
+ var pts = t.slice(1).trim().split(/[\\s,]+/).map(Number).filter(function(n){ return !isNaN(n); });
656
+ if (cmd==='M'||cmd==='L') { aX.push(pts[0]); aY.push(pts[1]); cx=pts[0]; cy=pts[1]; }
657
+ else if (cmd==='H') { aX.push(pts[0]); aY.push(cy); cx=pts[0]; }
658
+ else if (cmd==='V') { aX.push(cx); aY.push(pts[0]); cy=pts[0]; }
659
+ });
660
+ if (aX.length < 2) return;
661
+ var x0=Math.min.apply(null,aX), x1=Math.max.apply(null,aX);
662
+ var y0=Math.min.apply(null,aY), y1=Math.max.apply(null,aY);
663
+ var w=x1-x0, h=y1-y0;
664
+ if (w<=0 || h<=0) return;
665
+ var rr = Math.min(r, w/2, h/2);
666
+ var nd;
667
+ if (orientation === 'h') {
668
+ nd = 'M '+x0+','+y0
669
+ + ' H '+(x1-rr)+' Q '+x1+','+y0+' '+x1+','+(y0+rr)
670
+ + ' V '+(y1-rr)+' Q '+x1+','+y1+' '+(x1-rr)+','+y1
671
+ + ' H '+x0+' V '+y0+' Z';
672
+ } else {
673
+ nd = 'M '+x0+','+y1
674
+ + ' H '+x1
675
+ + ' V '+(y0+rr)
676
+ + ' Q '+x1+','+y0+' '+(x1-rr)+','+y0
677
+ + ' H '+(x0+rr)+' Q '+x0+','+y0+' '+x0+','+(y0+rr)
678
+ + ' V '+y1+' Z';
679
+ }
680
+ p.setAttribute('d', nd);
681
+ });
682
+ }, 80);
683
+ }
684
 
685
+ function renderBarV(item, elId) {
686
+ if (!item.valores.length) return;
687
+ var el = document.getElementById(elId);
688
+ var wh = gsize(elId);
689
+ var maxVal = Math.max.apply(null, item.valores);
690
+ var texts = item.valores.map(function(v){ return v>0 ? fmtDot(v) : ''; });
691
+ var data = [{
692
  type:'bar',
693
  x:item.labels, y:item.valores,
694
+ text:texts, textposition:'outside', cliponaxis:false,
695
+ textfont:{size:10,family:'DM Sans',color:'#333'},
696
+ marker:{color:item.cores, line:{width:0}},
697
+ hovertemplate:'<b>%{x}</b><br>%{y:,.0f}<extra></extra>'
698
+ }];
699
+ var layout = {
700
+ autosize:true,
 
 
 
701
  template:'plotly_white', showlegend:false,
702
+ font:{family:'DM Sans',size:9},
703
+ margin:{l:30,r:20,t:40,b:90},
704
+ plot_bgcolor:'rgba(248,249,250,0.5)', paper_bgcolor:'white',
705
+ xaxis:{
706
+ tickfont:{size:8,family:'DM Sans'},
707
+ tickangle:-35, showgrid:false, fixedrange:true,
708
+ automargin:true, ticklabelstandoff:8
709
+ },
710
+ yaxis:{
711
+ tickfont:{size:9,family:'DM Sans'},
712
+ showgrid:true, gridwidth:1, gridcolor:'rgba(200,200,200,0.2)',
713
+ range:[0, maxVal*1.35], fixedrange:true
714
+ },
715
+ hoverlabel:{bgcolor:'white',font_size:11,font_family:'DM Sans',bordercolor:'#e0e0e0'}
716
+ };
 
 
 
 
 
 
 
 
 
 
717
  el.style.overflow = 'visible';
718
+ if (el.parentElement) el.parentElement.style.overflow = 'visible';
719
+ if (!inited[elId]) {
720
+ Plotly.newPlot(el, data, layout, {displayModeBar:false, responsive:true});
721
+ inited[elId] = true;
722
+ } else {
 
723
  Plotly.react(el, data, layout);
724
+ }
725
  roundBars(el, 8, 'v');
726
+ }
727
+
728
+ function renderFrame(f) {
729
+ CARDS_CFG.forEach(function(c) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
730
  var cd = f.cards[c.col];
731
+ if (cd) document.getElementById('mv_'+c.col).textContent = cd.valor + cd.sufixo;
732
+ });
733
+ f.linha1.forEach(function(item, i) { renderLine(item.traces, 'l1_'+i, item.sufixo); });
734
+ f.linha2.forEach(function(item, i) { renderLine(item.traces, 'l2_'+i, item.sufixo); });
735
+ f.linha3.forEach(function(item, i) {
736
+ if (item.tipo===0) renderPizza(item, 'l3_'+i);
737
+ else if (item.tipo===1) renderBarH(item, 'l3_'+i);
738
+ else if (item.tipo===2) renderBarV(item, 'l3_'+i);
739
+ });
740
+ }
741
+
742
+ function stepFrame() {
 
743
  renderFrame(FRAMES[frameIdx]);
744
  frameIdx++;
745
+ if (frameIdx >= FRAMES.length) {
746
  clearTimeout(animTimer);
747
+ cdownTimer = setTimeout(function(){ frameIdx=0; startAnimation(); }, PAUSE_MS);
748
+ } else {
 
 
 
749
  animTimer = setTimeout(stepFrame, FRAME_MS);
750
+ }
751
+ }
752
 
753
+ function startAnimation() {
754
  clearTimeout(animTimer);
755
  clearTimeout(cdownTimer);
756
  animTimer = setTimeout(stepFrame, FRAME_MS);
757
+ }
758
 
759
  var _rt;
760
+ window.addEventListener('resize', function() {
761
  clearTimeout(_rt);
762
+ _rt = setTimeout(function() {
763
+ document.querySelectorAll('.cp').forEach(function(box) {
764
+ if (box.data) Plotly.relayout(box, {width:box.clientWidth, height:box.clientHeight});
765
+ });
766
+ }, 200);
767
+ });
768
+
769
+ window.addEventListener('load', function() {
770
  buildDOM();
771
+ setTimeout(function(){ frameIdx=0; startAnimation(); }, 400);
772
+ });
773
  </script>
774
+ </body>
775
+ </html>"""
776
+
777
+ html = (html
778
+ .replace('__FRAMES__', frames_json)
779
+ .replace('__MESES__', meses_json)
780
+ .replace('__ANOS__', anos_json)
781
+ .replace('__SUB_L1__', sub_l1)
782
+ .replace('__SUB_L2__', sub_l2)
783
+ .replace('__SUB_L3__', sub_l3)
784
+ )
785
 
786
+ components.html(html, height=8000, scrolling=True)
787
 
788
  else:
789
+ components.html("""<!DOCTYPE html><html><body style="display:flex;align-items:center;
790
+ justify-content:center;height:100dvh;font-family:sans-serif;color:#003a70;background:#f5f7fa;">
791
+ <div style="text-align:center"><div style="font-size:2em">📊</div>
792
  <p>Aguardando dados... Recarregue a página.</p></div></body></html>""",
793
  height=4000, scrolling=False)
794