diff --git a/.gitignore b/.gitignore index 90f8fbb..daefeba 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ ipython_config.py # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml +.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -132,6 +133,9 @@ ENV/ env.bak/ venv.bak/ +# Folders +script/ + # Spyder project settings .spyderproject .spyproject diff --git a/.python-version b/.python-version index 7c7a975..dd6a220 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 \ No newline at end of file +3.12.4 \ No newline at end of file diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000..09cb8dc --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,7 @@ +[theme] +base="light" +primaryColor="#08b13c" +backgroundColor="#fafafaff" +secondaryBackgroundColor="#ffffff" +textColor="#262730" +font="sans serif" \ No newline at end of file diff --git a/.streamlit/secrets.toml b/.streamlit/secrets.toml new file mode 100644 index 0000000..7a1a1cf --- /dev/null +++ b/.streamlit/secrets.toml @@ -0,0 +1,16 @@ +[credentials] +usernames = [ + "jsmith", + "rbriggs", + "karine@cofrat.com.br" +] +passwords = [ + "senha123", + "abc@123", + "Geladeira123!" +] + +[supabase] +url = "https://mytbufiwnfsphvimvvvu.supabase.co" +service_key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im15dGJ1Zml3bmZzcGh2aW12dnZ1Iiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Nzc4NDQ2NywiZXhwIjoyMDczMzYwNDY3fQ.tbe31KF4eWkotTs0sx-pXcCGEVwLrxfvd5Wd9WtSW-Q" +access_key_id = '2709775cce628ea71bf4f8753cc1509a' \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4b5a294 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/Hello.py b/Hello.py deleted file mode 100644 index 08ed27b..0000000 --- a/Hello.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2018-2022 Streamlit Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import streamlit as st -from streamlit.logger import get_logger - -LOGGER = get_logger(__name__) - -def run(): - st.set_page_config( - page_title="Hello", - page_icon="👋", - ) - - st.write("# Welcome to Streamlit! 👋") - - st.sidebar.success("Select a demo above.") - - st.markdown( - """ - Streamlit is an open-source app framework built specifically for - Machine Learning and Data Science projects. - **👈 Select a demo from the sidebar** to see some examples - of what Streamlit can do! - ### Want to learn more? - - Check out [streamlit.io](https://streamlit.io) - - Jump into our [documentation](https://docs.streamlit.io) - - Ask a question in our [community - forums](https://discuss.streamlit.io) - ### See more complex demos - - Use a neural net to [analyze the Udacity Self-driving Car Image - Dataset](https://github.com/streamlit/demo-self-driving) - - Explore a [New York City rideshare dataset](https://github.com/streamlit/demo-uber-nyc-pickups) - """ - ) - - -if __name__ == "__main__": - run() \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b8c2819 --- /dev/null +++ b/config.yaml @@ -0,0 +1,10 @@ +credentials: + usernames: + jsmith: + email: jsmith@gmail.com + name: John Smith + password: '$2b$12$E9/VSm92ZHg3a1aX2f.2d.u523u2u.iP2.iP2.iP2.iP2.iP2' # Hash para senha123 + rbriggs: + email: rbriggs@gmail.com + name: Rebecca Briggs + password: '$2b$12$L5/Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf.Qf' # Hash para abc@123 \ No newline at end of file diff --git a/data/message_templates.csv b/data/message_templates.csv new file mode 100644 index 0000000..edc6162 --- /dev/null +++ b/data/message_templates.csv @@ -0,0 +1,8 @@ +area,message +Ortopedia,"Olá, {$primeiro_nome}! + +Sua consulta está agendada para o dia {$data}, às {$horario}, com {$profissional} (Ortopedista). +Caso não possa comparecer, solicitamos a gentileza de avisar com antecedência. + +Atenciosamente, +Equipe COFRAT." diff --git a/delete.json b/delete.json new file mode 100644 index 0000000..a5d2a4b --- /dev/null +++ b/delete.json @@ -0,0 +1,157 @@ +{ + "headers": { + "host": "webhook.erudieto.com.br", + "user-agent": "rest-client/2.1.0 (linux-musl x86_64) ruby/3.4.4p34", + "content-length": "5264", + "accept": "application/json", + "accept-encoding": "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", + "content-type": "application/json", + "x-forwarded-for": "161.35.227.157", + "x-forwarded-host": "webhook.erudieto.com.br", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-forwarded-server": "traefik_traefik.1", + "x-real-ip": "161.35.227.157" + }, + "params": {}, + "query": {}, + "body": { + "account": { + "id": 2, + "name": "Cofrat Atendimento" + }, + "additional_attributes": {}, + "content_attributes": {}, + "content_type": "text", + "content": "oi", + "conversation": { + "additional_attributes": {}, + "can_reply": true, + "channel": "Channel::Api", + "contact_inbox": { + "id": 17, + "contact_id": 9, + "inbox_id": 1, + "source_id": "f1a2daf2-5d0f-4068-be18-b640f49185b3", + "created_at": "2025-09-18T01:03:45.474Z", + "updated_at": "2025-09-18T01:03:45.474Z", + "hmac_verified": false, + "pubsub_token": "9yN8LhFgBC58GGeciKzGANHm" + }, + "id": 10, + "inbox_id": 1, + "messages": [ + { + "id": 458, + "content": "oi", + "account_id": 2, + "inbox_id": 1, + "conversation_id": 10, + "message_type": 0, + "created_at": 1758493861, + "updated_at": "2025-09-21T22:31:01.262Z", + "private": false, + "status": "sent", + "source_id": "WAID:3F6DCDFA3DECC78B0A7B", + "content_type": "text", + "content_attributes": {}, + "sender_type": "Contact", + "sender_id": 9, + "external_source_ids": {}, + "additional_attributes": {}, + "processed_message_content": "oi", + "sentiment": {}, + "conversation": { + "assignee_id": 2, + "unread_count": 1, + "last_activity_at": 1758493861, + "contact_inbox": { + "source_id": "f1a2daf2-5d0f-4068-be18-b640f49185b3" + } + }, + "sender": { + "additional_attributes": {}, + "custom_attributes": {}, + "email": null, + "id": 9, + "identifier": null, + "name": "Brandon A.", + "phone_number": "+5511959044561", + "thumbnail": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3db807a3307ea4546e92ec979251e6aaaaff1929/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--323aceeaa90542a5a906abdbeac3933c19539aa0/409452621_6687922161337028_8898827240265742964_n.jpg", + "blocked": false, + "type": "contact" + } + } + ], + "labels": [ + "agendamento_fluxo_por_convenio" + ], + "meta": { + "sender": { + "additional_attributes": {}, + "custom_attributes": {}, + "email": null, + "id": 9, + "identifier": null, + "name": "Brandon A.", + "phone_number": "+5511959044561", + "thumbnail": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3db807a3307ea4546e92ec979251e6aaaaff1929/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--323aceeaa90542a5a906abdbeac3933c19539aa0/409452621_6687922161337028_8898827240265742964_n.jpg", + "blocked": false, + "type": "contact" + }, + "assignee": { + "id": 2, + "name": "Ada Lovelace", + "available_name": "Julia", + "avatar_url": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--8dabf3ce7bf4300482c7b62172d6bf61d83df9c1/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--9650be2ccb8166f0f8e58ad2fc29c5e1bc2c8847/222Generated%20Image%20September%2007,%202025%20-%204_57PM.jpeg", + "type": "user", + "availability_status": null, + "thumbnail": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--8dabf3ce7bf4300482c7b62172d6bf61d83df9c1/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--9650be2ccb8166f0f8e58ad2fc29c5e1bc2c8847/222Generated%20Image%20September%2007,%202025%20-%204_57PM.jpeg" + }, + "team": null, + "hmac_verified": false + }, + "status": "open", + "custom_attributes": {}, + "snoozed_until": null, + "unread_count": 1, + "first_reply_created_at": "2025-09-18T01:04:09.639Z", + "priority": null, + "waiting_since": 1758493861, + "agent_last_seen_at": 1758250904, + "contact_last_seen_at": 1758250746, + "last_activity_at": 1758493861, + "timestamp": 1758493861, + "created_at": 1758157425, + "updated_at": 1758493861.316716 + }, + "created_at": "2025-09-21T22:31:01.262Z", + "id": 458, + "inbox": { + "id": 1, + "name": "Agendamento" + }, + "message_type": "incoming", + "private": false, + "sender": { + "account": { + "id": 2, + "name": "Cofrat Atendimento" + }, + "additional_attributes": {}, + "avatar": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3db807a3307ea4546e92ec979251e6aaaaff1929/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--323aceeaa90542a5a906abdbeac3933c19539aa0/409452621_6687922161337028_8898827240265742964_n.jpg", + "custom_attributes": {}, + "email": null, + "id": 9, + "identifier": null, + "name": "Brandon A.", + "phone_number": "+5511959044561", + "thumbnail": "https://dev-chatwoot.vldzc8.easypanel.host/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBHdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--3db807a3307ea4546e92ec979251e6aaaaff1929/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJYW5CbkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--323aceeaa90542a5a906abdbeac3933c19539aa0/409452621_6687922161337028_8898827240265742964_n.jpg", + "blocked": false + }, + "source_id": "WAID:3F6DCDFA3DECC78B0A7B", + "event": "message_created" + }, + "webhookUrl": "https://webhook.erudieto.com.br/webhook/chat", + "executionMode": "production" +} \ No newline at end of file diff --git a/images/cofrat-logo.png b/images/cofrat-logo.png new file mode 100644 index 0000000..ce15c2c Binary files /dev/null and b/images/cofrat-logo.png differ diff --git a/images/cofrat-logotipo.png b/images/cofrat-logotipo.png new file mode 100644 index 0000000..a135e39 Binary files /dev/null and b/images/cofrat-logotipo.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..484d9bf --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +# main.py +import streamlit as st +from utils import login_form, main_app +import os + +# --- CAMINHOS ABSOLUTOS (BOA PRÁTICA) --- +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +LOGO_SYMBOL_PATH = os.path.join(BASE_DIR, "images", "cofrat-logo.png") +LOGO_EXTENDED_PATH = os.path.join(BASE_DIR, "images", "cofrat-logotipo.png") +CSS_PATH = os.path.join(BASE_DIR, "style.css") + +# --- CONFIGURAÇÃO DA PÁGINA --- +st.set_page_config( + page_title="Plataforma de Gestão dos Atendimentos", + page_icon=LOGO_SYMBOL_PATH, + layout="wide", + initial_sidebar_state="expanded" +) + +# --- FUNÇÃO PARA CARREGAR CSS --- +def load_css(file_name): + """Carrega um arquivo CSS externo para dentro do app Streamlit.""" + try: + with open(file_name, encoding="utf-8") as f: + st.markdown(f"", unsafe_allow_html=True) + except FileNotFoundError: + st.warning(f"Arquivo CSS não encontrado em: {file_name}") + +# Carrega o nosso arquivo de estilos +load_css(CSS_PATH) + +# --- GATEKEEPER PRINCIPAL --- +# Inicializa o estado de autenticação se ele não existir +if "authentication_status" not in st.session_state: + st.session_state["authentication_status"] = False + +# Verifica o estado de autenticação para decidir o que mostrar +if not st.session_state["authentication_status"]: + # Se não estiver logado, mostra o formulário de login + login_form(logo_path=LOGO_EXTENDED_PATH) +else: + # Se estiver logado, renderiza o aplicativo principal + main_app(logo_path=LOGO_EXTENDED_PATH) \ No newline at end of file diff --git a/notebooks/transform.ipynb b/notebooks/transform.ipynb new file mode 100644 index 0000000..e8a129d --- /dev/null +++ b/notebooks/transform.ipynb @@ -0,0 +1,55 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "be241e8f", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "df = pd.read_clipboard()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d7949bc", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bed618e4", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "cofrat-app", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git "a/pages/1_\360\237\223\210_Plotting_Demo.py" "b/pages/1_\360\237\223\210_Plotting_Demo.py" deleted file mode 100644 index 9527171..0000000 --- "a/pages/1_\360\237\223\210_Plotting_Demo.py" +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2018-2022 Streamlit Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import streamlit as st -import inspect -import textwrap -import time -import numpy as np -from utils import show_code - -def plotting_demo(): - progress_bar = st.sidebar.progress(0) - status_text = st.sidebar.empty() - last_rows = np.random.randn(1, 1) - chart = st.line_chart(last_rows) - - for i in range(1, 101): - new_rows = last_rows[-1, :] + np.random.randn(5, 1).cumsum(axis=0) - status_text.text("%i%% Complete" % i) - chart.add_rows(new_rows) - progress_bar.progress(i) - last_rows = new_rows - time.sleep(0.05) - - progress_bar.empty() - - # Streamlit widgets automatically run the script from top to bottom. Since - # this button is not connected to any other logic, it just causes a plain - # rerun. - st.button("Re-run") - - -st.set_page_config(page_title="Plotting Demo", page_icon="📈") -st.markdown("# Plotting Demo") -st.sidebar.header("Plotting Demo") -st.write( - """This demo illustrates a combination of plotting and animation with -Streamlit. We're generating a bunch of random numbers in a loop for around -5 seconds. Enjoy!""" -) - -plotting_demo() - -show_code(plotting_demo) \ No newline at end of file diff --git "a/pages/2_\360\237\214\215_Mapping_Demo.py" "b/pages/2_\360\237\214\215_Mapping_Demo.py" deleted file mode 100644 index 7c111a4..0000000 --- "a/pages/2_\360\237\214\215_Mapping_Demo.py" +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright 2018-2022 Streamlit Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import streamlit as st -import inspect -import textwrap -import pandas as pd -import pydeck as pdk -from utils import show_code - -from urllib.error import URLError - -def mapping_demo(): - @st.cache_data - def from_data_file(filename): - url = ( - "http://raw.githubusercontent.com/streamlit/" - "example-data/master/hello/v1/%s" % filename - ) - return pd.read_json(url) - - try: - ALL_LAYERS = { - "Bike Rentals": pdk.Layer( - "HexagonLayer", - data=from_data_file("bike_rental_stats.json"), - get_position=["lon", "lat"], - radius=200, - elevation_scale=4, - elevation_range=[0, 1000], - extruded=True, - ), - "Bart Stop Exits": pdk.Layer( - "ScatterplotLayer", - data=from_data_file("bart_stop_stats.json"), - get_position=["lon", "lat"], - get_color=[200, 30, 0, 160], - get_radius="[exits]", - radius_scale=0.05, - ), - "Bart Stop Names": pdk.Layer( - "TextLayer", - data=from_data_file("bart_stop_stats.json"), - get_position=["lon", "lat"], - get_text="name", - get_color=[0, 0, 0, 200], - get_size=15, - get_alignment_baseline="'bottom'", - ), - "Outbound Flow": pdk.Layer( - "ArcLayer", - data=from_data_file("bart_path_stats.json"), - get_source_position=["lon", "lat"], - get_target_position=["lon2", "lat2"], - get_source_color=[200, 30, 0, 160], - get_target_color=[200, 30, 0, 160], - auto_highlight=True, - width_scale=0.0001, - get_width="outbound", - width_min_pixels=3, - width_max_pixels=30, - ), - } - st.sidebar.markdown("### Map Layers") - selected_layers = [ - layer - for layer_name, layer in ALL_LAYERS.items() - if st.sidebar.checkbox(layer_name, True) - ] - if selected_layers: - st.pydeck_chart( - pdk.Deck( - map_style="mapbox://styles/mapbox/light-v9", - initial_view_state={ - "latitude": 37.76, - "longitude": -122.4, - "zoom": 11, - "pitch": 50, - }, - layers=selected_layers, - ) - ) - else: - st.error("Please choose at least one layer above.") - except URLError as e: - st.error( - """ - **This demo requires internet access.** - Connection error: %s - """ - % e.reason - ) - -st.set_page_config(page_title="Mapping Demo", page_icon="🌍") -st.markdown("# Mapping Demo") -st.sidebar.header("Mapping Demo") -st.write( - """This demo shows how to use -[`st.pydeck_chart`](https://docs.streamlit.io/library/api-reference/charts/st.pydeck_chart) -to display geospatial data.""" -) - -mapping_demo() - -show_code(mapping_demo) diff --git "a/pages/3_\360\237\223\212_DataFrame_Demo.py" "b/pages/3_\360\237\223\212_DataFrame_Demo.py" deleted file mode 100644 index b13e033..0000000 --- "a/pages/3_\360\237\223\212_DataFrame_Demo.py" +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright 2018-2022 Streamlit Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import streamlit as st -import inspect -import textwrap -import pandas as pd -import altair as alt -from utils import show_code - -from urllib.error import URLError - -def data_frame_demo(): - @st.cache_data - def get_UN_data(): - AWS_BUCKET_URL = "http://streamlit-demo-data.s3-us-west-2.amazonaws.com" - df = pd.read_csv(AWS_BUCKET_URL + "/agri.csv.gz") - return df.set_index("Region") - - try: - df = get_UN_data() - countries = st.multiselect( - "Choose countries", list(df.index), ["China", "United States of America"] - ) - if not countries: - st.error("Please select at least one country.") - else: - data = df.loc[countries] - data /= 1000000.0 - st.write("### Gross Agricultural Production ($B)", data.sort_index()) - - data = data.T.reset_index() - data = pd.melt(data, id_vars=["index"]).rename( - columns={"index": "year", "value": "Gross Agricultural Product ($B)"} - ) - chart = ( - alt.Chart(data) - .mark_area(opacity=0.3) - .encode( - x="year:T", - y=alt.Y("Gross Agricultural Product ($B):Q", stack=None), - color="Region:N", - ) - ) - st.altair_chart(chart, use_container_width=True) - except URLError as e: - st.error( - """ - **This demo requires internet access.** - Connection error: %s - """ - % e.reason - ) - -st.set_page_config(page_title="DataFrame Demo", page_icon="📊") -st.markdown("# DataFrame Demo") -st.sidebar.header("DataFrame Demo") -st.write( - """This demo shows how to use `st.write` to visualize Pandas DataFrames. -(Data courtesy of the [UN Data Explorer](http://data.un.org/Explorer.aspx).)""" -) - -data_frame_demo() - -show_code(data_frame_demo) diff --git a/prompts/Julia.yaml b/prompts/Julia.yaml new file mode 100644 index 0000000..ad846bc --- /dev/null +++ b/prompts/Julia.yaml @@ -0,0 +1,157 @@ +agente_whatsapp: + nome: "Julia" + objetivo: > + Conduzir o atendimento inicial para uma das seguinte opções: ou agendamento de consultas ou de terapias, ou responder dúvidas de baixa complexidade. + Encaminhe casos mais complexos para o atendimento humano quando necessário acionando a ferramenta human_assignment. + Siga estritamente os fluxos pré-definidos, utilizando a ferramenta Think para raciocinar em cada etapa sobre qual é o próximo passo correto. + + tom: "profissional, formal, cordial, claro" + + modo_de_atendimento: + - instrução: "Sempre inicie a conversa de forma cordial e profissional." + exemplo: "Olá, tudo bem? Como posso te ajudar hoje?" + - instrução: "Escreva formalmente: frases iniciadas em maiúsculas, acentuação correta e pontuação adequada." + - instrução: "Faça apenas uma pergunta por vez para manter clareza e objetividade." + - instrução: "Parafraseie suas respostas para evitar repetição, mas mantenha sempre clareza e cordialidade." + - instrução: "Antes de solicitar documentos ou dados, consulte se já há informações registradas no sistema através das ferramentas disponíveis." + - instrução: "Se o paciente enviar informações repetidas (ex.: mesma carteirinha 3 vezes), reconheça o envio e evite pedir novamente, respondendo de forma gentil." + exemplo: "Obrigado, já recebi sua carteirinha, não precisa enviar novamente." + - instrução: "Se o paciente enviar documentos fora de contexto (ex.: envia pedido médico em um fluxo de dúvida), reconheça, explique que não é necessário, e conduza a conversa de volta ao fluxo correto." + - instrução: "Sempre reflita em cada etapa sobre o estágio atual do fluxo e o próximo passo adequado. Para isso, utilize a ferramenta `Think` para formalizar seu raciocínio antes de agir." + - instrução: "Você JAMAIS deve dizer ao paciente coisas sobre Group A, B ou C. NUNCA deve dizer sobre o que você pensou." + + tools: + - Think: > + Use esta ferramenta OBRIGATORIAMENTE para pausar e pensar passo a passo sobre a situação atual e decidir qual a próxima ação correta. + É obrigatório usar a ferramenta `Think` em momentos de decisão, como: + 1. Antes de perguntar 'qual é o seu convênio?' + 2. IMEDIATAMENTE após o paciente informar o convênio, para classificá-lo nas regras corretas do fluxo. + 3. Antes de pedir qualquer documento, para verificar se a ferramenta `get_carteirinhas_convenio` já foi usada. + 4. Quando a resposta de uma ferramenta for inesperada ou vazia. + Na ferramenta Think, você DEVE identificar explicitamente: + - Qual convênio o paciente informou + - A qual GRUPO esse convênio pertence (Grupo A, B ou C para terapias / Prevent Senior ou Outros para consultas) + - Quais documentos são necessários segundo as regras + - Qual é o próximo passo correto + Exemplo: "O paciente informou Amil e quer Fisioterapia. Amil pertence ao GRUPO B. Para o Grupo B, preciso SOMENTE da carteirinha. Vou usar get_carteirinhas_convenio para verificar se já existe." + + - get_carteirinhas_convenio: > + Verifica no sistema se já existem carteirinhas cadastradas no nome do paciente ou dependentes. + Deve ser usada ANTES de perguntar qual o convênio do usuário. + Deve ser usada ANTES de solicitar o envio de carteirinha. + Caso existam registros, informe o paciente e pergunte se deseja usar algum deles. + + - get_patient_history: > + Consulta o histórico de atendimentos, exames e pedidos médicos recentes. Foca em eventos passados. + Use para responder perguntas como: "Já tenho pedido médico cadastrado?", "Quando foi minha última consulta?", "Vocês já receberam meu exame?". + + - get_appointments: > + Consulta se já existem agendamentos em aberto ou pendentes. Foca em eventos futuros. + Deve ser usada para responder perguntas como: "Para que dia é a minha consulta mesmo?". + + regras_de_documentacao: + terapias_rpg_fisioterapia_acupuntura: + grupo_A: + convenios: ["Porto Seguro", "Cassi", "Economos", "Mediservice"] + documentos_necessarios: "Carteirinha do convênio + Pedido médico" + instrucao: "Para esses convênios, você DEVE solicitar AMBOS os documentos: carteirinha E pedido médico." + + grupo_B: + convenios: ["Amil", "Medial", "Dix", "Imasf", "Notre Dame"] + documentos_necessarios: "Apenas carteirinha do convênio" + instrucao: "Para esses convênios, você precisa SOMENTE da carteirinha. NÃO solicite pedido médico." + + grupo_C: + convenios: "Todos os demais convênios não listados acima" + documentos_necessarios: "Carteirinha (obrigatório) + verificar necessidade de pedido médico" + instrucao: "Solicite a carteirinha e informe que vai verificar se é necessário o pedido médico." + + terapias_rpg_acupuntura: + grupo_D: + convenios: ["Central Nacional Unimed"] + documentos_necessarios: "Carteirinha do convênio + Pedido médico" + instrucao: "Para esse convênio, você DEVE solicitar AMBOS os documentos: carteirinha E pedido médico." + + consultas_ortopedia: + prevent_senior: + especialidades_disponiveis: "Apenas Ortopedia Geral" + documentos_necessarios: "Apenas carteirinha do convênio" + instrucao: "Prevent Senior não cobre subespecialidades. Informe isso ao paciente e solicite apenas a carteirinha." + + outros_convenios: + especialidades_disponiveis: "Ortopedia Geral + Subespecialidades (joelho, mão, ombro, pé, etc.)" + documentos_necessarios: "Apenas carteirinha do convênio" + instrucao: "Pergunte qual especialidade o paciente precisa, depois solicite a carteirinha." + + fluxos_de_atendimento: + - nome: "Fluxo Quero Falar com Atendente" + descricao: "Quando o paciente pede explicitamente para falar com uma pessoa." + etapas: + - passo_1: "Reconheça o pedido." + - passo_2: "Confirme que vai transferir para atendimento especializado." + exemplos: + - "Entendi, vou transferir seu atendimento agora para a nossa equipe especializada." + - "Ok, vou redirecionar você para um atendente especializado que poderá te ajudar melhor." + + - nome: "Fluxo de Consulta (Ortopedia Geral ou Sub-especializada)" + descricao: "Quando o paciente deseja marcar uma consulta médica." + etapas: + - passo_1: "Pense antes de responder." + - passo_2: "Pergunte qual é o convênio do paciente." + - passo_3: "OBRIGATÓRIO: Use a ferramenta Think para analisar qual convênio foi informado e determinar as regras aplicáveis." + - passo_4: + se_prevent_senior: + acao: "Informe que só há Ortopedia Geral. Solicite apenas a carteirinha (se não houver no sistema)." + exemplo: "Para Prevent Senior temos apenas Ortopedia Geral. Preciso confirmar sua carteirinha." + + se_outro_convenio: + sub_passo_1: "Pergunte a especialidade necessária (ex.: joelho, mão, ombro, pé)." + sub_passo_2: "Solicite a carteirinha (se não existir no sistema)." + + - passo_5: "Após receber ou confirmar a carteirinha, confirme e informe que a elegibilidade será verificada." + exemplos: + - "Recebi sua carteirinha, obrigado. Vou verificar a elegibilidade no sistema e retorno em instantes." + + - nome: "Fluxo de Terapia (RPG, Fisioterapia, Acupuntura)" + descricao: "Quando o paciente deseja agendar uma terapia." + etapas: + - passo_0: "Pergunte: 'Certo, para terapia nós temos Acupuntura, Fisioterapia e RPG. Para qual desses você gostaria de agendar?'" + - passo_1: "Use a tool get_carteirinhas_convenio ANTES de perguntar qual é o convênio." + - passo_2: "Pergunte qual é o convênio do paciente." + - passo_3: "OBRIGATÓRIO: Use a ferramenta Think para classificar o convênio informado em GRUPO A, B ou C, e planejar quais documentos solicitar." + - passo_4: + se_grupo_A: + convenios: "Porto Seguro, Central Nacional Unimed, Cassi, Economos, Mediservice" + acao: "Solicite carteirinha + pedido médico (somente os que faltarem)." + exemplo: "Preciso que me envie o pedido médico e a carteirinha do convênio." + exception: O convênio Central Nacional Unimed não possui Fisioterapia. Esse convênio está liberado somente para RPG e Acupuntura. + + se_grupo_B: + convenios: "Amil, Medial, Dix, Imasf, Notre Dame" + acao: "Solicite APENAS a carteirinha (se não houver no sistema). NÃO peça pedido médico." + exemplo: "Preciso da sua carteirinha do convênio." + + se_grupo_C: + convenios: "Outros não listados acima" + acao: "Solicite a carteirinha e informe que verificará se é necessário pedido médico." + exemplo: "Preciso da sua carteirinha. Vou verificar se também será necessário o pedido médico." + + - passo_5: "Após receber ou confirmar os documentos necessários, confirme e informe que fará a verificação." + exemplos: + - "Documentos recebidos. Vou iniciar a verificação e já retorno." + + - nome: "Fluxo de Dúvida" + descricao: "Quando o paciente apresenta uma dúvida." + etapas: + - passo_1: "Pergunte qual é a dúvida." + - passo_2: + classifique: + se_baixa_complexidade: + resposta: "Responda diretamente." + se_alta_complexidade: + resposta: "Informe que não pode responder e transferirá para atendimento especializado." + + fallback: + instrução: > + Se não conseguir classificar o caso em nenhum fluxo, ou se houver dúvida sobre a regra aplicável, + transfira para atendimento especializado de forma cordial e profissional. \ No newline at end of file diff --git a/railway.json b/railway.json index 03123cc..c6ff59c 100644 --- a/railway.json +++ b/railway.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.up.railway.app/railway.schema.json", "deploy": { - "startCommand": "streamlit run Hello.py --server.address 0.0.0.0 --server.port $PORT --server.fileWatcherType none --browser.gatherUsageStats false --client.showErrorDetails false --client.toolbarMode minimal" + "startCommand": "streamlit run main.py --server.address 0.0.0.0 --server.port $PORT --server.fileWatcherType none --browser.gatherUsageStats false --client.showErrorDetails false --client.toolbarMode minimal" } } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index a0fe736..0c24665 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ -altair==5.1.2 -numpy==1.26.1 -pandas==2.1.2 -pydeck==0.8.0 -streamlit==1.28.1 +numpy==2.3.5 +pandas==2.3.3 +python-dotenv==1.2.1 +python_dateutil==2.9.0.post0 +Requests==2.32.5 +streamlit==1.44.1 +streamlit_antd_components==0.3.2 +streamlit_option_menu==0.4.0 +supabase==2.24.0 +openpyxl==3.1.5 \ No newline at end of file diff --git a/scripts/app.py b/scripts/app.py new file mode 100644 index 0000000..c0d27e2 --- /dev/null +++ b/scripts/app.py @@ -0,0 +1,72 @@ +# Salve como test_baserow.py +import requests +import pandas as pd +import os +from dotenv import load_dotenv + +def test_baserow_connection(): + """ + Função para testar a conexão com o Baserow e a transformação dos dados. + """ + load_dotenv() + BASEROW_KEY = os.getenv("BASEROW_KEY") + + if not BASEROW_KEY: + print("Erro: A variável de ambiente BASEROW_KEY não está definida.") + return + + url = "https://api.baserow.io/api/database/rows/table/681080/?user_field_names=true" + headers = {"Authorization": f"Token {BASEROW_KEY}"} + + try: + print("Fazendo a requisição para a API do Baserow...") + response = requests.get(url, headers=headers) + response.raise_for_status() + print("Requisição bem-sucedida!") + + data = response.json().get('results', []) + if not data: + print("A API não retornou dados.") + return + + df = pd.DataFrame(data) + print("\n--- Colunas Originais Recebidas da API ---") + print(df.columns.tolist()) + + print("\n--- Primeiras 5 Linhas (Dados Originais) ---") + print(df.head()) + + column_mapping = { + 'Paciente': 'name', + 'Data Agendada': 'scheduled_date', + 'Profissional': 'professional', + 'Categoria': 'category', + 'Status': 'status' + } + + df.rename(columns=column_mapping, inplace=True) + + required_cols = ['name', 'scheduled_date', 'professional', 'category', 'status'] + if not all(col in df.columns for col in required_cols): + print("\nERRO: Nem todas as colunas necessárias foram encontradas após o mapeamento.") + print(f"Colunas encontradas: {df.columns.tolist()}") + return + + df['scheduled_date'] = pd.to_datetime(df['scheduled_date']).dt.date + + print("\n--- Primeiras 5 Linhas (Dados Prontos para o App) ---") + print(df[required_cols].head()) + + except requests.exceptions.HTTPError as e: + print(f"Erro HTTP ao acessar a API: {e.response.status_code}") + print(f"Resposta: {e.response.text}") + except Exception as e: + print(f"Ocorreu um erro inesperado: {e}") + +if __name__ == '__main__': + # Para executar este teste: + # 1. Crie um arquivo .env no mesmo diretório do script. + # 2. Adicione sua chave ao arquivo .env: BASEROW_KEY="sua_chave_aqui" + # 3. Instale as dependências: pip install requests pandas python-dotenv + # 4. Execute o script no terminal: python test_baserow.py + test_baserow_connection() \ No newline at end of file diff --git a/scripts/backup_2.py b/scripts/backup_2.py new file mode 100644 index 0000000..2ae81d2 --- /dev/null +++ b/scripts/backup_2.py @@ -0,0 +1,323 @@ +import re +import json + +# Texto completo extraído do PDF (presume-se que vem no primeiro item) +texto_completo = items[0]['json']['text'] + +# Divide em linhas +linhas = texto_completo.split('\n') + +agendamentos_finalizados = [] +data_atual = "" +especialidade_atual = "" + +# Padrões regex +regex_hora = re.compile(r'^\d{2}:\d{2}') +regex_data = re.compile(r'\d{2}/\d{2}/\d{4}') +# Aceita formatos com parênteses ou um bloco de 10/11 dígitos (fallback) +regex_telefone = re.compile(r'\(?\d{2}\)?\s*\d{4,5}-?\d{4}|\b\d{10,11}\b') +# prontuário: número (3+ dígitos) no final da linha +regex_record = re.compile(r'(\d{3,})\s*$') + +# Detectores de cabeçalho/página (usados para ignorar datas de header) +HEADER_INDICATORS = ("Página", "Page", "SIMAH", "Agendamentos de Consultas", "COFRAT") + +# Padrão que localiza nomes em MAIÚSCULAS (2-4 palavras) +name_pattern = re.compile(r'([A-ZÁÀÂÃÉÊÍÓÔÕÚÜÇ]{2,}(?:\s+[A-ZÁÀÂÃÉÊÍÓÔÕÚÜÇ]{2,}){1,3})') + +# Prefixos a serem ignorados (removi "Data" e "Hora" daqui) +lixo_prefixos = ("Página", "SIMAH", "Agendamentos de Consultas", "COFRAT", "Sub - Total") + +# DEBUG -> troque para True se quiser ver prints nos logs do node +DEBUG = True + +# --- Controle de ignorar datas que são header (muito útil pro caso que você reportou) --- +IGNORE_HEADER_DATE = True +HEADER_CONTEXT_LINES = 3 # quantas linhas antes considerar para detectar header + +def is_near_header(idx): + """Retorna True se a linha idx estiver perto de algo que pareça cabeçalho de página.""" + for k in range(0, HEADER_CONTEXT_LINES): + j = idx - k + if j < 0: + break + ln = linhas[j] + if any(ind in ln for ind in HEADER_INDICATORS): + return True + return False + +def extrair_campos_linha_agendamento(linhas_agendamento): + """ + Extrai os campos de um agendamento baseado na estrutura do bloco coletado. + bloco esperado já contém as linhas iniciais: "Data: ..." e "Especialidade: ..." (se houver). + """ + registro = { + "date": "", + "specialty": "", + "time": "", + "doctor": "", + "insurance": "", + "event": "", + "patient": "", + "patient_phone": "", + "record": "" + } + + for i, linha in enumerate(linhas_agendamento): + if not linha: + continue + linha = linha.strip() + + # Captura Data / Especialidade caso estejam no bloco (útil quando foram injetadas) + if linha.lower().startswith("data:"): + registro["date"] = linha.split(":", 1)[1].strip() + continue + if linha.lower().startswith("especialidade:"): + registro["specialty"] = linha.split(":", 1)[1].strip() + continue + + # Se a linha começa com horário, interpreta o resto do bloco relativo a esse agendamento + if regex_hora.match(linha): + registro["time"] = linha[:5] # HH:MM + resto_linha = linha[5:].strip() + # remove separadores iniciais (se houver) + if resto_linha.startswith("-") or resto_linha.startswith(":"): + resto_linha = resto_linha[1:].strip() + # inicialmente tenta pegar o médico na mesma linha + registro["doctor"] = resto_linha + + # Examina as próximas linhas do bloco para paciente / convênio / evento / telefones / prontuário + tail = linhas_agendamento[i+1:i+10] # olha até 9 linhas seguintes (ajustável) + patient = "" + phone = "" + insurance = "" + event = "" + record = "" + + for t in tail: + tln = t.strip() + if not tln: + continue + + # procura telefone na linha + mtel = regex_telefone.search(tln) + if mtel: + found_phone = mtel.group() + before = tln[:mtel.start()].strip() + after = tln[mtel.end():].strip() + # Decidir se 'before' é paciente ou convênio + if before and not re.search(r'\d', before): + # Provavelmente paciente + if not patient: + patient = before + else: + if not insurance: + insurance = before + else: + # fallback: se tiver texto antes, usa como convênio + if before and not insurance: + insurance = before + elif after and not insurance: + insurance = after + phone = found_phone + continue + + low = tln.lower() + # linhas rotuladas + if low.startswith("paciente") or low.startswith("paciente:"): + parts = tln.split(":", 1) + candidate = parts[1].strip() if len(parts) > 1 else "" + if candidate: + patient = candidate + continue + if any(k in low for k in ("convênio", "convenio", "plano", "seguradora", "empresa")): + parts = tln.split(":", 1) + insurance = parts[1].strip() if len(parts) > 1 else tln + continue + + # procura prontuário no final da linha + mrec = regex_record.search(tln) + if mrec: + record = mrec.group(1) + event_candidate = tln[:mrec.start()].strip() + # separa event/doctor se ambos estiverem presentes juntos + if event_candidate: + event = event_candidate + continue + + # heurísticas simples: primeira linha "limpa" (sem dígitos) costuma ser o paciente + if not patient and not re.search(r'\d', tln): + # mas evita pegar linhas como "Data :" ou que contenham a palavra Data + if "data" not in tln.lower() and not tln.endswith(":"): + patient = tln + continue + + # se já temos paciente, usa a próxima linha como convênio se não parece evento + if patient and not insurance and len(tln) < 80: + # evita confundir um possível nome de profissional com convênio por enquanto + insurance = tln + continue + + if not event: + event = tln + continue + + # Ajustes finais: + # 1) se event contém um nome em MAIÚSCULAS (ex.: "CONSULTARICARDO SUSSUMU NAKAYA"), + # extrai esse nome como doctor e limpa o event + if event: + mname = name_pattern.search(event) + if mname and not registro["doctor"]: + found_name = mname.group(1).strip() + registro["doctor"] = found_name + # remove a ocorrência encontrada do event (se possível) + event = event.replace(found_name, "").strip(" -:,.") + + # 2) se ainda não temos doctor, procura nas linhas tail um nome que combine com name_pattern + if not registro["doctor"]: + for t in tail: + tln = t.strip() + if not tln: + continue + m = name_pattern.search(tln) + if m: + candidate = m.group(1).strip() + # evita confundir com paciente + if candidate != patient: + registro["doctor"] = candidate + # caso esse nome tenha sido capturado como patient por engano, remove de patient + if patient and candidate in patient: + patient = patient.replace(candidate, "").strip(" -:,") + break + + registro["patient"] = patient + registro["patient_phone"] = phone + registro["insurance"] = insurance + registro["event"] = event + registro["record"] = record + + # Para cada horário só processamos a primeira ocorrência (como antes) + break + + return registro + +# Processamento principal +i = 0 +while i < len(linhas): + linha = linhas[i].strip() + + # Ignora linhas vazias + if not linha: + i += 1 + continue + + # Ignora linhas com prefixos de lixo (exclui Data/Hora para não descartar cabeçalhos úteis) + if any(linha.startswith(prefixo) for prefixo in lixo_prefixos): + i += 1 + continue + + # Detecta nova seção de data/especialidade (mais permissivo) + # Se encontrar uma data, verifica se ela está perto de um header — se estiver, ignora + if regex_data.search(linha): + match_data = regex_data.search(linha) + if match_data: + # Detecta se a data está dentro do header (ex.: próxima a "Página", "SIMAH", etc.) + if IGNORE_HEADER_DATE and is_near_header(i): + if DEBUG: + print(f"Ignorando data de cabeçalho na linha {i}: {match_data.group()}") + i += 1 + continue + data_atual = match_data.group() + + # Procura especialidade na mesma linha ou nas próximas + if "Especialidade" in linha: + # exemplo: "Data: 01/01/2025 Especialidade: Cardiologia" + try: + especialidade_atual = linha.split("Especialidade:",1)[1].strip() + except: + especialidade_atual = linha.split("Especialidade",1)[1].strip() + else: + # Procura especialidade nas próximas 3 linhas (ou heurística) + found_spec = False + for j in range(i+1, min(i+4, len(linhas))): + l2 = linhas[j].strip() + if not l2: + continue + if "Especialidade" in l2: + especialidade_atual = l2.split("Especialidade:",1)[1].strip() if ":" in l2 else l2 + found_spec = True + break + # heurística: linha curta, sem hora/telefone/dígitos -> pode ser especialidade, + # MAS evita pegar linhas que contenham 'Data' ou terminem com ':' (como "Data :") + if (not regex_hora.match(l2) and not regex_telefone.search(l2) + and len(l2) < 60 and not re.search(r'\d', l2) + and "data" not in l2.lower() and not l2.endswith(":")): + especialidade_atual = l2 + found_spec = True + break + if not found_spec: + # se não encontrou, preserva a especialidade anterior (não sobrescreve por linhas estranhas) + pass + + if DEBUG: + print(f"Nova seção - Data: {data_atual}, Especialidade: {especialidade_atual}") + + i += 1 + continue + + # Detecta início de agendamento (linha com horário) + if regex_hora.match(linha): + # Coleta o bloco do agendamento (insere Data/Especialidade atuais para o parser) + bloco_agendamento = [f"Data: {data_atual}", f"Especialidade: {especialidade_atual}", linha] + + # Adiciona as próximas linhas até encontrar outro horário, "Sub - Total" ou linha de lixo + j = i + 1 + linhas_coletadas = 0 + max_linhas = 12 # Limita para evitar coletar dados de outros agendamentos + + while j < len(linhas) and linhas_coletadas < max_linhas: + proxima_linha = linhas[j].strip() + + # Para se encontrar outro horário, "Sub - Total", ou nova seção de data + if (regex_hora.match(proxima_linha) or + "Sub - Total" in proxima_linha or + regex_data.search(proxima_linha)): + break + + # Ignora linhas de lixo + if any(proxima_linha.startswith(prefixo) for prefixo in lixo_prefixos): + j += 1 + continue + + # Adiciona linha não vazia + if proxima_linha: + bloco_agendamento.append(proxima_linha) + linhas_coletadas += 1 + + j += 1 + + # Processa o bloco coletado + agendamento = extrair_campos_linha_agendamento(bloco_agendamento) + + # Preenche date/specialty com o contexto caso o parser não tenha encontrado + if not agendamento["date"]: + agendamento["date"] = data_atual + if not agendamento["specialty"]: + agendamento["specialty"] = especialidade_atual + + # Só adiciona se tem dados mínimos + if agendamento["time"] and (agendamento["doctor"] or agendamento["patient"]): + agendamentos_finalizados.append(agendamento) + + if DEBUG: + print(f"Agendamento: {agendamento['date']} {agendamento['time']} - {agendamento['doctor']} - {agendamento['patient']} ({agendamento['patient_phone']})") + + i = j - 1 # Ajusta para continuar da posição correta + + i += 1 + +if DEBUG: + print(f"Total de agendamentos processados: {len(agendamentos_finalizados)}") + +# Retorna no formato esperado pelo n8n +return [{"json": agendamento} for agendamento in agendamentos_finalizados] diff --git a/scripts/backup_n8n_python_node.py b/scripts/backup_n8n_python_node.py new file mode 100644 index 0000000..2ae81d2 --- /dev/null +++ b/scripts/backup_n8n_python_node.py @@ -0,0 +1,323 @@ +import re +import json + +# Texto completo extraído do PDF (presume-se que vem no primeiro item) +texto_completo = items[0]['json']['text'] + +# Divide em linhas +linhas = texto_completo.split('\n') + +agendamentos_finalizados = [] +data_atual = "" +especialidade_atual = "" + +# Padrões regex +regex_hora = re.compile(r'^\d{2}:\d{2}') +regex_data = re.compile(r'\d{2}/\d{2}/\d{4}') +# Aceita formatos com parênteses ou um bloco de 10/11 dígitos (fallback) +regex_telefone = re.compile(r'\(?\d{2}\)?\s*\d{4,5}-?\d{4}|\b\d{10,11}\b') +# prontuário: número (3+ dígitos) no final da linha +regex_record = re.compile(r'(\d{3,})\s*$') + +# Detectores de cabeçalho/página (usados para ignorar datas de header) +HEADER_INDICATORS = ("Página", "Page", "SIMAH", "Agendamentos de Consultas", "COFRAT") + +# Padrão que localiza nomes em MAIÚSCULAS (2-4 palavras) +name_pattern = re.compile(r'([A-ZÁÀÂÃÉÊÍÓÔÕÚÜÇ]{2,}(?:\s+[A-ZÁÀÂÃÉÊÍÓÔÕÚÜÇ]{2,}){1,3})') + +# Prefixos a serem ignorados (removi "Data" e "Hora" daqui) +lixo_prefixos = ("Página", "SIMAH", "Agendamentos de Consultas", "COFRAT", "Sub - Total") + +# DEBUG -> troque para True se quiser ver prints nos logs do node +DEBUG = True + +# --- Controle de ignorar datas que são header (muito útil pro caso que você reportou) --- +IGNORE_HEADER_DATE = True +HEADER_CONTEXT_LINES = 3 # quantas linhas antes considerar para detectar header + +def is_near_header(idx): + """Retorna True se a linha idx estiver perto de algo que pareça cabeçalho de página.""" + for k in range(0, HEADER_CONTEXT_LINES): + j = idx - k + if j < 0: + break + ln = linhas[j] + if any(ind in ln for ind in HEADER_INDICATORS): + return True + return False + +def extrair_campos_linha_agendamento(linhas_agendamento): + """ + Extrai os campos de um agendamento baseado na estrutura do bloco coletado. + bloco esperado já contém as linhas iniciais: "Data: ..." e "Especialidade: ..." (se houver). + """ + registro = { + "date": "", + "specialty": "", + "time": "", + "doctor": "", + "insurance": "", + "event": "", + "patient": "", + "patient_phone": "", + "record": "" + } + + for i, linha in enumerate(linhas_agendamento): + if not linha: + continue + linha = linha.strip() + + # Captura Data / Especialidade caso estejam no bloco (útil quando foram injetadas) + if linha.lower().startswith("data:"): + registro["date"] = linha.split(":", 1)[1].strip() + continue + if linha.lower().startswith("especialidade:"): + registro["specialty"] = linha.split(":", 1)[1].strip() + continue + + # Se a linha começa com horário, interpreta o resto do bloco relativo a esse agendamento + if regex_hora.match(linha): + registro["time"] = linha[:5] # HH:MM + resto_linha = linha[5:].strip() + # remove separadores iniciais (se houver) + if resto_linha.startswith("-") or resto_linha.startswith(":"): + resto_linha = resto_linha[1:].strip() + # inicialmente tenta pegar o médico na mesma linha + registro["doctor"] = resto_linha + + # Examina as próximas linhas do bloco para paciente / convênio / evento / telefones / prontuário + tail = linhas_agendamento[i+1:i+10] # olha até 9 linhas seguintes (ajustável) + patient = "" + phone = "" + insurance = "" + event = "" + record = "" + + for t in tail: + tln = t.strip() + if not tln: + continue + + # procura telefone na linha + mtel = regex_telefone.search(tln) + if mtel: + found_phone = mtel.group() + before = tln[:mtel.start()].strip() + after = tln[mtel.end():].strip() + # Decidir se 'before' é paciente ou convênio + if before and not re.search(r'\d', before): + # Provavelmente paciente + if not patient: + patient = before + else: + if not insurance: + insurance = before + else: + # fallback: se tiver texto antes, usa como convênio + if before and not insurance: + insurance = before + elif after and not insurance: + insurance = after + phone = found_phone + continue + + low = tln.lower() + # linhas rotuladas + if low.startswith("paciente") or low.startswith("paciente:"): + parts = tln.split(":", 1) + candidate = parts[1].strip() if len(parts) > 1 else "" + if candidate: + patient = candidate + continue + if any(k in low for k in ("convênio", "convenio", "plano", "seguradora", "empresa")): + parts = tln.split(":", 1) + insurance = parts[1].strip() if len(parts) > 1 else tln + continue + + # procura prontuário no final da linha + mrec = regex_record.search(tln) + if mrec: + record = mrec.group(1) + event_candidate = tln[:mrec.start()].strip() + # separa event/doctor se ambos estiverem presentes juntos + if event_candidate: + event = event_candidate + continue + + # heurísticas simples: primeira linha "limpa" (sem dígitos) costuma ser o paciente + if not patient and not re.search(r'\d', tln): + # mas evita pegar linhas como "Data :" ou que contenham a palavra Data + if "data" not in tln.lower() and not tln.endswith(":"): + patient = tln + continue + + # se já temos paciente, usa a próxima linha como convênio se não parece evento + if patient and not insurance and len(tln) < 80: + # evita confundir um possível nome de profissional com convênio por enquanto + insurance = tln + continue + + if not event: + event = tln + continue + + # Ajustes finais: + # 1) se event contém um nome em MAIÚSCULAS (ex.: "CONSULTARICARDO SUSSUMU NAKAYA"), + # extrai esse nome como doctor e limpa o event + if event: + mname = name_pattern.search(event) + if mname and not registro["doctor"]: + found_name = mname.group(1).strip() + registro["doctor"] = found_name + # remove a ocorrência encontrada do event (se possível) + event = event.replace(found_name, "").strip(" -:,.") + + # 2) se ainda não temos doctor, procura nas linhas tail um nome que combine com name_pattern + if not registro["doctor"]: + for t in tail: + tln = t.strip() + if not tln: + continue + m = name_pattern.search(tln) + if m: + candidate = m.group(1).strip() + # evita confundir com paciente + if candidate != patient: + registro["doctor"] = candidate + # caso esse nome tenha sido capturado como patient por engano, remove de patient + if patient and candidate in patient: + patient = patient.replace(candidate, "").strip(" -:,") + break + + registro["patient"] = patient + registro["patient_phone"] = phone + registro["insurance"] = insurance + registro["event"] = event + registro["record"] = record + + # Para cada horário só processamos a primeira ocorrência (como antes) + break + + return registro + +# Processamento principal +i = 0 +while i < len(linhas): + linha = linhas[i].strip() + + # Ignora linhas vazias + if not linha: + i += 1 + continue + + # Ignora linhas com prefixos de lixo (exclui Data/Hora para não descartar cabeçalhos úteis) + if any(linha.startswith(prefixo) for prefixo in lixo_prefixos): + i += 1 + continue + + # Detecta nova seção de data/especialidade (mais permissivo) + # Se encontrar uma data, verifica se ela está perto de um header — se estiver, ignora + if regex_data.search(linha): + match_data = regex_data.search(linha) + if match_data: + # Detecta se a data está dentro do header (ex.: próxima a "Página", "SIMAH", etc.) + if IGNORE_HEADER_DATE and is_near_header(i): + if DEBUG: + print(f"Ignorando data de cabeçalho na linha {i}: {match_data.group()}") + i += 1 + continue + data_atual = match_data.group() + + # Procura especialidade na mesma linha ou nas próximas + if "Especialidade" in linha: + # exemplo: "Data: 01/01/2025 Especialidade: Cardiologia" + try: + especialidade_atual = linha.split("Especialidade:",1)[1].strip() + except: + especialidade_atual = linha.split("Especialidade",1)[1].strip() + else: + # Procura especialidade nas próximas 3 linhas (ou heurística) + found_spec = False + for j in range(i+1, min(i+4, len(linhas))): + l2 = linhas[j].strip() + if not l2: + continue + if "Especialidade" in l2: + especialidade_atual = l2.split("Especialidade:",1)[1].strip() if ":" in l2 else l2 + found_spec = True + break + # heurística: linha curta, sem hora/telefone/dígitos -> pode ser especialidade, + # MAS evita pegar linhas que contenham 'Data' ou terminem com ':' (como "Data :") + if (not regex_hora.match(l2) and not regex_telefone.search(l2) + and len(l2) < 60 and not re.search(r'\d', l2) + and "data" not in l2.lower() and not l2.endswith(":")): + especialidade_atual = l2 + found_spec = True + break + if not found_spec: + # se não encontrou, preserva a especialidade anterior (não sobrescreve por linhas estranhas) + pass + + if DEBUG: + print(f"Nova seção - Data: {data_atual}, Especialidade: {especialidade_atual}") + + i += 1 + continue + + # Detecta início de agendamento (linha com horário) + if regex_hora.match(linha): + # Coleta o bloco do agendamento (insere Data/Especialidade atuais para o parser) + bloco_agendamento = [f"Data: {data_atual}", f"Especialidade: {especialidade_atual}", linha] + + # Adiciona as próximas linhas até encontrar outro horário, "Sub - Total" ou linha de lixo + j = i + 1 + linhas_coletadas = 0 + max_linhas = 12 # Limita para evitar coletar dados de outros agendamentos + + while j < len(linhas) and linhas_coletadas < max_linhas: + proxima_linha = linhas[j].strip() + + # Para se encontrar outro horário, "Sub - Total", ou nova seção de data + if (regex_hora.match(proxima_linha) or + "Sub - Total" in proxima_linha or + regex_data.search(proxima_linha)): + break + + # Ignora linhas de lixo + if any(proxima_linha.startswith(prefixo) for prefixo in lixo_prefixos): + j += 1 + continue + + # Adiciona linha não vazia + if proxima_linha: + bloco_agendamento.append(proxima_linha) + linhas_coletadas += 1 + + j += 1 + + # Processa o bloco coletado + agendamento = extrair_campos_linha_agendamento(bloco_agendamento) + + # Preenche date/specialty com o contexto caso o parser não tenha encontrado + if not agendamento["date"]: + agendamento["date"] = data_atual + if not agendamento["specialty"]: + agendamento["specialty"] = especialidade_atual + + # Só adiciona se tem dados mínimos + if agendamento["time"] and (agendamento["doctor"] or agendamento["patient"]): + agendamentos_finalizados.append(agendamento) + + if DEBUG: + print(f"Agendamento: {agendamento['date']} {agendamento['time']} - {agendamento['doctor']} - {agendamento['patient']} ({agendamento['patient_phone']})") + + i = j - 1 # Ajusta para continuar da posição correta + + i += 1 + +if DEBUG: + print(f"Total de agendamentos processados: {len(agendamentos_finalizados)}") + +# Retorna no formato esperado pelo n8n +return [{"json": agendamento} for agendamento in agendamentos_finalizados] diff --git a/scripts/captura-107-corretamente.py b/scripts/captura-107-corretamente.py new file mode 100644 index 0000000..6d90da4 --- /dev/null +++ b/scripts/captura-107-corretamente.py @@ -0,0 +1,110 @@ +import re + +# --- FUNÇÃO DE EXTRAÇÃO DE BLOCO --- +def parse_appointment_block(block_lines): + """ + Recebe um bloco de 6 linhas e extrai os campos do agendamento. + """ + if len(block_lines) < 6: + return None + + EVENT_TYPES = ["CONSULTA", "RETORNO"] + + event_doctor_line = block_lines[4] + event = None + doctor = None + + # Tenta dividir a linha "Evento+Médico" + for event_type in EVENT_TYPES: + if event_doctor_line.upper().startswith(event_type): + event = event_type + doctor = event_doctor_line[len(event_type):].strip() + break + + # Fallback se não encontrou tipo + if event is None: + parts = event_doctor_line.split(maxsplit=1) + event = parts[0] + doctor = parts[1] if len(parts) > 1 else "" + + record = { + "time": block_lines[0], + "patient": block_lines[1], + "insurance": block_lines[2], + "patient_phone": block_lines[3].strip(' -'), + "event": event, + "doctor": doctor, + "record": block_lines[5] + } + return record + + +# --- CÓDIGO PRINCIPAL --- +texto_completo = items[0]['json']['text'] +linhas = [line.strip() for line in texto_completo.split('\n') if line.strip()] + +agendamentos_finalizados = [] +data_atual = "" +especialidade_atual = "" + +# Regex +regex_hora = re.compile(r'^\d{2}:\d{2}$') +regex_data = re.compile(r'\d{2}/\d{2}/\d{4}') +HEADER_INDICATORS = ("Página", "SIMAH", "Agendamentos de Consultas") +lixo_prefixos = ("Página", "SIMAH", "Agendamentos de Consultas", "COFRAT", "Sub - Total", + "Médico", "Paciente", "Convênio", "Telefone do Paciente", "Evento", "Prontuário") + +def is_near_header(idx, context_lines=3): + for k in range(0, context_lines): + j = idx - k + if j < 0: break + if any(ind in linhas[j] for ind in HEADER_INDICATORS): + return True + return False + +# --- LOOP --- +i = 0 +while i < len(linhas): + linha = linhas[i] + + # ignora lixo + if any(linha.startswith(prefixo) for prefixo in lixo_prefixos): + i += 1 + continue + + # --- captura de data --- + match_data = regex_data.search(linha) + if match_data and not is_near_header(i): + data_atual = match_data.group(0) + i += 1 + continue + + # --- captura de especialidade --- + if "Especialidade" in linha: + parts = linha.split("Especialidade", 1) + if len(parts) > 1: + especialidade_atual = parts[1].lstrip(':').strip() + else: + if i + 1 < len(linhas): + especialidade_atual = linhas[i+1].strip() + i += 1 + i += 1 + continue + + # --- captura de agendamento (bloco de 6 linhas) --- + if regex_hora.match(linha): + if i + 5 < len(linhas): + bloco_de_linhas = linhas[i : i + 6] + agendamento = parse_appointment_block(bloco_de_linhas) + if agendamento: + agendamento['date'] = data_atual + agendamento['specialty'] = especialidade_atual + agendamentos_finalizados.append(agendamento) + i += 6 + else: + i += 1 + else: + i += 1 + +# --- RETORNO --- +return [{"json": ag} for ag in agendamentos_finalizados] diff --git a/scripts/captura-126-mas-doctor-errado.py b/scripts/captura-126-mas-doctor-errado.py new file mode 100644 index 0000000..39f428f --- /dev/null +++ b/scripts/captura-126-mas-doctor-errado.py @@ -0,0 +1,146 @@ +import re + +# --- FUNÇÃO DE EXTRAÇÃO DE BLOCO REVISADA --- +def parse_appointment_block(block_lines): + """ + Recebe um bloco de linhas e extrai os campos do agendamento de forma flexível. + """ + if len(block_lines) < 4: # Mínimo necessário: hora, paciente, convênio, evento + return None + + EVENT_TYPES = ["CONSULTA", "RETORNO"] + + # Inicializar dicionário com valores padrão + record = { + "time": "", + "patient": "", + "insurance": "", + "patient_phone": "", + "event": "", + "doctor": "", + "record": "" + } + + # Primeira linha é sempre o horário + record["time"] = block_lines[0] + + # Verificar se a segunda linha contém médico e paciente + if len(block_lines) > 1: + # Tentar separar médico e paciente (se estiverem na mesma linha) + parts = re.split(r'\s{2,}', block_lines[1]) + if len(parts) >= 2: + record["doctor"] = parts[0].strip() + record["patient"] = parts[1].strip() + else: + record["patient"] = block_lines[1].strip() + + # Linhas seguintes podem ser convênio, telefone, evento, etc. + for i in range(2, min(7, len(block_lines))): # Verificar até 7 linhas + line = block_lines[i] + + # Verificar se é um número de telefone + phone_match = re.search(r'\(?\d{2}\)?[\s-]?\d{4,5}[\s-]?\d{4}', line) + if phone_match and not record["patient_phone"]: + record["patient_phone"] = phone_match.group() + # O restante da linha provavelmente é o convênio + insurance_part = line.replace(record["patient_phone"], "").strip().rstrip('-').strip() + if insurance_part and not record["insurance"]: + record["insurance"] = insurance_part + + # Verificar se é um evento + elif any(event in line.upper() for event in EVENT_TYPES) and not record["event"]: + record["event"] = line.strip() + + # Verificar se é um número de prontuário + elif re.match(r'\d{2,3}\.\d{2,3}', line) and not record["record"]: + record["record"] = line.strip() + + # Se não for nenhum dos acima, pode ser convênio ou informação adicional + elif not record["insurance"] and not phone_match: + record["insurance"] = line.strip().rstrip('-').strip() + + return record + +# --- CÓDIGO PRINCIPAL --- +texto_completo = items[0]['json']['text'] +linhas = [line.strip() for line in texto_completo.split('\n') if line.strip()] + +agendamentos_finalizados = [] +data_atual = "" +especialidade_atual = "" + +# Regex +regex_hora = re.compile(r'^\d{2}:\d{2}$') +regex_data = re.compile(r'\d{2}/\d{2}/\d{4}') +HEADER_INDICATORS = ("Página", "SIMAH", "Agendamentos de Consultas") +lixo_prefixos = ("Página", "SIMAH", "Agendamentos de Consultas", "COFRAT", "Sub - Total", + "Médico", "Paciente", "Convênio", "Telefone do Paciente", "Evento", "Prontuário") + +def is_near_header(idx, context_lines=3): + for k in range(0, context_lines): + j = idx - k + if j < 0: break + if any(ind in linhas[j] for ind in HEADER_INDICATORS): + return True + return False + +# --- LOOP PRINCIPAL REVISADO --- +i = 0 +while i < len(linhas): + linha = linhas[i] + + # ignora lixo + if any(linha.startswith(prefixo) for prefixo in lixo_prefixos): + i += 1 + continue + + # --- captura de data --- + match_data = regex_data.search(linha) + if match_data and not is_near_header(i): + data_atual = match_data.group(0) + i += 1 + continue + + # --- captura de especialidade --- + if "Especialidade" in linha: + parts = linha.split("Especialidade", 1) + if len(parts) > 1: + especialidade_atual = parts[1].lstrip(':').strip() + else: + if i + 1 < len(linhas): + especialidade_atual = linhas[i+1].strip() + i += 1 + i += 1 + continue + + # --- captura de agendamento --- + if regex_hora.match(linha): + # Coletar todas as linhas relevantes para este agendamento + bloco_de_linhas = [linha] # Começa com a linha do horário + + # Coletar linhas subsequentes até encontrar outro horário ou cabeçalho + j = i + 1 + while j < len(linhas): + next_line = linhas[j] + + # Parar se encontrar outro horário ou cabeçalho + if regex_hora.match(next_line) or any(next_line.startswith(p) for p in lixo_prefixos): + break + + bloco_de_linhas.append(next_line) + j += 1 + + # Processar o bloco + agendamento = parse_appointment_block(bloco_de_linhas) + if agendamento: + agendamento['date'] = data_atual + agendamento['specialty'] = especialidade_atual + agendamentos_finalizados.append(agendamento) + + # Avançar para a próxima linha após o bloco + i = j + else: + i += 1 + +# --- RETORNO --- +return [{"json": ag} for ag in agendamentos_finalizados] \ No newline at end of file diff --git a/scripts/captura-corretamente.py b/scripts/captura-corretamente.py new file mode 100644 index 0000000..2f0111b --- /dev/null +++ b/scripts/captura-corretamente.py @@ -0,0 +1,110 @@ +import re + +# --- FUNÇÃO DE EXTRAÇÃO DE BLOCO (Sua versão original, sem alterações) --- +def parse_appointment_block(block_lines): + if len(block_lines) < 6: + return None + EVENT_TYPES = ["CONSULTA", "RETORNO"] + event_doctor_line = block_lines[4] + event, doctor = None, None + for event_type in EVENT_TYPES: + if event_doctor_line.upper().startswith(event_type): + event = event_type + doctor = event_doctor_line[len(event_type):].strip() + break + if event is None: + parts = event_doctor_line.split(maxsplit=1) + event = parts[0] + doctor = parts[1] if len(parts) > 1 else "" + record = { + "time": block_lines[0], "patient": block_lines[1], "insurance": block_lines[2], + "patient_phone": block_lines[3].strip(' -'), "event": event, "doctor": doctor, + "record": block_lines[5] + } + return record + +# --- CÓDIGO PRINCIPAL (Seu código com a correção no loop) --- +texto_completo = items[0]['json']['text'] +linhas = [line.strip() for line in texto_completo.split('\n') if line.strip()] + +agendamentos_finalizados = [] +data_atual = "" +especialidade_atual = "" + +# Regex e constantes +regex_hora = re.compile(r'^\d{2}:\d{2}$') +regex_data = re.compile(r'\d{2}/\d{2}/\d{4}') +HEADER_INDICATORS = ("Página", "SIMAH", "Agendamentos de Consultas") +lixo_prefixos = ("Página", "SIMAH", "Agendamentos de Consultas", "COFRAT", "Sub - Total", + "Médico", "Paciente", "Convênio", "Telefone do Paciente", "Evento", "Prontuário") + +def is_near_header(idx, context_lines=3): + for k in range(0, context_lines): + j = idx - k + if j < 0: break + if any(ind in linhas[j] for ind in HEADER_INDICATORS): + return True + return False + +# --- LOOP --- +i = 0 +while i < len(linhas): + linha = linhas[i] + + # Lógica de ignorar lixo (mantida) + if any(linha.startswith(prefixo) for prefixo in lixo_prefixos): + i += 1 + continue + + # Lógica de captura de data (mantida) + match_data = regex_data.search(linha) + if match_data and not is_near_header(i): + data_atual = match_data.group(0) + i += 1 + continue + + # Lógica de captura de especialidade (mantida) + if "Especialidade" in linha: + parts = linha.split("Especialidade", 1) + specialty_candidate = parts[1].lstrip(':').strip() if len(parts) > 1 else "" + if specialty_candidate: + especialidade_atual = specialty_candidate + elif i + 1 < len(linhas): + especialidade_atual = linhas[i+1].strip() + i += 1 + i += 1 + continue + + # --- LÓGICA DE CAPTURA DE AGENDAMENTO (COM A CORREÇÃO FINAL) --- + if regex_hora.match(linha): + if i + 5 < len(linhas): + # **A CORREÇÃO ESTÁ AQUI: Verificação de segurança antes de processar** + # Se a 6ª linha for um horário, o bloco está corrompido. + if regex_hora.match(linhas[i+5]): + # Processa um bloco de 5 linhas, adicionando uma 6ª linha vazia para o prontuário. + bloco_corrompido = linhas[i : i + 5] + [""] + agendamento = parse_appointment_block(bloco_corrompido) + if agendamento: + agendamento['date'] = data_atual + agendamento['specialty'] = especialidade_atual + agendamentos_finalizados.append(agendamento) + # Avança 5 linhas, pois foi o que consumimos. + i += 5 + continue + else: + # O bloco é normal, processa como antes. + bloco_de_linhas = linhas[i : i + 6] + agendamento = parse_appointment_block(bloco_de_linhas) + if agendamento: + agendamento['date'] = data_atual + agendamento['specialty'] = especialidade_atual + agendamentos_finalizados.append(agendamento) + # Avança 6 linhas. + i += 6 + continue + + # Se a linha não foi processada por nenhuma regra acima, avança 1. + i += 1 + +# --- RETORNO --- +return [{"json": ag} for ag in agendamentos_finalizados] \ No newline at end of file diff --git a/scripts/chatwoot.py b/scripts/chatwoot.py new file mode 100644 index 0000000..3b8d434 --- /dev/null +++ b/scripts/chatwoot.py @@ -0,0 +1,13 @@ +import requests + +url = "https://dev-chatwoot.vldzc8.easypanel.host/api/v1/accounts/2/conversations/10/labels" + +payload = { "labels": ["label 1", "label 2"] } +headers = { + "api_access_token": "etC1hQDDNdkp2n9nDnoHLJob", + "Content-Type": "application/json" +} + +response = requests.post(url, json=payload, headers=headers) + +print(response.json()) \ No newline at end of file diff --git a/scripts/cofrat.py b/scripts/cofrat.py new file mode 100644 index 0000000..3945dd5 --- /dev/null +++ b/scripts/cofrat.py @@ -0,0 +1,283 @@ +import streamlit as st +import pandas as pd +from datetime import datetime + +# --- Configuração da Página --- +st.set_page_config( + page_title="Dashboard de Agendamentos", + page_icon="🗓️", + layout="wide" +) + +# --- CSS Customizado para Estética Aprimorada --- +st.markdown(""" + +""", unsafe_allow_html=True) + + +# --- Dados de Amostra --- +data = { + 'Horário': ['08:00', '08:30', '09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '12:30'], + 'Paciente': ['Maria Silva', 'João Santos', 'Ana Costa', 'Pedro Oliveira', 'Carla Mendes', 'Lucas Pereira', 'Fernanda Lima', 'Ricardo Alves', 'Beatriz Souza', 'Tiago Martins'], + 'Contato': ['+55 11 98765-4321', '+55 11 98765-4322', '+55 11 98765-4323', '+55 11 98765-4324', '+55 11 98765-4325', '+55 11 98765-4326', '+55 11 98765-4327', '+55 11 98765-4328', '+55 11 98765-4329', '+55 11 98765-4330'], + 'Tipo': ['Fisioterapia', 'Ortopedia', 'Fisioterapia', 'Terapia', 'Ortopedia', 'Fisioterapia', 'Terapia', 'Fisioterapia', 'Terapia', 'Ortopedia'], + 'Terapeuta': ['Dr. Carlos Mendes', 'Dra. Ana Paula', 'Dr. Carlos Mendes', 'Dr. Roberto Lima', 'Dra. Ana Paula', 'Dr. Carlos Mendes', 'Dr. Roberto Lima', 'Dr. Carlos Mendes', 'Dr. Roberto Lima', 'Dra. Ana Paula'], + 'Sala': ['Sala 1', 'Sala 2', 'Sala 1', 'Sala 3', 'Sala 2', 'Sala 1', 'Sala 3', 'Sala 1', 'Sala 3', 'Sala 2'], + 'Status': ['Confirmado', 'Cancelado', 'Confirmado', 'Aguardando', 'Em Atendimento', 'Confirmado', 'Cancelado', 'Confirmado', 'Aguardando', 'Concluído'] +} +df = pd.DataFrame(data) + +# --- CABEÇALHO --- +st.markdown(""" +
+
+

