Spaces:
Sleeping
Sleeping
| # app.py | |
| # CIA – Central de Impressões APOGEU | |
| # Streamlit (Hugging Face Spaces) | |
| # | |
| # Versão "nível Apple" consolidada: | |
| # ✅ Sem sidebar: Tipo/Acesso/Solicitante/Unidade abaixo do cabeçalho | |
| # ✅ Impressão + Acabamentos (acabamentos abaixo; total = soma) | |
| # ✅ Quantidade de acabamentos = Tiragem (não pergunta quantidade) | |
| # ✅ Regra OFICIAL: 1x1 = 1x0 × 2 (base sempre por página) | |
| # ✅ Upload múltiplo obrigatório (1+ arquivos) — base64 só no clique/execução (evita rerun pesado) | |
| # ✅ Persistência de contato: Nome/E-mail não somem ao anexar arquivos | |
| # ✅ Segurança: não exibe links de arquivos após envio (e GAS deve estar sem links no e-mail) | |
| # ✅ Bloqueio REAL anti-reenvio: trava otimista + sending inflight (sem “segundo envio”) | |
| # ✅ Compatibilidade: envia tipo e tipo_pedido | |
| from __future__ import annotations | |
| import base64 | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import pandas as pd | |
| import requests | |
| import streamlit as st | |
| # ========================= | |
| # Config / Assets | |
| # ========================= | |
| BASE_DIR = Path(__file__).resolve().parent | |
| HEADER_IMG = BASE_DIR / "CIA - Central de Impressões APOGEU.png" | |
| EXCEL_BASE = BASE_DIR / "vale-o-que-custa-MODELO.xlsx" | |
| UNIDADES = [ | |
| "Global School Cidade Alta", | |
| "Global School Ferreira Guimarães", | |
| "Santo Antônio 1", | |
| "Santo Antônio 2", | |
| "Zona Norte", | |
| "Central", | |
| ] | |
| SOLICITANTES = ["Unidade", "Central"] | |
| TIPOS = [ | |
| "Material Didático", | |
| "Avaliação/Cartão Resposta", | |
| "Material Extra", | |
| "Carta Dourada", | |
| "Certificado A+", | |
| "Certificado Olímpico", | |
| ] | |
| # ========================= | |
| # Helpers | |
| # ========================= | |
| def get_webhook_url() -> Optional[str]: | |
| try: | |
| return st.secrets.get("WEBHOOK_URL", None) | |
| except Exception: | |
| return None | |
| def money_br(v: float) -> str: | |
| return f"R$ {v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") | |
| def split_multi(value: Any) -> list[str]: | |
| if value is None: | |
| return [] | |
| s = str(value).strip() | |
| if not s: | |
| return [] | |
| return [p.strip() for p in s.split(";") if p.strip()] | |
| def verso_label(code: str) -> str: | |
| c = (code or "").strip().lower() | |
| if c == "1x0": | |
| return "1 face (1x0)" | |
| if c == "1x1": | |
| return "Frente e verso (1x1)" | |
| return code | |
| def render_header_img(path: Path) -> None: | |
| """Header definitivo sem corte usando HTML <img> base64 e object-fit: contain.""" | |
| if not path.exists(): | |
| st.warning( | |
| f"Imagem de cabeçalho não encontrada: **{path.name}**.\n\n" | |
| "Confira se o arquivo está na raiz do repositório e com o nome EXATO." | |
| ) | |
| return | |
| b = path.read_bytes() | |
| b64 = base64.b64encode(b).decode("utf-8") | |
| st.markdown( | |
| f""" | |
| <div style=" | |
| width:100%; | |
| border-radius:22px; | |
| overflow:hidden; | |
| border:1px solid rgba(0,0,0,0.08); | |
| background: rgba(255,255,255,0.70); | |
| "> | |
| <img | |
| src="data:image/png;base64,{b64}" | |
| style="width:100%; height:auto; display:block; object-fit:contain;" | |
| /> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| def uploads_to_payload(uploads) -> List[Dict[str, Any]]: | |
| """ | |
| Converte UploadedFiles em lista serializável. | |
| IMPORTANTE: chamar somente na execução do envio (evita rerun pesado). | |
| """ | |
| out: List[Dict[str, Any]] = [] | |
| for up in uploads or []: | |
| raw = up.getvalue() | |
| out.append( | |
| { | |
| "filename": up.name, | |
| "mimetype": up.type or "application/octet-stream", | |
| "content_base64": base64.b64encode(raw).decode("utf-8"), | |
| "size_bytes": len(raw), | |
| } | |
| ) | |
| return out | |
| def load_excel_tables(xlsx_path: Path) -> Tuple[pd.DataFrame, pd.DataFrame]: | |
| if not xlsx_path.exists(): | |
| return pd.DataFrame(), pd.DataFrame() | |
| df_imp = pd.read_excel(xlsx_path, sheet_name="impressao") | |
| df_acab = pd.read_excel(xlsx_path, sheet_name="acabamentos") | |
| df_imp.columns = [str(c).strip() for c in df_imp.columns] | |
| df_acab.columns = [str(c).strip() for c in df_acab.columns] | |
| if "ativo" in df_imp.columns: | |
| df_imp = df_imp[df_imp["ativo"].astype(bool)] | |
| if "ativo" in df_acab.columns: | |
| df_acab = df_acab[df_acab["ativo"].astype(bool)] | |
| return df_imp, df_acab | |
| def filter_by_solicitante(df: pd.DataFrame, solicitante_tipo: str) -> pd.DataFrame: | |
| if df.empty: | |
| return df | |
| if "Solicitante" not in df.columns: | |
| return df | |
| return df[df["Solicitante"].astype(str).str.strip().str.lower() == solicitante_tipo.strip().lower()] | |
| def post_to_webhook( | |
| payload: Dict[str, Any], webhook_url: str, timeout: int = 240 | |
| ) -> Tuple[bool, str, int, str, Optional[Dict[str, Any]]]: | |
| """ | |
| Regra: Só OK se JSON tiver status="ok". | |
| """ | |
| try: | |
| r = requests.post(webhook_url, json=payload, timeout=timeout) | |
| raw = r.text or "" | |
| parsed: Optional[Dict[str, Any]] = None | |
| try: | |
| parsed = r.json() | |
| except Exception: | |
| parsed = None | |
| if not (200 <= r.status_code < 300): | |
| return False, f"Falha HTTP {r.status_code}", r.status_code, raw[:2000], parsed | |
| if not isinstance(parsed, dict) or "status" not in parsed: | |
| return False, "Servidor não retornou JSON válido com status.", r.status_code, raw[:2000], parsed | |
| if str(parsed.get("status")).lower() != "ok": | |
| return False, f"Servidor respondeu erro: {parsed.get('message','(sem mensagem)')}", r.status_code, raw[:2000], parsed | |
| return True, "Pedido confirmado pelo servidor.", r.status_code, raw[:2000], parsed | |
| except Exception as e: | |
| return False, f"Erro ao enviar: {e}", 0, "", None | |
| def build_resumo_pedido(sim_imp: Dict[str, Any], acab_list: List[Dict[str, Any]], tipo: str) -> str: | |
| verso = sim_imp.get("verso", "") | |
| tiragem = int(sim_imp.get("tiragem", 0) or 0) | |
| paginas_por_copia = int(sim_imp.get("paginas_por_copia", 0) or 0) | |
| paginas_totais = int(sim_imp.get("paginas_totais", 0) or 0) | |
| base = f"Tipo: **{tipo}**\n\n{tiragem} cópias • {paginas_por_copia} página(s) por cópia • total {paginas_totais} página(s)" | |
| imp = ( | |
| f"Impressão: **{sim_imp.get('tipo_papel','')}** • **{sim_imp.get('formato','')}** • " | |
| f"**{sim_imp.get('cor','')}** • verso **{verso}**" | |
| ) | |
| if acab_list: | |
| acabs = ", ".join([a.get("acabamento", "") for a in acab_list]) | |
| acab_txt = f"Acabamentos: **{acabs}** (quantidade = tiragem)" | |
| else: | |
| acab_txt = "Acabamentos: **nenhum**" | |
| return f"{base}\n\n{imp}\n\n{acab_txt}" | |
| # ========================= | |
| # Premium UI | |
| # ========================= | |
| st.set_page_config(page_title="CIA - Central de Impressões APOGEU", layout="wide") | |
| st.markdown( | |
| """ | |
| <style> | |
| .block-container { padding-top: 1.15rem; padding-bottom: 2.2rem; max-width: 1120px; } | |
| .ap-card { | |
| border: 1px solid rgba(0,0,0,0.08); | |
| border-radius: 18px; | |
| padding: 16px 16px 12px 16px; | |
| background: rgba(255,255,255,0.90); | |
| box-shadow: 0 14px 34px rgba(0,0,0,0.06); | |
| } | |
| .ap-title { font-size: 1.05rem; font-weight: 820; margin: 0 0 8px 0; letter-spacing: 0.1px; } | |
| .ap-badge { | |
| display: inline-block; padding: 6px 12px; border-radius: 999px; | |
| font-size: 0.85rem; border: 1px solid rgba(0,0,0,0.10); | |
| background: rgba(0,0,0,0.03); | |
| } | |
| .ap-resumo { | |
| border-radius: 18px; border: 1px solid rgba(0,0,0,0.08); | |
| background: linear-gradient(180deg, rgba(0,0,0,0.02), rgba(0,0,0,0.01)); | |
| padding: 16px; | |
| } | |
| .ap-hero { | |
| border-radius: 22px; | |
| padding: 16px; | |
| border: 1px solid rgba(0,0,0,0.08); | |
| background: linear-gradient(180deg, rgba(0,0,0,0.02), rgba(0,0,0,0.00)); | |
| } | |
| .ap-code { | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; | |
| font-weight: 900; | |
| padding: 8px 12px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(0,0,0,0.10); | |
| background: rgba(0,0,0,0.03); | |
| display: inline-block; | |
| } | |
| .stButton > button { border-radius: 14px !important; padding: 0.86rem 1rem !important; font-weight: 820 !important; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ========================= | |
| # Estado | |
| # ========================= | |
| st.session_state.setdefault("step", 1) | |
| st.session_state.setdefault("perfil", "") | |
| st.session_state.setdefault("solicitante_tipo", SOLICITANTES[0]) | |
| st.session_state.setdefault("unidade", UNIDADES[0]) | |
| st.session_state.setdefault("tipo_categoria", TIPOS[0]) | |
| # seleção dinâmica impressão | |
| st.session_state.setdefault("tipo_papel", "") | |
| st.session_state.setdefault("formato", "") | |
| st.session_state.setdefault("cor", "") | |
| st.session_state.setdefault("verso_ui", "") | |
| # volumes | |
| st.session_state.setdefault("paginas_por_copia", 1) | |
| st.session_state.setdefault("tiragem", 1) | |
| # acabamentos | |
| st.session_state.setdefault("acabamentos_sel", []) | |
| # resultados | |
| st.session_state.setdefault("sim_imp", {}) | |
| st.session_state.setdefault("sim_acab", []) | |
| st.session_state.setdefault("tot_impressao", 0.0) | |
| st.session_state.setdefault("tot_acabamentos", 0.0) | |
| st.session_state.setdefault("tot_geral", 0.0) | |
| # solicitante (inputs) | |
| st.session_state.setdefault("solicitante_nome", "") | |
| st.session_state.setdefault("solicitante_email", "") | |
| st.session_state.setdefault("observacoes", "") | |
| # solicitante (salvos para etapa 3) | |
| st.session_state.setdefault("solicitante_nome_saved", "") | |
| st.session_state.setdefault("solicitante_email_saved", "") | |
| st.session_state.setdefault("observacoes_saved", "") | |
| # retorno envio | |
| st.session_state.setdefault("last_send_debug", None) | |
| st.session_state.setdefault("codigo_pedido", "") | |
| st.session_state.setdefault("envio_ok", False) | |
| # bloqueio real anti-reenvio + anti-duplo clique | |
| st.session_state.setdefault("sending", False) # envio em andamento | |
| st.session_state.setdefault("sent_lock", False) # pedido já registrado | |
| st.session_state.setdefault("sent_codigo", "") | |
| # ========================= | |
| # Topo | |
| # ========================= | |
| render_header_img(HEADER_IMG) | |
| st.markdown("## CIA – Central de Impressões APOGEU") | |
| st.caption("Simule custos, valide e envie solicitações com anexos e confirmação por e-mail.") | |
| df_imp, df_acab = load_excel_tables(EXCEL_BASE) | |
| # Stepper | |
| progress = {1: 0.33, 2: 0.66, 3: 1.0}.get(int(st.session_state.step), 0.33) | |
| st.progress(progress) | |
| st.markdown(f'<span class="ap-badge">Etapa {st.session_state.step} de 3</span>', unsafe_allow_html=True) | |
| # Bloco inicial | |
| st.markdown("---") | |
| c0, c1, c2, c3 = st.columns([1.25, 1, 1, 1]) | |
| with c0: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Tipo</div>', unsafe_allow_html=True) | |
| st.selectbox("Selecione o tipo", TIPOS, key="tipo_categoria") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c1: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Acesso</div>', unsafe_allow_html=True) | |
| st.selectbox( | |
| "Quem é você?", | |
| ["", "Coordenação", "Secretaria", "Professores", "Administrativo", "Direção"], | |
| key="perfil", | |
| ) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Solicitante</div>', unsafe_allow_html=True) | |
| st.selectbox("Selecione o solicitante", SOLICITANTES, key="solicitante_tipo") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Unidade</div>', unsafe_allow_html=True) | |
| st.selectbox("Selecione a unidade", UNIDADES, key="unidade") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| perfil_ok = bool(st.session_state.perfil.strip()) | |
| solicitante_tipo = st.session_state.solicitante_tipo | |
| df_imp_f = filter_by_solicitante(df_imp, solicitante_tipo) | |
| df_acab_f = filter_by_solicitante(df_acab, solicitante_tipo) | |
| # callbacks instantâneos | |
| def on_change_tipo_papel(): | |
| st.session_state.formato = "" | |
| st.session_state.cor = "" | |
| st.session_state.verso_ui = "" | |
| def on_change_formato(): | |
| st.session_state.cor = "" | |
| st.session_state.verso_ui = "" | |
| def on_change_cor(): | |
| st.session_state.verso_ui = "" | |
| # ========================= | |
| # ETAPA 1 — Simulação | |
| # ========================= | |
| if st.session_state.step == 1: | |
| st.markdown("---") | |
| st.markdown("### 1) Simulação") | |
| if not perfil_ok: | |
| st.info("Selecione **Quem é você?** para liberar a simulação.") | |
| st.stop() | |
| if df_imp_f.empty: | |
| st.error("Não há opções de impressão disponíveis para este solicitante.") | |
| st.stop() | |
| tipo_papel_opts = sorted(df_imp_f["tipo_papel"].dropna().astype(str).unique().tolist()) | |
| if st.session_state.tipo_papel not in tipo_papel_opts: | |
| st.session_state.tipo_papel = tipo_papel_opts[0] if tipo_papel_opts else "" | |
| base_papel = df_imp_f[df_imp_f["tipo_papel"].astype(str) == str(st.session_state.tipo_papel)] | |
| formato_opts = sorted(base_papel["formato"].dropna().astype(str).unique().tolist()) | |
| if st.session_state.formato not in formato_opts: | |
| st.session_state.formato = formato_opts[0] if formato_opts else "" | |
| base_formato = base_papel[base_papel["formato"].astype(str) == str(st.session_state.formato)] | |
| cor_opts = sorted(base_formato["cor"].dropna().astype(str).unique().tolist()) | |
| if st.session_state.cor not in cor_opts: | |
| st.session_state.cor = cor_opts[0] if cor_opts else "" | |
| base_cor = base_formato[base_formato["cor"].astype(str) == str(st.session_state.cor)] | |
| verso_raw_opts: List[str] = [] | |
| for v in base_cor["verso"].dropna().tolist(): | |
| verso_raw_opts.extend(split_multi(v)) | |
| verso_raw_opts = sorted(list(dict.fromkeys(verso_raw_opts))) or ["1x0"] | |
| verso_map = {verso_label(v): v for v in verso_raw_opts} | |
| verso_ui_opts = list(verso_map.keys()) | |
| if st.session_state.verso_ui not in verso_ui_opts: | |
| st.session_state.verso_ui = verso_ui_opts[0] if verso_ui_opts else "" | |
| st.markdown('<div class="ap-hero">', unsafe_allow_html=True) | |
| topA, topB = st.columns([2, 1]) | |
| with topA: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Impressão</div>', unsafe_allow_html=True) | |
| s1, s2, s3 = st.columns(3) | |
| with s1: | |
| st.selectbox("Tipo / Papel", tipo_papel_opts, key="tipo_papel", on_change=on_change_tipo_papel) | |
| st.selectbox("Formato", formato_opts, key="formato", on_change=on_change_formato) | |
| with s2: | |
| st.selectbox("Cor", cor_opts, key="cor", on_change=on_change_cor) | |
| st.selectbox("Verso", verso_ui_opts, key="verso_ui") | |
| with s3: | |
| st.number_input("Páginas (por cópia/conjunto)", min_value=1, step=1, key="paginas_por_copia") | |
| st.number_input("Tiragem (quantidade de cópias)", min_value=1, step=1, key="tiragem") | |
| st.caption("Regra oficial: **1x1 = 1x0 × 2** (base por página).") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with topB: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Acabamentos (opcional)</div>', unsafe_allow_html=True) | |
| if df_acab_f.empty: | |
| st.info("Sem acabamentos disponíveis para este solicitante.") | |
| st.session_state.acabamentos_sel = [] | |
| else: | |
| acabamento_opts = sorted(df_acab_f["acabamento"].dropna().astype(str).unique().tolist()) | |
| st.multiselect( | |
| "Selecione os acabamentos", | |
| options=acabamento_opts, | |
| default=st.session_state.acabamentos_sel, | |
| key="acabamentos_sel", | |
| help="Quantidade de cada acabamento = tiragem.", | |
| ) | |
| st.caption("Cálculo: preço unitário × tiragem (para cada item).") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("---") | |
| if st.button("Calcular", type="primary", use_container_width=True): | |
| verso_code = verso_map.get(st.session_state.verso_ui, st.session_state.verso_ui) | |
| pick = df_imp_f[ | |
| (df_imp_f["tipo_papel"].astype(str) == str(st.session_state.tipo_papel)) | |
| & (df_imp_f["formato"].astype(str) == str(st.session_state.formato)) | |
| & (df_imp_f["cor"].astype(str) == str(st.session_state.cor)) | |
| ].copy() | |
| def verso_contains(cell: Any) -> bool: | |
| return verso_code in split_multi(cell) | |
| pick = pick[pick["verso"].apply(verso_contains)] | |
| if pick.empty: | |
| st.error("Não encontrei um preço no Excel para a combinação selecionada.") | |
| else: | |
| unit_base = float(pick.iloc[0]["preco_unit"]) | |
| paginas_totais = int(st.session_state.paginas_por_copia) * int(st.session_state.tiragem) | |
| multiplicador = 2.0 if (verso_code or "").strip().lower() == "1x1" else 1.0 | |
| unit_aplicado = unit_base * multiplicador | |
| tot_imp = paginas_totais * unit_aplicado | |
| st.session_state.sim_imp = { | |
| "tipo_papel": str(st.session_state.tipo_papel), | |
| "formato": str(st.session_state.formato), | |
| "cor": str(st.session_state.cor), | |
| "verso": str(verso_code), | |
| "preco_unit_base": float(unit_base), | |
| "multiplicador_verso": float(multiplicador), | |
| "preco_unit_aplicado": float(unit_aplicado), | |
| "paginas_por_copia": int(st.session_state.paginas_por_copia), | |
| "tiragem": int(st.session_state.tiragem), | |
| "paginas_totais": int(paginas_totais), | |
| "base_cobranca": "pagina", | |
| "total_impressao": float(tot_imp), | |
| } | |
| itens_acab: List[Dict[str, Any]] = [] | |
| tot_acab = 0.0 | |
| if not df_acab_f.empty and st.session_state.acabamentos_sel: | |
| for nome_acab in st.session_state.acabamentos_sel: | |
| pick_a = df_acab_f[df_acab_f["acabamento"].astype(str) == str(nome_acab)] | |
| if pick_a.empty: | |
| continue | |
| unit_a = float(pick_a.iloc[0]["preco_unit"]) | |
| subtotal = unit_a * int(st.session_state.tiragem) | |
| itens_acab.append( | |
| { | |
| "acabamento": str(nome_acab), | |
| "preco_unit": float(unit_a), | |
| "quantidade": int(st.session_state.tiragem), | |
| "subtotal": float(subtotal), | |
| } | |
| ) | |
| tot_acab += subtotal | |
| st.session_state.sim_acab = itens_acab | |
| st.session_state.tot_impressao = float(tot_imp) | |
| st.session_state.tot_acabamentos = float(tot_acab) | |
| st.session_state.tot_geral = float(tot_imp + tot_acab) | |
| # nova simulação = novo pedido (reseta envio/travas) | |
| st.session_state.envio_ok = False | |
| st.session_state.codigo_pedido = "" | |
| st.session_state.last_send_debug = None | |
| st.session_state.sending = False | |
| st.session_state.sent_lock = False | |
| st.session_state.sent_codigo = "" | |
| if st.session_state.sim_imp: | |
| st.markdown("---") | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("Total Impressão", money_br(st.session_state.tot_impressao)) | |
| c2.metric("Total Acabamentos", money_br(st.session_state.tot_acabamentos)) | |
| c3.metric("Total Geral", money_br(st.session_state.tot_geral)) | |
| st.markdown('<div class="ap-resumo">', unsafe_allow_html=True) | |
| st.markdown("#### Resumo do pedido") | |
| st.write(build_resumo_pedido(st.session_state.sim_imp, st.session_state.sim_acab, st.session_state.tipo_categoria)) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("---") | |
| if st.button("Continuar", type="primary", use_container_width=True): | |
| st.session_state.step = 2 | |
| st.rerun() | |
| # ========================= | |
| # ETAPA 2 — Dados do solicitante | |
| # ========================= | |
| if st.session_state.step == 2: | |
| st.markdown("---") | |
| st.markdown("### 2) Dados do solicitante") | |
| if not st.session_state.sim_imp: | |
| st.warning("Você precisa concluir a simulação primeiro.") | |
| if st.button("Voltar para Simulação"): | |
| st.session_state.step = 1 | |
| st.rerun() | |
| st.stop() | |
| left, right = st.columns([2, 1]) | |
| with left: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Contato</div>', unsafe_allow_html=True) | |
| st.text_input("Nome completo", key="solicitante_nome") | |
| st.text_input("E-mail", key="solicitante_email") | |
| st.text_area("Observações (opcional)", key="observacoes", height=110) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with right: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Resumo</div>', unsafe_allow_html=True) | |
| st.write(build_resumo_pedido(st.session_state.sim_imp, st.session_state.sim_acab, st.session_state.tipo_categoria)) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("---") | |
| b1, b2 = st.columns(2) | |
| with b1: | |
| if st.button("Voltar", use_container_width=True): | |
| st.session_state.step = 1 | |
| st.rerun() | |
| with b2: | |
| can_next = bool(st.session_state.solicitante_nome.strip()) and ("@" in st.session_state.solicitante_email.strip()) | |
| if st.button("Ir para envio", type="primary", use_container_width=True, disabled=not can_next): | |
| st.session_state.solicitante_nome_saved = st.session_state.solicitante_nome.strip() | |
| st.session_state.solicitante_email_saved = st.session_state.solicitante_email.strip() | |
| st.session_state.observacoes_saved = st.session_state.observacoes.strip() | |
| st.session_state.step = 3 | |
| st.rerun() | |
| # ========================= | |
| # ETAPA 3 — Envio | |
| # ========================= | |
| if st.session_state.step == 3: | |
| st.markdown("---") | |
| st.markdown("### 3) Enviar solicitação") | |
| webhook_url = get_webhook_url() | |
| if not webhook_url: | |
| st.warning( | |
| "Segredo **WEBHOOK_URL** não configurado.\n\n" | |
| "No Hugging Face: Settings → Secrets → New secret\n" | |
| "- Name: WEBHOOK_URL\n" | |
| "- Value: URL /exec do Apps Script\n" | |
| ) | |
| # fallback se chegou aqui sem congelar | |
| if not st.session_state.solicitante_nome_saved.strip() and st.session_state.solicitante_nome.strip(): | |
| st.session_state.solicitante_nome_saved = st.session_state.solicitante_nome.strip() | |
| if not st.session_state.solicitante_email_saved.strip() and st.session_state.solicitante_email.strip(): | |
| st.session_state.solicitante_email_saved = st.session_state.solicitante_email.strip() | |
| if not st.session_state.observacoes_saved.strip() and st.session_state.observacoes.strip(): | |
| st.session_state.observacoes_saved = st.session_state.observacoes.strip() | |
| left, right = st.columns([2, 1]) | |
| with left: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Anexos (obrigatório)</div>', unsafe_allow_html=True) | |
| uploads = st.file_uploader( | |
| "Envie um ou mais arquivos do pedido", | |
| type=None, | |
| accept_multiple_files=True, | |
| help="Obrigatório enviar pelo menos 1 arquivo.", | |
| ) | |
| if uploads and len(uploads) > 0: | |
| st.success(f"{len(uploads)} arquivo(s) pronto(s) para envio.") | |
| st.write("**Arquivos selecionados:**") | |
| for u in uploads: | |
| st.write(f"• {u.name}") | |
| else: | |
| st.info("Anexe ao menos 1 arquivo para habilitar o envio.") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown('<div class="ap-card" style="margin-top:14px;">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Revisão</div>', unsafe_allow_html=True) | |
| st.metric("Total Geral", money_br(st.session_state.tot_geral)) | |
| st.write(build_resumo_pedido(st.session_state.sim_imp, st.session_state.sim_acab, st.session_state.tipo_categoria)) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with right: | |
| st.markdown('<div class="ap-card">', unsafe_allow_html=True) | |
| st.markdown('<div class="ap-title">Identificação</div>', unsafe_allow_html=True) | |
| st.write(f"**Tipo:** {st.session_state.tipo_categoria}") | |
| st.write(f"**Perfil:** {st.session_state.perfil}") | |
| st.write(f"**Solicitante:** {st.session_state.solicitante_tipo}") | |
| st.write(f"**Unidade:** {st.session_state.unidade}") | |
| st.write(f"**Nome:** {st.session_state.solicitante_nome_saved}") | |
| st.write(f"**E-mail:** {st.session_state.solicitante_email_saved}") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.markdown("---") | |
| # Botões de navegação (voltar desabilitado após sucesso) | |
| if st.button("Voltar", use_container_width=True, disabled=st.session_state.sent_lock or st.session_state.sending): | |
| st.session_state.step = 2 | |
| st.rerun() | |
| if st.session_state.sent_lock: | |
| st.info( | |
| f"Pedido já registrado: **{st.session_state.sent_codigo}**. " | |
| "Para enviar outro pedido, clique em **Nova solicitação**." | |
| ) | |
| # Pode enviar? | |
| can_send = ( | |
| bool(webhook_url) | |
| and bool(uploads and len(uploads) > 0) | |
| and bool(st.session_state.sim_imp) | |
| and bool(st.session_state.solicitante_nome_saved.strip()) | |
| and ("@" in st.session_state.solicitante_email_saved.strip()) | |
| and (not st.session_state.sent_lock) | |
| and (not st.session_state.sending) | |
| ) | |
| send = st.button( | |
| "Enviar agora", | |
| type="primary", | |
| use_container_width=True, | |
| disabled=not can_send, | |
| ) | |
| # TRAVA OTIMISTA imediata: mata o botão antes de qualquer POST/base64 | |
| if send: | |
| st.session_state.sending = True | |
| st.session_state.sent_lock = True | |
| st.session_state.sent_codigo = "Processando…" | |
| st.rerun() | |
| # EXECUÇÃO DO ENVIO (após rerun, com sending=True) | |
| if st.session_state.sending: | |
| # Revalida pré-condições (segurança) | |
| pre_ok = ( | |
| bool(webhook_url) | |
| and bool(uploads and len(uploads) > 0) | |
| and bool(st.session_state.sim_imp) | |
| and bool(st.session_state.solicitante_nome_saved.strip()) | |
| and ("@" in st.session_state.solicitante_email_saved.strip()) | |
| ) | |
| if not pre_ok: | |
| st.session_state.sending = False | |
| st.session_state.sent_lock = False | |
| st.session_state.sent_codigo = "" | |
| st.warning("Faltam dados para envio. Verifique anexos e contato.") | |
| st.stop() | |
| with st.spinner("Enviando… registrando na planilha e salvando anexos…"): | |
| arquivos_payload = uploads_to_payload(uploads) | |
| payload = { | |
| "timestamp": datetime.now().isoformat(timespec="seconds"), | |
| "perfil": st.session_state.perfil, | |
| # compatibilidade (novo + antigo) | |
| "tipo": st.session_state.tipo_categoria, | |
| "tipo_pedido": st.session_state.tipo_categoria, | |
| "solicitante_tipo": st.session_state.solicitante_tipo, | |
| "unidade": st.session_state.unidade, | |
| "solicitante_nome": st.session_state.solicitante_nome_saved.strip(), | |
| "solicitante_email": st.session_state.solicitante_email_saved.strip(), | |
| "observacoes": st.session_state.observacoes_saved.strip(), | |
| "pedido": { | |
| "impressao": st.session_state.sim_imp, | |
| "acabamentos": st.session_state.sim_acab, | |
| "totais": { | |
| "impressao": float(st.session_state.tot_impressao), | |
| "acabamentos": float(st.session_state.tot_acabamentos), | |
| "geral": float(st.session_state.tot_geral), | |
| }, | |
| }, | |
| "arquivos": arquivos_payload, | |
| } | |
| ok, msg, code, raw, parsed = post_to_webhook(payload, webhook_url) | |
| st.session_state.last_send_debug = {"ok": ok, "msg": msg, "http_code": code, "raw": raw, "parsed": parsed} | |
| if ok: | |
| st.session_state.envio_ok = True | |
| st.session_state.codigo_pedido = str(parsed.get("codigo_pedido", "") or "").strip() | |
| st.session_state.sent_codigo = st.session_state.codigo_pedido or "Pedido confirmado" | |
| # mantém sent_lock = True | |
| else: | |
| st.session_state.envio_ok = False | |
| st.session_state.codigo_pedido = "" | |
| st.session_state.sent_lock = False | |
| st.session_state.sent_codigo = "" | |
| st.session_state.sending = False | |
| st.rerun() | |
| # Resultado pós-envio | |
| if st.session_state.envio_ok: | |
| st.markdown("---") | |
| st.success("Solicitação enviada e confirmada.") | |
| if st.session_state.codigo_pedido: | |
| st.markdown( | |
| f'**Código do pedido:** <span class="ap-code">{st.session_state.codigo_pedido}</span>', | |
| unsafe_allow_html=True, | |
| ) | |
| # Segurança: não exibir links. Se o GAS devolver files, só mostra nomes (opcional). | |
| parsed = (st.session_state.last_send_debug or {}).get("parsed") | |
| if isinstance(parsed, dict) and parsed.get("files"): | |
| with st.expander("Arquivos registrados", expanded=False): | |
| for f in parsed["files"]: | |
| st.write(f"• {f.get('name','arquivo')}") | |
| st.markdown("---") | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| if st.button("Nova solicitação", use_container_width=True): | |
| st.session_state.step = 1 | |
| # reseta simulação (mantém dados de acesso pra UX premium) | |
| st.session_state.sim_imp = {} | |
| st.session_state.sim_acab = [] | |
| st.session_state.tot_impressao = 0.0 | |
| st.session_state.tot_acabamentos = 0.0 | |
| st.session_state.tot_geral = 0.0 | |
| st.session_state.paginas_por_copia = 1 | |
| st.session_state.tiragem = 1 | |
| st.session_state.acabamentos_sel = [] | |
| st.session_state.envio_ok = False | |
| st.session_state.codigo_pedido = "" | |
| st.session_state.last_send_debug = None | |
| st.session_state.sending = False | |
| st.session_state.sent_lock = False | |
| st.session_state.sent_codigo = "" | |
| st.rerun() | |
| with col_b: | |
| if st.button("Limpar contato", use_container_width=True): | |
| st.session_state.solicitante_nome = "" | |
| st.session_state.solicitante_email = "" | |
| st.session_state.observacoes = "" | |
| st.session_state.solicitante_nome_saved = "" | |
| st.session_state.solicitante_email_saved = "" | |
| st.session_state.observacoes_saved = "" | |
| st.rerun() | |
| elif st.session_state.last_send_debug and not st.session_state.envio_ok: | |
| st.error("Não foi possível confirmar o envio com o servidor.") | |
| with st.expander("Retorno técnico", expanded=True): | |
| st.write(st.session_state.last_send_debug) | |