import dash from dash import dcc, html, Input, Output, dash_table import dash_bootstrap_components as dbc import plotly.express as px import plotly.graph_objects as go import pandas as pd import numpy as np import os import re # --- CONFIGURAÇÕES DE PASTA --- PASTA_CACHE = "dados_cache" if not os.path.exists(PASTA_CACHE): os.makedirs(PASTA_CACHE) # ============================================================================== # 1. MOTORES DE CARGA E LIMPEZA # ============================================================================== def converter_para_float(valor): if pd.isna(valor) or valor == '': return 0.0 if isinstance(valor, (int, float)): return float(valor) try: v = str(valor).replace('R$', '').strip() # Formato brasileiro (ex: 1.234.567,89 ou 1.000,00) if ',' in v and '.' in v and v.rfind(',') > v.rfind('.'): v = v.replace('.', '').replace(',', '.') # Formato americano (ex: 1,234,567.89 ou 1,000.00) elif ',' in v and '.' in v and v.rfind('.') > v.rfind(','): v = v.replace(',', '') # Apenas vírgula (ex: 1000,50 -> BR) ou (1,000,000 -> US) elif ',' in v and '.' not in v: if v.count(',') > 1: # Múltiplas vírgulas = padrão americano v = v.replace(',', '') else: # Uma vírgula = decimal brasileiro v = v.replace(',', '.') # Apenas ponto (ex: 1000.50 -> US) ou (1.000.000 -> BR) elif '.' in v and ',' not in v: if v.count('.') > 1: # Múltiplos pontos = milhares brasileiros v = v.replace('.', '') # Se for só um ponto (ex: 539.72), o Python lê nativamente sem alterar. return float(v) except: return 0.0 def limpar_colunas(df): if df.empty: return df # A mágica aqui: removemos espaços vazios e sublinhados dos cabeçalhos df.columns = [str(c).strip().lower().replace(' ', '').replace('_', '') for c in df.columns] mapa = {'exercicio': 'ano', 'ano_licitacao': 'ano', 'exercicio_contrato': 'ano'} df.rename(columns=mapa, inplace=True) return df def carregar_csv_seguro(nome_arquivo, separador=';'): caminho = os.path.join(PASTA_CACHE, nome_arquivo) if not os.path.exists(caminho): return pd.DataFrame() # Tenta ler com o separador inicial (ponto e vírgula) try: df = pd.read_csv(caminho, sep=separador, encoding='utf-8', on_bad_lines='skip', low_memory=False) if len(df.columns) == 1: # Se não dividiu direito, tenta vírgula df = pd.read_csv(caminho, sep=',', encoding='utf-8', on_bad_lines='skip', low_memory=False) except: df = pd.read_csv(caminho, sep=separador, encoding='latin1', on_bad_lines='skip', low_memory=False) if len(df.columns) == 1: df = pd.read_csv(caminho, sep=',', encoding='latin1', on_bad_lines='skip', low_memory=False) return limpar_colunas(df) def carregar_todos_os_anos(prefixo): arquivos = [f for f in os.listdir(PASTA_CACHE) if f.startswith(prefixo) and f.endswith('.csv')] dfs = [] for f in arquivos: # TENTA PONTO E VÍRGULA PRIMEIRO (Evita quebrar os centavos) try: temp_df = pd.read_csv(os.path.join(PASTA_CACHE, f), sep=';', encoding='utf-8', on_bad_lines='skip') if len(temp_df.columns) == 1: temp_df = pd.read_csv(os.path.join(PASTA_CACHE, f), sep=',', encoding='utf-8', on_bad_lines='skip') except: temp_df = pd.read_csv(os.path.join(PASTA_CACHE, f), sep=';', encoding='latin1', on_bad_lines='skip') if len(temp_df.columns) == 1: temp_df = pd.read_csv(os.path.join(PASTA_CACHE, f), sep=',', encoding='latin1', on_bad_lines='skip') if not temp_df.empty: match = re.search(r'(\d{4})', f) if match: temp_df['ano'] = int(match.group(1)) dfs.append(limpar_colunas(temp_df)) if not dfs: return pd.DataFrame() df_final = pd.concat(dfs, ignore_index=True) cols_financeiras = ['valorarrecadado', 'valorempenhado', 'valorpago'] for col in cols_financeiras: if col in df_final.columns: df_final[col] = df_final[col].apply(converter_para_float) return df_final # --- CARGA GLOBAL --- receitas_df = carregar_todos_os_anos("receitas_") despesas_df = carregar_todos_os_anos("despesas_") restos_df = carregar_todos_os_anos("restos_") licitacoes_df = carregar_csv_seguro("licitacoes.csv") contratos_df = carregar_csv_seguro("contratos.csv") if not contratos_df.empty and 'valortotal' in contratos_df.columns: contratos_df['valortotal'] = contratos_df['valortotal'].apply(converter_para_float) col_est = next((c for c in licitacoes_df.columns if 'estimado' in c), None) col_hom = next((c for c in licitacoes_df.columns if 'homologado' in c), None) col_sit = next((c for c in licitacoes_df.columns if 'situa' in c or 'status' in c), None) col_mod = next((c for c in licitacoes_df.columns if 'modalidade' in c), None) if col_est: licitacoes_df[col_est] = licitacoes_df[col_est].apply(converter_para_float) if col_hom: licitacoes_df[col_hom] = licitacoes_df[col_hom].apply(converter_para_float) # ============================================================================== # 2. OPÇÕES DOS DROPDOWNS E PRÉ-CÁLCULOS # ============================================================================== op_rec = [{'label': 'VISÃO GERAL', 'value': 'TOTAL'}] if not receitas_df.empty and 'contadescricao' in receitas_df.columns: op_rec += [{'label': str(n), 'value': str(n)} for n in sorted(receitas_df['contadescricao'].dropna().unique())] op_desp = [{'label': 'VISÃO GERAL', 'value': 'TOTAL'}] if not despesas_df.empty and 'orgaodescricao' in despesas_df.columns: op_desp += [{'label': str(n), 'value': str(n)} for n in sorted(despesas_df['orgaodescricao'].dropna().unique())] op_forn = [{'label': 'VISÃO GERAL (Top 10 Fornecedores)', 'value': 'TOTAL'}] if not contratos_df.empty and 'fornecedor' in contratos_df.columns: op_forn += [{'label': str(n), 'value': str(n)} for n in sorted(contratos_df['fornecedor'].dropna().unique())] op_mod = [{'label': 'VISÃO GERAL (Todas as Modalidades)', 'value': 'TOTAL'}] if not licitacoes_df.empty and col_mod: op_mod += [{'label': str(n).upper(), 'value': str(n)} for n in sorted(licitacoes_df[col_mod].dropna().astype(str).unique())] def format_currency(val): return f"R$ {val:,.2f}".replace(',','X').replace('.',',').replace('X','.') def extrair_periodo(df): if not df.empty and 'ano' in df.columns: anos = pd.to_numeric(df['ano'], errors='coerce').dropna() if not anos.empty: return f"({int(anos.min())} a {int(anos.max())})" return "(Base Total)" rec_anual = receitas_df.groupby('ano')['valorarrecadado'].sum().reset_index() if not receitas_df.empty else pd.DataFrame() des_anual = despesas_df.groupby('ano')['valorempenhado'].sum().reset_index() if not despesas_df.empty else pd.DataFrame() # ============================================================================== # 3. ESTRUTURA BASE (SIDEBAR) # ============================================================================== app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.FONT_AWESOME]) app.title = "Navegantes 360º" server = app.server SIDEBAR_STYLE = { "position": "fixed", "top": 0, "left": 0, "bottom": 0, "width": "18rem", "padding": "2rem 1rem", "backgroundColor": "#1F2937", "color": "white", "overflowY": "auto" } CONTENT_STYLE = {"marginLeft": "18rem", "padding": "2rem 1rem", "backgroundColor": "#F3F4F6", "minHeight": "100vh"} sidebar = html.Div([ html.Div([ html.H3("⚓ Navegantes 360", className="text-white mb-0"), html.Div(id="periodo-dinamico", style={"fontSize": "0.8rem", "color": "#9CA3AF", "marginTop": "5px"}) ], className="text-center mb-4"), html.Div([ html.Div("SEPAF - Departamento de", style={"fontSize": "0.7rem", "color": "#9CA3AF"}), html.Div("Planejamento e Gestão", style={"fontSize": "0.85rem", "fontWeight": "bold"}) ], style={"backgroundColor": "#111827", "padding": "12px", "borderRadius": "8px", "textAlign": "center", "marginBottom": "2rem"}), dbc.Nav([ dbc.NavLink([html.I(className="fas fa-chart-line me-3"), "Visão Geral"], href="/", active="exact", className="text-white-50"), dbc.NavLink([html.I(className="fas fa-wallet me-3"), "Receitas"], href="/receitas", active="exact", className="text-white-50"), dbc.NavLink([html.I(className="fas fa-file-invoice-dollar me-3"), "Despesas"], href="/despesas", active="exact", className="text-white-50"), html.Hr(style={"color": "#4B5563"}), dbc.NavLink([html.I(className="fas fa-gavel me-3"), "Licitações"], href="/licitacoes", active="exact", className="text-white-50"), dbc.NavLink([html.I(className="fas fa-file-contract me-3"), "Contratos"], href="/contratos", active="exact", className="text-white-50"), ], vertical=True, pills=True), ], style=SIDEBAR_STYLE) app.layout = html.Div([dcc.Location(id="url"), sidebar, html.Div(id="page-content", style=CONTENT_STYLE)]) # ============================================================================== # 4. CALLBACKS E ROTEAMENTO # ============================================================================== @app.callback(Output("periodo-dinamico", "children"), Input("url", "pathname")) def atualizar_periodo(path): df_map = {"/": pd.concat([rec_anual, des_anual]) if not rec_anual.empty else pd.DataFrame(), "/receitas": rec_anual, "/despesas": des_anual, "/licitacoes": licitacoes_df, "/contratos": contratos_df} return f"Período: {extrair_periodo(df_map.get(path, pd.DataFrame()))}" @app.callback(Output("page-content", "children"), Input("url", "pathname")) def render_page(path): if path == "/receitas": return html.Div([ html.H2("💰 Receitas"), dcc.Dropdown(id='rec-dd', options=op_rec, value='TOTAL', className="mb-4", clearable=False), html.Div(id='rec-content') ]) if path == "/despesas": return html.Div([ html.H2("💸 Despesas"), dcc.Dropdown(id='desp-dd', options=op_desp, value='TOTAL', className="mb-4", clearable=False), html.Div(id='desp-content') ]) if path == "/contratos": return html.Div([ html.H2("📄 Gestão de Contratos"), dcc.Dropdown(id='contrato-dd', options=op_forn, value='TOTAL', className="mb-4", clearable=False), html.Div(id='contrato-content') ]) if path == "/licitacoes": return html.Div([ html.H2("⚖️ Processos Licitatórios"), dcc.Dropdown(id='licitacao-dd', options=op_mod, value='TOTAL', className="mb-4", clearable=False), html.Div(id='licitacao-content') ]) # HOME MANTIDA INTACTA CONFORME PEDIDO if rec_anual.empty or des_anual.empty: return dbc.Alert("Aguardando dados...", color="warning") fig = go.Figure() fig.add_trace(go.Bar(x=rec_anual['ano'], y=rec_anual['valorarrecadado'], name="Receita", marker_color='#10B981')) fig.add_trace(go.Bar(x=des_anual['ano'], y=des_anual['valorempenhado'], name="Despesa", marker_color='#EF4444')) if len(rec_anual) > 1: fig.add_trace(go.Scatter(x=rec_anual['ano'], y=np.poly1d(np.polyfit(rec_anual['ano'], rec_anual['valorarrecadado'], 1))(rec_anual['ano']), name='Tendência Rec.', line=dict(color='#065F46', dash='dot'))) if len(des_anual) > 1: fig.add_trace(go.Scatter(x=des_anual['ano'], y=np.poly1d(np.polyfit(des_anual['ano'], des_anual['valorempenhado'], 1))(des_anual['ano']), name='Tendência Desp.', line=dict(color='#991B1B', dash='dot'))) fig.update_layout(title="Equilíbrio Orçamentário Anual com Tendência", barmode='group', template="plotly_white") total_con = contratos_df['valortotal'].sum() if not contratos_df.empty and 'valortotal' in contratos_df.columns else 0 ultima_rec = rec_anual['valorarrecadado'].iloc[-1] if not rec_anual.empty else 0 ano_rec = int(rec_anual['ano'].iloc[-1]) if not rec_anual.empty else "" return html.Div([ html.H2("🏁 Visão Geral Executiva", className="mb-4"), dbc.Row([ dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(ultima_rec), className="text-success"), html.P(f"Última Receita Anual ({ano_rec})")]), className="border-0 shadow-sm"), width=4), dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(total_con), className="text-primary"), html.P(f"Total em Contratos {extrair_periodo(contratos_df)}")]), className="border-0 shadow-sm"), width=4), dbc.Col(dbc.Card(dbc.CardBody([html.H4(len(licitacoes_df), className="text-info"), html.P(f"Processos Licitatórios {extrair_periodo(licitacoes_df)}")]), className="border-0 shadow-sm"), width=4), ], className="mb-4"), dbc.Card(dbc.CardBody(dcc.Graph(figure=fig)), className="border-0 shadow-sm") ]) # --- LÓGICA DINÂMICA DOS DROPDOWNS --- @app.callback(Output('rec-content', 'children'), Input('rec-dd', 'value')) def update_rec(conta): df = receitas_df if conta == 'TOTAL' else receitas_df[receitas_df['contadescricao'] == conta] fig = px.line(df.groupby('ano')['valorarrecadado'].sum().reset_index(), x='ano', y='valorarrecadado', title=f"Evolução: {conta}", markers=True, template="plotly_white") return dbc.Card(dbc.CardBody(dcc.Graph(figure=fig)), className="border-0 shadow-sm") @app.callback(Output('desp-content', 'children'), Input('desp-dd', 'value')) def update_desp(orgao): df = despesas_df if orgao == 'TOTAL' else despesas_df[despesas_df['orgaodescricao'] == orgao] fig = px.bar(df.groupby('ano')['valorempenhado'].sum().reset_index(), x='ano', y='valorempenhado', title=f"Despesa: {orgao}", template="plotly_white", color_discrete_sequence=['#EF4444']) return dbc.Card(dbc.CardBody(dcc.Graph(figure=fig)), className="border-0 shadow-sm") @app.callback(Output('contrato-content', 'children'), Input('contrato-dd', 'value')) def update_contrato(forn): if contratos_df.empty: return dbc.Alert("Sem dados", color="danger") if forn == 'TOTAL': df_top = contratos_df.groupby('fornecedor')['valortotal'].sum().nlargest(10).reset_index() fig = px.bar(df_top, x='valortotal', y='fornecedor', orientation='h', title="Top 10 Fornecedores", template="plotly_white", color_discrete_sequence=['#3B82F6']) fig.update_layout(yaxis={'categoryorder':'total ascending'}) return html.Div([ dbc.Row([dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(contratos_df['valortotal'].sum()), className="text-primary"), html.P("Volume Financeiro Total")]), className="border-0 shadow-sm mb-4"), width=6)]), dbc.Card(dbc.CardBody(dcc.Graph(figure=fig)), className="border-0 shadow-sm") ]) else: df_f = contratos_df[contratos_df['fornecedor'] == forn] cols = [{"name": i.upper(), "id": i} for i in df_f.columns if i not in ['ano', 'fornecedor']] return html.Div([ dbc.Row([dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(df_f['valortotal'].sum()), className="text-primary"), html.P(f"Total Contratado: {forn}")]), className="border-0 shadow-sm mb-4"), width=12)]), dbc.Card(dbc.CardBody(dash_table.DataTable(data=df_f.to_dict('records'), columns=cols, page_size=10, style_table={'overflowX': 'auto'}, style_header={'backgroundColor': '#F3F4F6', 'fontWeight': 'bold'})), className="border-0 shadow-sm") ]) @app.callback(Output('licitacao-content', 'children'), Input('licitacao-dd', 'value')) def update_licitacao(mod): if licitacoes_df.empty: return dbc.Alert("Sem dados", color="danger") df_l = licitacoes_df if mod == 'TOTAL' else licitacoes_df[licitacoes_df[col_mod] == mod] fig_status = go.Figure().update_layout(title="Status não disponível") if col_sit: fig_status = px.bar(df_l[col_sit].value_counts().reset_index(), x=col_sit, y='count', title=f"Status: {mod}", template="plotly_white", color_discrete_sequence=['#8B5CF6']) est = df_l[col_est].sum() if col_est else 0 hom = df_l[col_hom].sum() if col_hom else 0 return html.Div([ dbc.Row([ dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(est), className="text-warning"), html.P("Estimado Global")]), className="border-0 shadow-sm"), width=6), dbc.Col(dbc.Card(dbc.CardBody([html.H4(format_currency(hom), className="text-success"), html.P("Homologado Global")]), className="border-0 shadow-sm"), width=6), ], className="mb-4"), dbc.Card(dbc.CardBody(dcc.Graph(figure=fig_status)), className="border-0 shadow-sm") ]) if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=7860)