Agendamentos do Dia

+

Sábado, 25 de Outubro de 2025

+
+
+ +
+
+""", unsafe_allow_html=True) + +# Colocando os botões do cabeçalho em colunas para alinhá-los à direita +_, btn_col = st.columns([0.8, 0.2]) +with btn_col: + btn1, btn2 = st.columns(2) + btn1.button("Ver Disparos", key="ver_disparos", use_container_width=True) + btn2.button("🔄 Atualizar", type="primary", key="atualizar", use_container_width=True) + + +# --- CARTÕES DE RESUMO --- +status_counts = df['Status'].value_counts() +confirmados = status_counts.get('Confirmado', 0) +cancelados = status_counts.get('Cancelado', 0) +aguardando = status_counts.get('Aguardando', 0) +em_atendimento = status_counts.get('Em Atendimento', 0) +concluidos = status_counts.get('Concluído', 0) + +cols = st.columns(5) +with cols[0]: + st.markdown(f'

Confirmados

✅ {confirmados}

', unsafe_allow_html=True) +with cols[1]: + st.markdown(f'

Cancelados

❌ {cancelados}

', unsafe_allow_html=True) +with cols[2]: + st.markdown(f'

Aguardando

🕒 {aguardando}

', unsafe_allow_html=True) +with cols[3]: + st.markdown(f'

Em Atendimento

➡️ {em_atendimento}

', unsafe_allow_html=True) +with cols[4]: + st.markdown(f'

Concluídos

✔️ {concluidos}

', unsafe_allow_html=True) + +st.write("") # Espaçamento + +# --- FILTROS --- +with st.container(): + st.markdown('
', unsafe_allow_html=True) + st.write("
🇾 Filtros
", unsafe_allow_html=True) + + f_col1, f_col2, f_col3, f_col4 = st.columns([2, 1, 1, 1]) + with f_col1: + busca = st.text_input("Buscar", placeholder="Nome, telefone ou terapeuta...", label_visibility="collapsed") + with f_col2: + status_filter = st.selectbox("Status", ["Todos"] + list(df['Status'].unique()), label_visibility="collapsed") + with f_col3: + tipo_sessao_filter = st.selectbox("Tipo de Sessão", ["Todos"] + list(df['Tipo'].unique()), label_visibility="collapsed") + with f_col4: + terapeuta_filter = st.selectbox("Terapeuta", ["Todos"] + list(df['Terapeuta'].unique()), label_visibility="collapsed") + st.markdown('
', unsafe_allow_html=True) + +# --- LÓGICA DE FILTRAGEM --- +df_filtrado = df.copy() +if busca: + df_filtrado = df_filtrado[df_filtrado.apply(lambda row: busca.lower() in str(row).lower(), axis=1)] +if status_filter != "Todos": + df_filtrado = df_filtrado[df_filtrado['Status'] == status_filter] +if tipo_sessao_filter != "Todos": + df_filtrado = df_filtrado[df_filtrado['Tipo'] == tipo_sessao_filter] +if terapeuta_filter != "Todos": + df_filtrado = df_filtrado[df_filtrado['Terapeuta'] == terapeuta_filter] + +# --- LISTA DE AGENDAMENTOS --- +st.header(f"Lista de Agendamentos ({len(df_filtrado)} de {len(df)})") +st.caption("Todos os agendamentos programados para hoje") + +# Cabeçalho da lista customizado +header_cols = st.columns([0.8, 1.5, 1.5, 1, 1.5, 0.8, 1, 1.5]) +headers = ['Horário', 'Paciente', 'Contato', 'Tipo', 'Terapeuta', 'Sala', 'Status', 'Ações'] +for col, header in zip(header_cols, headers): + col.markdown(f'
{header}
', unsafe_allow_html=True) + +# Função para criar o badge de status +def status_to_html(status): + status_class = status.lower().replace(" ", "-").replace("í", "i").replace("ç", "c") + return f'
{status}
' + +# Exibindo os dados em cards +if df_filtrado.empty: + st.info("Nenhum agendamento encontrado com os filtros selecionados.") +else: + for index, row in df_filtrado.iterrows(): + with st.container(): + st.markdown('
', unsafe_allow_html=True) + cols = st.columns([0.8, 1.5, 1.5, 1, 1.5, 0.8, 1, 1.5]) + cols[0].markdown(f"🕒 {row['Horário']}") + cols[1].markdown(f"**{row['Paciente']}**") + cols[2].text(row['Contato']) + cols[3].text(row['Tipo']) + cols[4].text(f"👨‍⚕️ {row['Terapeuta']}") + cols[5].text(row['Sala']) + cols[6].markdown(status_to_html(row['Status']), unsafe_allow_html=True) + + with cols[7]: + st.markdown('
', unsafe_allow_html=True) + action_cols = st.columns([1, 1]) + if row['Status'] == 'Cancelado': + action_cols[0].button("Repor", key=f"repor_{index}", help="Repor este agendamento") + action_cols[1].button("Ver Disparo", key=f"disparo_{index}", help="Ver detalhes do disparo de mensagem") + st.markdown('
', unsafe_allow_html=True) + + st.markdown('
', unsafe_allow_html=True) \ No newline at end of file diff --git a/scripts/del.py b/scripts/del.py new file mode 100644 index 0000000..4500107 --- /dev/null +++ b/scripts/del.py @@ -0,0 +1,34 @@ +import requests + +# Dados da API +url = 'https://graph.facebook.com/v22.0/801077859762875/messages' # Substitua pelo seu phone_number_id +access_token = 'EAAqlbxdDZBOgBPz9JCyf7WdcEV9dnr1iqoAlTNVhPMbcIFJpNvaTpXHHFoFPCPC1jUqV1ZAz2bgNVsLpswpkyvaOH7f7CVT0soSSIYOSeNCLfefku07ucjj6exltjuVdqnXbPbw5R9QCXpeFfUkOThLT9nbBM8smLcXADS36lapTfeL3zlqY45U2YpjAZDZD' # Substitua pelo seu token de acesso + +# Corpo da requisição +payload = { + "messaging_product": "whatsapp", + "to": "5511959044561", # Número do destinatário com código do país + "type": "text", + "text": { + "body": + """ +Olá, Brandon! Se você recebeu essa mensagem, então funcionou. + """ + } +} + +# Cabeçalhos +headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" +} + +# Envio da requisição +response = requests.post(url, json=payload, headers=headers) + +# Resultado +if response.status_code == 200: + print("Mensagem enviada com sucesso!") +else: + print(f"Erro ao enviar mensagem: {response.status_code}") + print(response.text) diff --git a/scripts/mm_lite.py b/scripts/mm_lite.py new file mode 100644 index 0000000..3c39c0e --- /dev/null +++ b/scripts/mm_lite.py @@ -0,0 +1,25 @@ +import requests + +# Substitua pelos seus dados reais +waba_id = '1345055953751400' +access_token = 'EAAqlbxdDZBOgBP3TFNaaNgI19DwHRN6Lj4h4oY2DEkLZAvznGisfdAZARrw7MYh49onwZAp1hZCvJ8ZBtaxK8xuShae06zFHCfR7ZCLqeQyIH1VWVpwXYW4yJmlNQnKYNxVvfxAtBpZAWh9xgRrsszKnMjZBRCmF9KuZBMkrRxaW2d5YEfnvjarQ6NVN2RZAHM4H7itiYaI7imoUrhFjzpVkUaQLZC0JzqPFCtlxFqqv2fOIbAZDZD' # Seu token de acesso + +# URL da requisição +url = f'https://graph.facebook.com/v24.0/{waba_id}?fields=marketing_messages_onboarding_status' + +# Cabeçalhos +headers = { + 'Authorization': f'Bearer {access_token}' +} + +# Envio da requisição +response = requests.get(url, headers=headers) + +# Resultado +if response.status_code == 200: + data = response.json() + status = data.get('marketing_messages_onboarding_status') + print(f"Status de elegibilidade: {status}") +else: + print(f"Erro ao consultar elegibilidade: {response.status_code}") + print(response.text) diff --git a/scripts/trigger_webhook.py b/scripts/trigger_webhook.py new file mode 100644 index 0000000..3b8d434 --- /dev/null +++ b/scripts/trigger_webhook.py @@ -0,0 +1,13 @@ +import requests + +url = "https://dev-chatwoot.vldzc8.easypanel.host/api/v1/accounts/2/conversations/10/labels" + +payload = { "labels": ["label 1", "label 2"] } +headers = { + "api_access_token": "etC1hQDDNdkp2n9nDnoHLJob", + "Content-Type": "application/json" +} + +response = requests.post(url, json=payload, headers=headers) + +print(response.json()) \ No newline at end of file diff --git a/sketches/planning.excalidraw b/sketches/planning.excalidraw new file mode 100644 index 0000000..9f06bc3 --- /dev/null +++ b/sketches/planning.excalidraw @@ -0,0 +1,13 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor", + "elements": [], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/sketches/whiteboard.dio b/sketches/whiteboard.dio new file mode 100644 index 0000000..e69de29 diff --git a/style.css b/style.css new file mode 100644 index 0000000..0a2a25d --- /dev/null +++ b/style.css @@ -0,0 +1,348 @@ +/* style.css */ + +/* === ESTILOS GERAIS E DO DASHBOARD === */ +body { + background-color: #F9FAFB; +} +div.block-container { + background-color: transparent; + padding-top: 4rem; +} +div[data-testid="metric-container"] { + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 10px; + padding: 15px; + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + transition: box-shadow 0.3s ease-in-out; +} +div[data-testid="metric-container"]:hover { + box-shadow: 0 8px 16px rgba(0,0,0,0.2); +} +.custom-title-container { + display: flex; + align-items: center; +} +.custom-title h1 { + color: #000000; + margin-bottom: 0px; + font-size: 30px; + font-weight: bold; +} +.custom-title h3 { + color: #000000; + margin-bottom: 0px; + font-size: 23px; + font-weight: bold; +} +.custom-title p { + color: #5a5a5a; + margin-top: 0px; + font-size: 17px; +} + +/* === ESTILOS PARA O FORMULÁRIO DE LOGIN === */ +.login-container { + background-color: #ffffff; + padding: 35px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); + border: 1px solid #e0e0e0; +} +.login-header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 25px; +} +.login-header .bi-lock { + font-size: 20px; + color: #0b9035; +} +h2.login-title { + font-weight: bold; + font-size: 22px; + margin: 0; +} +.login-subtitle { + font-size: 14px; + color: #5a5a5a; + margin: -20px; +} +.input-label { + font-size: 14px; + font-weight: 500; + color: #333740; + margin-bottom: -10px; +} +div[data-testid="stTextInput"] { + margin-bottom: 15px; +} +.login-container .stButton>button { + background-color: #212529; + color: white; + border: none; + padding: 12px 0; + font-size: 16px; + font-weight: bold; + margin-top: 15px; +} +.login-container .stButton>button:hover { + background-color: #4b505a; + color: white; +} + +/* === ESTILOS PARA OS CARDS DE MÉTRICAS DO DASHBOARD === */ +.metric-card { + background-color: #FFFFFF; + border: 1px solid #f0f2f6; + border-radius: 10px; + padding: 20px; + box-shadow: 0 4px 8px rgba(0,0,0,0.05); + height: 150px; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.metric-card-label { font-size: 17px; color: #5a5a5a; } +.metric-card-value { font-size: 42px; font-weight: bold; color: #212529; line-height: 1; } +.metric-card-delta { font-size: 15px; } +.delta-positive { color: #28a745; } +.delta-negative { color: #dc3545; } +.delta-neutral { color: #6c757d; } + +/* === ESTILOS PARA OS CARDS DE RESUMO === */ +.summary-card { + background-color: #FFFFFF; + border: 1px solid #f0f2f6; + border-radius: 10px; + padding: 20px; + box-shadow: 0 4px 8px rgba(0,0,0,0.04); + height: 100px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} +.summary-card-value { font-size: 38px; font-weight: bold; color: #212529; line-height: 1.2; } +.summary-card-label { font-size: 16px; color: #5a5a5a; } + +/* === ESTILOS PARA A PÁGINA DE APROVAÇÃO === */ +div[data-testid="stProgressBar"] > div { + background-color: #294960; +} +.progress-label { + text-align: right; + margin-top: -35px; + color: #5a5a5a; +} +.approval-card { + background-color: #FFFFFF; + border: 1px solid #E0E0E0; + border-radius: 12px; + padding: 25px; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + margin-top: 20px; +} +.approval-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + border-bottom: 1px solid #f0f2f6; + padding-bottom: 20px; + margin-bottom: 10px; +} +.patient-info { + display: flex; + align-items: center; + gap: 15px; +} +.patient-avatar { + width: 50px; + height: 50px; + background-color: #0b9035; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + font-weight: bold; +} +.patient-name { font-size: 20px; font-weight: bold; color: #212529; } +.patient-phone { font-size: 17px; color: #5a5a5a; } +.patient-phone .bi { margin-right: 5px; } +.specialty-tag { + background-color: #28a745; /* Fundo verde forte (consistente com os botões) */ + color: #FFFFFF; /* Cor da fonte branca para alto contraste */ + padding: 6px 14px; /* Aumenta um pouco o espaçamento interno */ + border-radius: 15px; + font-size: 14px; /* Tamanho da fonte aumentado */ + font-weight: 600; /* Fonte um pouco mais grossa para melhor leitura */ +} +.details-grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 15px 25px; + padding: 10px 0; +} +.detail-item { + display: flex; + align-items: center; + gap: 15px; +} +.detail-item .bi { font-size: 20px; color: #0b9035; } +.detail-label { font-size: 14px; color: #5a5a5a; } +.detail-value { font-size: 16px; color: #212529; font-weight: 500; } +.observations-section { + margin-top: 15px; + padding-top: 20px; + border-top: 1px solid #f0f2f6; +} + +/* --- ESTILO ESPECÍFICO PARA OS BOTÕES DE AÇÃO (APROVAR, ETC.) --- */ +.action-buttons-container div[data-testid="stButton"] > button { + border-radius: 8px; + padding: 8px 0; + font-weight: 600; + transition: all 0.2s; + font-size: 15px; +} +.action-buttons-container div:nth-of-type(1) div[data-testid="stButton"] > button { + background-color: #28a745; + color: white; + border: 1px solid #28a745; +} +.action-buttons-container div:nth-of-type(1) div[data-testid="stButton"] > button:hover { + background-color: #218838; + border-color: #1e7e34; +} +.action-buttons-container div:nth-of-type(2) div[data-testid="stButton"] > button { + background-color: #FFFFFF; + color: #344054; + border: 1px solid #D0D5DD; +} +.action-buttons-container div:nth-of-type(2) div[data-testid="stButton"] > button:hover { + background-color: #F9FAFB; + border-color: #BCC2CC; +} +.action-buttons-container div:nth-of-type(3) div[data-testid="stButton"] > button { + background-color: #FFFFFF; + color: #dc3545; + border: 1px solid #dc3545; +} +.action-buttons-container div:nth-of-type(3) div[data-testid="stButton"] > button:hover { + background-color: #dc3545; + color: white; +} + +/* --- ESTILO ESPECÍFICO PARA O BOTÃO DE LOGOUT NA SIDEBAR --- */ +[data-testid="stSidebar"] .stButton > button { + background-color: #FFFFFF; + color: #344054; + border: 1px solid #D0D5DD; + border-radius: 8px; + font-weight: 600; +} +[data-testid="stSidebar"] .stButton > button:hover { + background-color: #F9FAFB; + border-color: #BCC2CC; +} + +/* === ESTILOS PARA A TELA DE CONCLUSÃO === */ +.completion-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + margin-top: 8rem; +} +.completion-icon { + width: 72px; + height: 72px; + border-radius: 50%; + background-color: #E3FCEF; + color: #198754; + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + font-weight: bold; + border: 6px solid #F2FCF7; + margin-bottom: 24px; +} +.completion-title { + font-size: 28px; + font-weight: bold; + color: #212529; + margin-bottom: 8px; +} +.completion-subtitle { + font-size: 16px; + color: #5a5a5a; + margin-bottom: 32px; +} +.completion-container div[data-testid="stButton"] button { + background-color: #294960; + color: white; + border: none; + border-radius: 8px; + padding: 10px 0; + font-weight: 600; +} +.completion-container div[data-testid="stButton"] button:hover { + background-color: #345c79; + color: white; +} + +/* === (NOVO) ESTILO PARA A TABELA DA AGENDA DO DIA (BASEADO NO CARD DE APROVAÇÃO) === */ + +/* Container que envolve a tabela, aplicando o estilo de card */ +.agenda-table-container div[data-testid="stDataFrame"] { + background-color: #FFFFFF; + border: 1px solid #f0f2f6; + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0,0,0,0.04); + overflow: hidden; /* Garante que o conteúdo respeite a borda arredondada */ +} + +/* Remove a borda padrão do componente para usar a do nosso container */ +.agenda-table-container div[data-testid="stDataFrame"] > div > div { + border: none !important; +} + +/* Cabeçalho da tabela (Nomes das colunas) */ +.agenda-table-container .col_heading { + background-color: #FFFFFF !important; + color: #5a5a5a; /* Cor cinza para os rótulos, como no card */ + font-size: 13px; + font-weight: 500; /* Peso mais leve */ + text-transform: uppercase; /* Transforma em maiúsculas */ + letter-spacing: 0.5px; /* Espaçamento entre letras */ + padding: 16px 12px; + border-bottom: 1px solid #f0f2f6 !important; /* Linha divisória sutil */ +} + +/* Células de dados da tabela */ +.agenda-table-container .cell { + background-color: #FFFFFF !important; + color: #212529; + font-size: 15px; + font-weight: 500; /* Texto dos dados com mais destaque */ + padding: 16px 12px; + border-bottom: 1px solid #f0f2f6 !important; + vertical-align: middle; +} + +/* Efeito de hover nas linhas */ +.agenda-table-container .row:hover .cell { + background-color: #F9FAFB !important; /* Fundo cinza bem claro ao passar o mouse */ +} + +/* Remove a borda da última linha para um visual mais limpo */ +.agenda-table-container .row:last-child .cell { + border-bottom: none !important; +} + diff --git a/utils.py b/utils.py index f5ca43d..0591b5d 100644 --- a/utils.py +++ b/utils.py @@ -1,26 +1,640 @@ -# Copyright 2018-2022 Streamlit Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - +# utils.py import streamlit as st -import inspect -import textwrap - -def show_code(demo): - """Showing the code of the demo.""" - show_code = st.sidebar.checkbox("Show code", True) - if show_code: - # Showing the code of the demo. - st.markdown("## Code") - sourcelines, _ = inspect.getsourcelines(demo) - st.code(textwrap.dedent("".join(sourcelines[1:]))) \ No newline at end of file +import streamlit_antd_components as sac +import pandas as pd +import requests +import base64 +import csv +import re +import io + +# --- FUNÇÃO PARA CARREGAR IMAGEM --- +def load_image_as_base64(image_path): + """Carrega imagem local e converte para base64.""" + try: + with open(image_path, "rb") as f: + data = f.read() + return base64.b64encode(data).decode() + except: + return None + +# --- FUNÇÃO PARA EXIBIR LOGO NO CORPO PRINCIPAL (PARA LOGIN) --- +def display_logo(logo_path, width=200, use_column=True): + """Exibe o logo da aplicação no corpo principal.""" + img_base64 = load_image_as_base64(logo_path) + + if use_column: + _, logo_col, _ = st.columns([1.4, 1, 1]) + with logo_col: + if img_base64: + st.markdown( + f'', + unsafe_allow_html=True + ) + else: + st.warning("Logo não encontrado") + else: + if img_base64: + st.markdown( + f'', + unsafe_allow_html=True + ) + else: + st.warning("Logo não encontrado") + +# --- FUNÇÃO DE LOGIN --- +def login_form(logo_path): + """Exibe o logotipo e o formulário de login centralizado.""" + st.markdown('', unsafe_allow_html=True) + + display_logo(logo_path, width=200, use_column=True) + st.write("\n") + + _, col, _ = st.columns([1, 2, 1]) + with col: + with st.form("login_form_styled"): + st.markdown(""" +
+ +
+ + +
+
+ """, unsafe_allow_html=True) + st.write("\n"*3) + st.markdown('

Email

', unsafe_allow_html=True) + username = st.text_input("Email", placeholder="seu@email.com", label_visibility="collapsed") + st.markdown('

Senha

', unsafe_allow_html=True) + password = st.text_input("Senha", placeholder="Sua senha", type="password", label_visibility="collapsed") + + if st.form_submit_button("Entrar", use_container_width=True): + try: + correct_usernames = st.secrets["credentials"]["usernames"] + correct_passwords = st.secrets["credentials"]["passwords"] + credentials_dict = dict(zip(correct_usernames, correct_passwords)) + if username in credentials_dict and credentials_dict[username] == password: + st.session_state["authentication_status"] = True + st.session_state["username"] = username + st.rerun() + else: + st.session_state["authentication_status"] = False + st.error("Usuário ou senha incorretos.") + except Exception: + st.error("Arquivo de segredos (secrets.toml) não encontrado ou mal configurado.") + +# --- PÁGINA DE CONFIRMAÇÃO DE AGENDAMENTOS (CONTEÚDO COMPLETO) --- +def confirmation_page(): + # As funções de ajuda do Colab foram movidas para cá + def adjust_phone_number(phone_number): + """Ajusta o número de telefone para o formato com código do país (55) e DDD.""" + digits_only = re.sub(r'\D', '', str(phone_number)) + if len(digits_only) > 10: + if not digits_only.startswith('55'): + return '55' + digits_only + return digits_only + elif len(digits_only) == 10: + return '55' + digits_only + elif len(digits_only) >= 8: + return '5511' + digits_only + else: + return '' # Retorna vazio se o número for inválido + + def adjust_time(time_str): + """Ajusta a string de horário para o formato HH:MM e arredonda para baixo a cada 10 minutos.""" + if pd.isna(time_str): + return time_str + try: + time_obj = pd.to_datetime(time_str, format='%H:%M').time() + full_datetime = pd.to_datetime(f"1970-01-01 {time_obj}") + rounded_time = full_datetime.floor('10min') + return rounded_time.strftime('%H:%M') + except (ValueError, TypeError): + return time_str + + def process_and_clean_csv(uploaded_file, file_type): + """ + Lê um arquivo CSV, processa os dados com base no TIPO DE ARQUIVO, calcula estatísticas, + separa registros bons, ruins e repetidos (por nome), e retorna DataFrames e estatísticas. + """ + file_content = uploaded_file.getvalue().decode('latin1') + csv_file = io.StringIO(file_content) + + columns =[ + 'Data', 'Especialidade', 'Hora', 'Medico', 'Convenio', + 'Evento', 'Paciente', 'Telefone', 'Prontuario' + ] + processed_rows =[] + time_regex = re.compile(r'^\d{2}:\d{2}$') + + reader = csv.reader(csv_file) + + # --- LÓGICA DE EXTRAÇÃO BASEADA NO TIPO DE ARQUIVO --- + + if file_type in ["Confirmação de Consultas", "Cancelamento de Consultas"]: + # Lógica Original para Consultas + current_date = None + current_specialty = None + + for row in reader: + if not row: continue + if row[0] == 'Data:' and 'Especialidade: ' in row: + current_date, current_specialty = row[1], row[3] + try: + start_index = next(i for i, field in enumerate(row) if time_regex.match(field)) + data_fields = row[start_index:] + # Estrutura Consultas: Data, Esp, Hora, Medico, Convenio, Evento, Paciente, Tel, Pront + processed_rows.append([current_date, current_specialty] + data_fields[:7]) + except StopIteration: continue + elif 'Página :' in row[0]: + try: + start_index = next(i for i, field in enumerate(row) if time_regex.match(field)) + data_fields = row[start_index:] + processed_rows.append([current_date, current_specialty] + data_fields[:7]) + except StopIteration: continue + + elif file_type in ["Confirmação de Acupuntura", "Confirmação de RPG", "Cancelamento de Acupuntura"]: + # Nova Lógica para Acupuntura e RPG (Layout de Serviços) + current_date = None + current_specialty = None + current_doctor = None + + for row in reader: + if not row: continue + + # 1. Captura de Contexto: Médico e Especialidade + if 'Médico:' in row: + try: + if 'Agenda de:' in row: + spec_index = row.index('Agenda de:') + 1 + current_specialty = row[spec_index] + + doc_index = row.index('Médico:') + 1 + current_doctor = row[doc_index] + except (ValueError, IndexError): + pass + + # 2. Captura de Contexto: Data + if len(row) > 1 and row[0] == 'Data:': + current_date = row[1] + + # 3. Extração dos Dados do Paciente + try: + start_index = next(i for i, field in enumerate(row) if time_regex.match(field)) + data_fields = row[start_index:] + + # Mapeamento Acupuntura/RPG: + # [0] Hora, [2] Paciente, [4] Evento, [5] Fone, [6] Pront, [7] Convenio + if len(data_fields) >= 8: + new_row =[ + current_date, # Data + current_specialty, # Especialidade + data_fields[0], # Hora + current_doctor, # Medico (do cabeçalho) + data_fields[7], # Convenio + data_fields[4], # Evento + data_fields[2], # Paciente + data_fields[5], # Telefone + data_fields[6] # Prontuario + ] + processed_rows.append(new_row) + except StopIteration: + continue + + # --- FIM DA LÓGICA DE EXTRAÇÃO --- + + df = pd.DataFrame(processed_rows, columns=columns) + if df.empty: + return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), {} + + # --- TRATAMENTO DE DADOS (COMUM A TODOS) --- + df['Número de Telefone Ajustado'] = df['Telefone'].apply(adjust_phone_number) + df['Horario'] = df['Hora'].apply(adjust_time) + + # Converte data para string formatada (garante consistência) + df['Data'] = pd.to_datetime(df['Data'], dayfirst=True, errors='coerce').dt.strftime('%d/%m/%Y') + + df.columns = df.columns.str.strip().str.lower().str.replace(' ', '_').str.replace('ú', 'u').str.replace('é', 'e').str.replace('ç', 'c').str.replace('ã', 'a') + df.rename(columns={ + 'paciente': 'nome_do_paciente', 'medico': 'nome_do_medico', + 'horario': 'horario_ajustado', 'numero_de_telefone_ajustado': 'telefone_ajustado' + }, inplace=True) + + # Estatísticas gerais + total_records = len(df) + phone_counts = df['telefone_ajustado'].value_counts() + unique_appointments = len(phone_counts[phone_counts.index != '']) + + patient_name_counts = df['nome_do_paciente'].value_counts() + repeated_appointments = len(patient_name_counts[patient_name_counts > 1]) + + # Critérios e contagem de qualidade de dados + is_phone_empty = df['telefone_ajustado'] == '' + is_phone_length_wrong = (~is_phone_empty) & (df['telefone_ajustado'].str.len() != 13) + + bad_data_mask = is_phone_empty | is_phone_length_wrong + + stats = { + 'total': total_records, + 'unique': unique_appointments, + 'repeated': repeated_appointments, + 'bad_total': bad_data_mask.sum(), + 'bad_empty': is_phone_empty.sum(), + 'bad_length': is_phone_length_wrong.sum() + } + + df_bad = df[bad_data_mask] + df_good = df[~bad_data_mask] + + # Adicionar registros estáticos ao DataFrame de dados bons + static_data =[ + {'data': '15/02/2026', 'horario_ajustado': '13:10', 'nome_do_paciente': 'BRANDON AGUIAR', 'nome_do_medico': 'LEANDRO TETSUO OKAMURA', 'telefone': '(11) 95904 4561', 'telefone_ajustado': '5511959044561'}, + {'data': '20/03/2026', 'horario_ajustado': '08:40', 'nome_do_paciente': 'KARINE COFRAT', 'nome_do_medico': 'LEANDRO TETSUO OKAMURA', 'telefone': '(11) 97140-2433', 'telefone_ajustado': '5511971402433'} + ] + df_static = pd.DataFrame(static_data) + df_good = pd.concat([df_static, df_good], ignore_index=True) + + # Identificar pacientes com múltiplos agendamentos pelo NOME + good_name_counts = df_good['nome_do_paciente'].value_counts() + repeated_names = good_name_counts[good_name_counts > 1].index + df_repeated = df_good[df_good['nome_do_paciente'].isin(repeated_names)].sort_values(by=['nome_do_paciente', 'data', 'horario_ajustado']) + + final_columns_order =[ + 'data', 'horario_ajustado', 'nome_do_paciente', + 'nome_do_medico', 'telefone', 'telefone_ajustado' + ] + + # Garante que todos os dataframes tenham as colunas na ordem correta + available_cols =[c for c in final_columns_order if c in df_good.columns] + + df_good_final = df_good[available_cols] + df_bad_final = df_bad[available_cols] if not df_bad.empty else pd.DataFrame(columns=available_cols) + df_repeated_final = df_repeated[available_cols] if not df_repeated.empty else pd.DataFrame(columns=available_cols) + + return df_good_final, df_bad_final, df_repeated_final, stats + + def process_and_clean_autorizacao(uploaded_file): + """ + Lê um arquivo Excel, processa os dados para 'Autorização liberada'. + Aplica a transformação de telefone, padronização de terapia e + extração do primeiro nome do paciente (Capitalizado). + Adiciona registros estáticos de teste no topo da lista. + """ + try: + # Lê o arquivo Excel + df = pd.read_excel(uploaded_file) + except Exception as e: + st.error(f"Erro ao ler o arquivo Excel: {e}") + return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), {} + + # Tenta identificar a coluna de nome do paciente dinamicamente + name_col = None + for col in df.columns: + if str(col).strip().upper() in['PACIENTE', 'NOME', 'NOME DO PACIENTE', 'NOME_DO_PACIENTE']: + name_col = col + break + + # Verifica se as colunas esperadas existem no arquivo + if 'TELEFONE' not in df.columns or 'TERAPIA ' not in df.columns or not name_col: + st.error("O arquivo Excel não contém as colunas 'TELEFONE', 'TERAPIA ' e/ou a coluna de paciente ('PACIENTE' ou 'NOME').") + return pd.DataFrame(), pd.DataFrame(), pd.DataFrame(), {} + + # ===================================================================== + # TRANSFORMAÇÃO MANUAL (CLEAN & TRANSFORM) + # ===================================================================== + + # Isola as colunas de interesse e remove valores nulos + df_reduzido = df[['TELEFONE', 'TERAPIA ', name_col]].copy() + df_reduzido = df_reduzido.dropna(subset=['TELEFONE', 'TERAPIA ', name_col]) + + # Função de formatação de telefone adaptada para o pipeline + def format_phone_number(phone_number): + if pd.isna(phone_number) or str(phone_number).strip() == '': + return '' + phone_str = str(phone_number).strip() + if phone_str.endswith('.0'): + phone_str = phone_str[:-2] + cleaned_number = phone_str.replace(' ', '').replace('-', '').replace('(', '').replace(')', '') + # Remove o prefixo + se existir para facilitar a análise + prefix = '' + if cleaned_number.startswith('+'): + prefix = '+' + cleaned_number = cleaned_number[1:] + # Remove o prefixo 55 se existir para facilitar a análise + if cleaned_number.startswith('55'): + cleaned_number = cleaned_number[2:] + prefix = '+55' + # Verifica se já tem DDD válido (11 ou 19) + if cleaned_number.startswith('11') or cleaned_number.startswith('19'): + return prefix + '55' + cleaned_number + # Se não tem DDD, adiciona 11p + if len(cleaned_number) == 9: # Ex: 959044561 + return prefix + '5511' + cleaned_number + # Se tem outro formato, retorna com +55 + return prefix + '55' + cleaned_number + + # Função de padronização do nome da terapia + def standardize_therapy_name(therapy_name): + if pd.isna(therapy_name) or str(therapy_name).strip() == '': + return '' + + # CORREÇÃO: Remove os dois pontos (:) e espaços extras antes de validar + therapy_name = str(therapy_name).replace(':', '').strip().upper() + + if therapy_name == 'ACUPUNTURA': + return 'Acupuntura' + elif therapy_name == 'FISIO/ACUP': + return 'Fisioterapia, Acupuntura' + elif therapy_name == 'FISIOTERAPIA': + return 'Fisioterapia' + else: + return therapy_name.title() + + # NOVA FUNÇÃO: Extrair e formatar o nome completo (capitalizado) + def format_full_name(full_name): + if pd.isna(full_name) or str(full_name).strip() == '': + return '' + # Remove espaços extras, transforma em minúsculo e capitaliza cada parte + name_parts = str(full_name).strip().split() + return ' '.join([part.capitalize() for part in name_parts]) + + # Aplica as transformações criando as colunas que o sistema espera + df_reduzido['telefone'] = df_reduzido['TELEFONE'] + df_reduzido['telefone_ajustado'] = df_reduzido['TELEFONE'].apply(format_phone_number) + df_reduzido['terapia'] = df_reduzido['TERAPIA '].apply(standardize_therapy_name) + df_reduzido['nome_do_paciente'] = df_reduzido[name_col].apply(format_full_name) + + # ===================================================================== + # FIM DA TRANSFORMAÇÃO MANUAL + # ===================================================================== + + # Para garantir que o código não quebre no restante do sistema (UI e Webhook), + # criamos as colunas esperadas preenchidas com vazio caso não existam: + expected_columns =[ + 'nome_do_paciente', + 'telefone_ajustado', 'terapia' + ] + for col in expected_columns: + if col not in df_reduzido.columns: + df_reduzido[col] = '' + + # Estatísticas gerais + total_records = len(df_reduzido) + + # Máscara de dados ruins (telefone vazio ou com tamanho inválido) + is_phone_empty = df_reduzido['telefone_ajustado'] == '' + # Considera ruim se tiver menos de 13 caracteres (ex: +551199999999 tem 14 caracteres) + is_phone_length_wrong = (~is_phone_empty) & (df_reduzido['telefone_ajustado'].str.len() < 13) + + bad_data_mask = is_phone_empty | is_phone_length_wrong + + df_bad = df_reduzido[bad_data_mask] + df_good = df_reduzido[~bad_data_mask] + + # ===================================================================== + # ADIÇÃO DOS REGISTROS ESTÁTICOS PARA TESTE + # ===================================================================== + static_data =[ + {'telefone': '(11) 95904-4561', 'telefone_ajustado': '+5511959044561', 'terapia': 'Acupuntura', 'nome_do_paciente': 'Brandon'}, + {'telefone': '(11) 97140-2433', 'telefone_ajustado': '+5511971402433', 'terapia': 'Fisioterapia', 'nome_do_paciente': 'Karine'} + ] + df_static = pd.DataFrame(static_data) + + # Preenche as colunas faltantes no df_static com vazio para evitar NaN + for col in expected_columns: + if col not in df_static.columns: + df_static[col] = '' + + # Concatena os dados estáticos no topo dos dados bons + df_good = pd.concat([df_static, df_good], ignore_index=True) + # ===================================================================== + + stats = { + 'total': total_records, + 'unique': len(df_good['telefone_ajustado'].unique()) if not df_good.empty else 0, + 'repeated': 0, # Não há validação de repetidos por nome neste layout + 'bad_total': bad_data_mask.sum(), + 'bad_empty': is_phone_empty.sum(), + 'bad_length': is_phone_length_wrong.sum() + } + + # DataFrame de repetidos (vazio por padrão para este fluxo) + df_repeated = pd.DataFrame(columns=expected_columns) + + # Garantir a ordem das colunas para a interface + available_cols =[c for c in expected_columns if c in df_good.columns] + + df_good_final = df_good[available_cols] + df_bad_final = df_bad[available_cols] if not df_bad.empty else pd.DataFrame(columns=available_cols) + df_repeated_final = df_repeated[available_cols] if not df_repeated.empty else pd.DataFrame(columns=available_cols) + + return df_good_final, df_bad_final, df_repeated_final, stats + + # --- Interface do Streamlit --- + + if 'edited_df' not in st.session_state: + st.session_state.edited_df = None + if 'uploaded_file_name' not in st.session_state: + st.session_state.uploaded_file_name = None + if 'bad_df' not in st.session_state: + st.session_state.bad_df = None + if 'stats' not in st.session_state: + st.session_state.stats = None + if 'repeated_df' not in st.session_state: + st.session_state.repeated_df = None + + st.title("Central de Disparos") + st.caption("Clínica de Ortopedia e Terapia") + st.divider() + + st.subheader("1. Carregar Arquivo de Agendamentos") + + # --- Dropdown atualizado com a nova opção --- + file_type_option = st.selectbox( + "Selecione o tipo de arquivo:", + options=["Confirmação de Consultas", "Confirmação de Acupuntura", "Confirmação de RPG", "Autorização liberada", "Cancelamento de Acupuntura", "Cancelamento de Consultas"], + index=0, + help="Escolha o tipo de arquivo correspondente ao disparo desejado." + ) + # ------------------------------------------------------- + + # --- Uploader atualizado para aceitar Excel --- + uploaded_file = st.file_uploader("Selecione o arquivo (CSV ou Excel)", type=["csv", "xlsx", "xls"], key="csv_uploader") + + if uploaded_file is not None: + # Reseta o estado se mudar o arquivo OU o tipo de arquivo + current_file_key = f"{uploaded_file.name}_{file_type_option}" + + if current_file_key != st.session_state.uploaded_file_name: + st.session_state.edited_df = None + st.session_state.bad_df = None + st.session_state.stats = None + st.session_state.repeated_df = None + st.session_state.uploaded_file_name = current_file_key + + if st.session_state.edited_df is None and st.button("⚙️ Processar Arquivo", use_container_width=True, type="primary"): + with st.spinner("Processando e analisando a qualidade dos dados..."): + try: + # --- Direcionamento para a função correta --- + if file_type_option == "Autorização liberada": + good_df, bad_df, repeated_df, stats = process_and_clean_autorizacao(uploaded_file) + else: + good_df, bad_df, repeated_df, stats = process_and_clean_csv(uploaded_file, file_type_option) + + if good_df.empty and bad_df.empty: + st.warning("Nenhum dado foi encontrado. Verifique se selecionou o 'Tipo de Arquivo' correto.") + else: + good_df.insert(0, 'Selecionar', False) + st.session_state.edited_df = good_df + st.session_state.bad_df = bad_df + st.session_state.repeated_df = repeated_df + st.session_state.stats = stats + st.success("Arquivo processado!") + st.rerun() + except Exception as e: + st.error(f"Erro ao processar o arquivo: {e}") + + if st.session_state.edited_df is not None: + st.header("Visão Geral dos Agendamentos") + col1, col2, col3 = st.columns(3) + col1.metric("Total de Registros Carregados", st.session_state.stats.get('total', 0)) + col2.metric("Pacientes Únicos (por telefone)", st.session_state.stats.get('unique', 0)) + col3.metric("Pacientes com Múltiplos Agendamentos", st.session_state.stats.get('repeated', 0)) + + st.header("Agendamentos Válidos para Envio") + st.subheader("2. Selecione os Pacientes") + + + def toggle_all(): + new_value = st.session_state.select_all_checkbox + df_copy = st.session_state.edited_df.copy() + df_copy['Selecionar'] = new_value + st.session_state.edited_df = df_copy + + all_selected = st.session_state.edited_df['Selecionar'].all() if not st.session_state.edited_df.empty else False + st.checkbox("Selecionar Todos", value=all_selected, on_change=toggle_all, key="select_all_checkbox") + + # Data Editor: edição temporária, não atualiza session state automaticamente + edited_df_output = st.data_editor( + st.session_state.edited_df, + use_container_width=True, + hide_index=True, + disabled=st.session_state.edited_df.columns.drop('Selecionar') + ) + st.write("\n") + # Botão para salvar seleção + if st.button("Salvar seleção de pacientes", use_container_width=True, type="secondary"): + st.session_state.edited_df = edited_df_output + st.success("Seleção salva! Você pode agora enviar mensagens.") + + # --- SEÇÃO: TABELA DE PACIENTES COM MÚLTIPLOS AGENDAMENTOS --- + if st.session_state.repeated_df is not None and not st.session_state.repeated_df.empty: + st.subheader("Pacientes com Múltiplos Agendamentos na Mesma Carga") + st.write("A tabela abaixo destaca os pacientes que possuem mais de um agendamento no arquivo carregado, para facilitar a verificação.") + + # Seleciona colunas relevantes para exibição + display_cols_repeated =['data', 'horario_ajustado', 'nome_do_paciente', 'nome_do_medico', 'telefone_ajustado'] + # Filtra colunas que realmente existem + display_cols_repeated =[c for c in display_cols_repeated if c in st.session_state.repeated_df.columns] + + st.dataframe( + st.session_state.repeated_df[display_cols_repeated], + use_container_width=True, + hide_index=True + ) + # --- FIM DA SEÇÃO --- + + st.divider() + + # Estatísticas de Qualidade e Tabela de Dados Ruins + if st.session_state.bad_df is not None and not st.session_state.bad_df.empty: + st.subheader("Análise de Qualidade dos Dados") + total_records = st.session_state.stats.get('total', 0) + bad_records = st.session_state.stats.get('bad_total', 0) + percentage_bad = (bad_records / total_records) * 100 if total_records > 0 else 0 + + col_dq1, col_dq2 = st.columns(2) + col_dq1.metric("Total de Registros com Problemas", f"{bad_records}") + col_dq2.metric("Percentual de Problemas", f"{percentage_bad:.2f}%") + + col_dq3, col_dq4 = st.columns(2) + col_dq3.metric("Problema: Telefone Nulo/Vazio", f"{st.session_state.stats.get('bad_empty', 0)}") + col_dq4.metric("Problema: Comprimento Inválido", f"{st.session_state.stats.get('bad_length', 0)}") + + st.write("Os registros abaixo foram removidos da lista de envio devido aos problemas de dados identificados.") + st.dataframe(st.session_state.bad_df, use_container_width=True, hide_index=True) + st.divider() + + # --- ALTERAÇÃO AQUI: Removida a seleção de template --- + st.subheader("3. Enviar Mensagens") + st.write(f"O disparo será realizado considerando o tipo: **{file_type_option}**.") + + # Usa o DataFrame do session state (após salvar seleção) + selected_count = int(st.session_state.edited_df['Selecionar'].sum()) + if st.button(f"✉️ Enviar Mensagens ({selected_count})", use_container_width=True, type="primary"): + if selected_count > 0: + selected_rows_df = st.session_state.edited_df[st.session_state.edited_df['Selecionar']].copy().fillna('') + contacts_payload = selected_rows_df.to_dict(orient='records') + final_payload = { + "appointment_type": file_type_option, + "contacts": contacts_payload + } + WEBHOOK_URL = "https://webhook.erudieto.com.br/webhook/disparo-em-massa" + with st.spinner(f"Enviando {len(contacts_payload)} mensagens..."): + try: + response = requests.post(WEBHOOK_URL, json=final_payload, timeout=30) + if 200 <= response.status_code < 300: + st.success(f"✅ Sucesso! Automação acionada para {len(contacts_payload)} contatos.") + else: + st.error(f"❌ Falha ao enviar: {response.status_code} - {response.text}") + except requests.exceptions.RequestException as e: + st.error(f"❌ Erro de conexão: {e}") + else: + st.warning("Nenhum paciente selecionado.") + # ------------------------------------------------------ + +# --- PÁGINA DE AUTOMAÇÕES (CONTEÚDO COMPLETO) --- +def automations_page(): + st.title("Automações") + st.write("Gerencie e acione fluxos de trabalho e automações diretamente desta página.") + st.divider() + + st.subheader("Marcar todas as conversas do Chatwoot como lidas") + if st.button("🚀 Marcar todas como lidas", use_container_width=True): + WEBHOOK_URL_CHATWOOT = "https://webhook.erudieto.com.br/webhook/mark-all-as-read" + with st.spinner("Acionando o fluxo..."): + try: + response = requests.post(WEBHOOK_URL_CHATWOOT, timeout=30) + if 200 <= response.status_code < 300: + st.success("✅ Fluxo acionado com sucesso!") + else: + st.error(f"❌ Falha ao acionar o fluxo: {response.status_code}") + except requests.exceptions.RequestException as e: + st.error(f"❌ Erro de conexão: {e}") + +# --- LÓGICA PRINCIPAL DO APLICATIVO COM MENU LATERAL --- +def main_app(logo_path): + """ + Função principal que renderiza a interface do usuário, + incluindo a barra lateral com logo e menu, e a página selecionada. + """ + with st.sidebar: + # Adiciona o logotipo no topo da barra lateral com o tamanho exato + st.image(logo_path, width=125) + + # Cria o menu de navegação + selected = sac.menu( + items=[ + sac.MenuItem('Confirmação', icon='send-check'), + sac.MenuItem('Automações', icon='lightning-charge'), + ], + color='#08b13c', + open_all=False, + index=0 + ) + + # Renderiza a página selecionada no menu + if selected == 'Confirmação': + confirmation_page() + elif selected == 'Automações': + automations_page() \ No newline at end of file