diff --git a/Imagens para implantação /Imagem colada (10).png b/Imagens para implantação /Bkp Imagens/Imagem colada (10).png similarity index 100% rename from Imagens para implantação /Imagem colada (10).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (10).png diff --git a/Imagens para implantação /Imagem colada (11).png b/Imagens para implantação /Bkp Imagens/Imagem colada (11).png similarity index 100% rename from Imagens para implantação /Imagem colada (11).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (11).png diff --git a/Imagens para implantação /Imagem colada (12).png b/Imagens para implantação /Bkp Imagens/Imagem colada (12).png similarity index 100% rename from Imagens para implantação /Imagem colada (12).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (12).png diff --git a/Imagens para implantação /Imagem colada (13).png b/Imagens para implantação /Bkp Imagens/Imagem colada (13).png similarity index 100% rename from Imagens para implantação /Imagem colada (13).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (13).png diff --git a/Imagens para implantação /Imagem colada (14).png b/Imagens para implantação /Bkp Imagens/Imagem colada (14).png similarity index 100% rename from Imagens para implantação /Imagem colada (14).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (14).png diff --git a/Imagens para implantação /Imagem colada (15).png b/Imagens para implantação /Bkp Imagens/Imagem colada (15).png similarity index 100% rename from Imagens para implantação /Imagem colada (15).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (15).png diff --git a/Imagens para implantação /Imagem colada (16).png b/Imagens para implantação /Bkp Imagens/Imagem colada (16).png similarity index 100% rename from Imagens para implantação /Imagem colada (16).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (16).png diff --git a/Imagens para implantação /Imagem colada (17).png b/Imagens para implantação /Bkp Imagens/Imagem colada (17).png similarity index 100% rename from Imagens para implantação /Imagem colada (17).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (17).png diff --git a/Imagens para implantação /Imagem colada (18).png b/Imagens para implantação /Bkp Imagens/Imagem colada (18).png similarity index 100% rename from Imagens para implantação /Imagem colada (18).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (18).png diff --git a/Imagens para implantação /Imagem colada (19).png b/Imagens para implantação /Bkp Imagens/Imagem colada (19).png similarity index 100% rename from Imagens para implantação /Imagem colada (19).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (19).png diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (2).png b/Imagens para implantação /Bkp Imagens/Imagem colada (2).png new file mode 100644 index 0000000..1608c25 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (2).png differ diff --git a/Imagens para implantação /Imagem colada (20).png b/Imagens para implantação /Bkp Imagens/Imagem colada (20).png similarity index 100% rename from Imagens para implantação /Imagem colada (20).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (20).png diff --git a/Imagens para implantação /Imagem colada (21).png b/Imagens para implantação /Bkp Imagens/Imagem colada (21).png similarity index 100% rename from Imagens para implantação /Imagem colada (21).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (21).png diff --git a/Imagens para implantação /Imagem colada (22).png b/Imagens para implantação /Bkp Imagens/Imagem colada (22).png similarity index 100% rename from Imagens para implantação /Imagem colada (22).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (22).png diff --git a/Imagens para implantação /Imagem colada (23).png b/Imagens para implantação /Bkp Imagens/Imagem colada (23).png similarity index 100% rename from Imagens para implantação /Imagem colada (23).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (23).png diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (3).png b/Imagens para implantação /Bkp Imagens/Imagem colada (3).png new file mode 100644 index 0000000..d3fc022 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (3).png differ diff --git a/Imagens para implantação /Imagem colada (4).png b/Imagens para implantação /Bkp Imagens/Imagem colada (4).png similarity index 100% rename from Imagens para implantação /Imagem colada (4).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (4).png diff --git a/Imagens para implantação /Imagem colada (5).png b/Imagens para implantação /Bkp Imagens/Imagem colada (5).png similarity index 100% rename from Imagens para implantação /Imagem colada (5).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (5).png diff --git a/Imagens para implantação /Imagem colada (6).png b/Imagens para implantação /Bkp Imagens/Imagem colada (6).png similarity index 100% rename from Imagens para implantação /Imagem colada (6).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (6).png diff --git a/Imagens para implantação /Imagem colada (7).png b/Imagens para implantação /Bkp Imagens/Imagem colada (7).png similarity index 100% rename from Imagens para implantação /Imagem colada (7).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (7).png diff --git a/Imagens para implantação /Imagem colada (8).png b/Imagens para implantação /Bkp Imagens/Imagem colada (8).png similarity index 100% rename from Imagens para implantação /Imagem colada (8).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (8).png diff --git a/Imagens para implantação /Imagem colada (9).png b/Imagens para implantação /Bkp Imagens/Imagem colada (9).png similarity index 100% rename from Imagens para implantação /Imagem colada (9).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (9).png diff --git a/Imagens para implantação /Imagem colada (2).png b/Imagens para implantação /Imagem colada (2).png index 1608c25..3278741 100644 Binary files a/Imagens para implantação /Imagem colada (2).png and b/Imagens para implantação /Imagem colada (2).png differ diff --git a/Imagens para implantação /Imagem colada (3).png b/Imagens para implantação /Imagem colada (3).png index d3fc022..e4c9134 100644 Binary files a/Imagens para implantação /Imagem colada (3).png and b/Imagens para implantação /Imagem colada (3).png differ diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb new file mode 100644 index 0000000..2e45720 --- /dev/null +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -0,0 +1,53 @@ +# app/controllers/operacoes_dashboard_controller.rb +# +# Dashboard de análise de ENTREGAS por operação (separado do dashboard +# financeiro). O operador escolhe uma operação (UBS Norte, EMAD, ...) e vê os +# KPIs/insucessos/STS/mapa; também há os modos "global" (todas no período) e +# "comparar" (uma operação vs outra). Os números vêm de Analytics::OperacaoMetricas. +class OperacoesDashboardController < ApplicationController + MODOS = %w[operacao global comparar].freeze + + def index + authorize :dashboard, :operacoes? + + @periodo_inicio, @periodo_fim = periodo_selecionado + @modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao' + @operacoes_agrupadas = Operacao.agrupadas_por_mes + @tabelas_validas = Operacao.nomes_validos + + case @modo + when 'global' + @metricas = montar(@tabelas_validas) + when 'comparar' + @op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0] + @op_b = Operacao.sanitizar([params[:op_b]]).first || @tabelas_validas[1] + @metricas_a = montar([@op_a]) if @op_a + @metricas_b = montar([@op_b]) if @op_b + else + @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first + @metricas = montar([@operacao]) if @operacao + end + end + + private + + def montar(tabelas) + Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: @periodo_inicio, fim: @periodo_fim) + end + + # Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje. + # Espelha DashboardController#periodo_selecionado. + def periodo_selecionado + inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month + fim = parse_data(params[:fim]) || Date.current + inicio, fim = fim, inicio if fim < inicio + [inicio, fim] + end + + def parse_data(str) + return nil if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + nil + end +end diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb index 76a74ec..d3591de 100644 --- a/app/policies/dashboard_policy.rb +++ b/app/policies/dashboard_policy.rb @@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard) def index? user.admin? || user.gerente? end + + # Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML). + def operacoes? + !user.motorista? + end end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb new file mode 100644 index 0000000..af4f627 --- /dev/null +++ b/app/services/analytics/operacao_metricas.rb @@ -0,0 +1,221 @@ +# app/services/analytics/operacao_metricas.rb +# +# Núcleo de dados do "Dashboard de Operações". +# +# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em +# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz +# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por +# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os +# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia +# e o mapa de calor). +# +# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar +# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão +# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA. +module Analytics + class OperacaoMetricas + # Centro padrão do mapa quando não há coordenadas (Grande São Paulo). + SP_CENTRO = [-23.55, -46.63].freeze + + def initialize(tabelas:, inicio:, fim:) + @tabelas = Operacao.sanitizar(Array(tabelas)) + @inicio = inicio.to_date + @fim = fim.to_date + end + + def operacoes_label + @tabelas.map { |t| Operacao.label(t) }.join(', ') + end + + # Linhas cruas (Array de Hash com chave string) — base de todas as agregações. + def registros + @registros ||= carregar + end + + def vazio? + registros.empty? + end + + # ── KPIs ───────────────────────────────────────────────────── + def total + registros.size + end + + def sucesso + registros.count { |r| r['status'] == 'completed' } + end + + def recusas + registros.count { |r| Entrega::STATUS_FALHA.include?(r['status']) } + end + + # Em aberto: nem concluídas nem falhadas. + def pendentes + total - sucesso - recusas + end + + # Donut "Insucessos %": completas vs falhas (sobre o total). + def insucessos_pct + { + completed: sucesso, + failed: recusas, + pct_completed: pct(sucesso, total), + pct_failed: pct(recusas, total) + } + end + + # Falhas agrupadas por observação (motivo), % sobre o total de falhas. + def indices_falha + falhas = registros.select { |r| Entrega::STATUS_FALHA.include?(r['status']) } + total_falhas = falhas.size + falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' } + .map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } } + .sort_by { |h| -h[:total] } + end + + # Contagem por status da tabela gade (RECORRENTE/NOVO/...). + def por_status_gade + registros.group_by { |r| r['status_gade'].presence || '—' } + .map { |st, rows| { status: st, total: rows.size } } + .sort_by { |h| -h[:total] } + end + + # NFs por motorista (todas as entregas), desc. + def por_motorista + contagem(registros, 'driver') + end + + # Entregas CONCLUÍDAS por unidade (contact_name), desc. + def por_sts + contagem(registros.select { |r| r['status'] == 'completed' }, 'contact_name') + end + + # Por dia (DATE do checkout): completas vs falhas — gráfico de barras. + def por_dia + por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } } + registros.each do |r| + d = data_de(r['checkout']) + next unless d + if r['status'] == 'completed' + por_data[d][:completed] += 1 + elsif Entrega::STATUS_FALHA.include?(r['status']) + por_data[d][:failed] += 1 + end + end + datas = por_data.keys.sort + { + labels: datas.map { |d| d.strftime('%d/%m/%Y') }, + completed: datas.map { |d| por_data[d][:completed] }, + failed: datas.map { |d| por_data[d][:failed] } + } + end + + # Pontos [lat, lng] das entregas concluídas para o heatmap. + # Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN. + def pontos_mapa + @pontos_mapa ||= registros.filter_map do |r| + next unless r['status'] == 'completed' + lat = num(r['checkout_latitude']) || num(r['latitude']) + lng = num(r['checkout_longitude']) || num(r['longitude']) + next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?) + [lat, lng] + end + end + + def centro_mapa + pontos_mapa.first || SP_CENTRO + end + + private + + def contagem(rows, coluna) + rows.group_by { |r| r[coluna].presence || '—' } + .map { |nome, grupo| { nome: nome, total: grupo.size } } + .sort_by { |h| -h[:total] } + end + + def pct(parte, total) + return 0.0 if total.to_i.zero? + (parte.to_f / total * 100).round(2) + end + + def num(valor) + return nil if valor.nil? || valor.to_s.strip.empty? + Float(valor) + rescue ArgumentError, TypeError + nil + end + + def data_de(valor) + return nil if valor.nil? + return valor.to_date if valor.respond_to?(:to_date) + Date.parse(valor.to_s) + rescue ArgumentError, TypeError + nil + end + + def carregar + return [] if @tabelas.empty? + + conn = ActiveRecord::Base.connection + + unions = @tabelas.map do |tabela| + gade = conn.quote_table_name(tabela) + label = conn.quote(Operacao.label(tabela)) + <<~SQL.strip + SELECT #{label} AS operacao, + r.driver, r.status, r.observation, r.contact_name, + r.checkout, r.planned_date, + r.latitude, r.longitude, + r.checkout_latitude, r.checkout_longitude, + g.status AS status_gade + FROM ultimo r + INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal + WHERE r.rn = 1 AND (#{periodo_sql(conn)}) + SQL + end + + sql = <<~SQL + WITH ultimo AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn + FROM #{conn.quote_table_name(Entrega.table_name)} + WHERE reference_id IS NOT NULL #{conta_sql(conn)} + ) + #{unions.join("\nUNION ALL\n")} + SQL + + conn.select_all(sql).to_a + end + + # Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela + # data planejada. Espelha Entrega.no_periodo_checkout + no_periodo. + def periodo_sql(conn) + ini = conn.quote(@inicio) + fim_excl = conn.quote(@fim + 1) + fim_dia = conn.quote(@fim.end_of_day) + <<~SQL.strip + (r.checkout >= #{ini} AND r.checkout < #{fim_excl}) + OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}) + SQL + end + + # Replica Entrega.da_conta_gade como fragmento "AND (account_id ...)". + # Lista separada por vírgula em DB_EXISTING_ACCOUNT_ID; "all"/vazio = sem filtro. + def conta_sql(conn) + contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip + return '' if contas.empty? || contas.casecmp?('all') + + valores = contas.split(',', -1).map(&:strip) + inclui_null = valores.any?(&:empty?) + lista = valores.reject(&:empty?).map { |v| conn.quote(v) } + + condicoes = [] + condicoes << "account_id IN (#{lista.join(', ')})" if lista.any? + condicoes << 'account_id IS NULL' if inclui_null + return '' if condicoes.empty? + + "AND (#{condicoes.join(' OR ')})" + end + end +end diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index e365a80..6527adc 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -37,6 +37,7 @@ <%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %> <% unless current_user.motorista? %> <%= nav_link_to '📊 Dashboard', dashboard_path %> + <%= nav_link_to '📈 Operações', operacoes_dashboard_path %> <% end %> <%# Consolidações — só quem pode consolidar (exclui o externo) %> diff --git a/app/views/operacoes_dashboard/_mapa.html.erb b/app/views/operacoes_dashboard/_mapa.html.erb new file mode 100644 index 0000000..0e196ea --- /dev/null +++ b/app/views/operacoes_dashboard/_mapa.html.erb @@ -0,0 +1,15 @@ +<%# locals: metricas (Analytics::OperacaoMetricas) %> +<% pontos = metricas.pontos_mapa %> +
+
+

Mapa de Calor de Entregas

+ <%= pontos.size %> pontos +
+ <% if pontos.empty? %> +

Sem coordenadas para exibir no período.

+ <% else %> +
+ <% end %> +
diff --git a/app/views/operacoes_dashboard/_painel.html.erb b/app/views/operacoes_dashboard/_painel.html.erb new file mode 100644 index 0000000..3132c04 --- /dev/null +++ b/app/views/operacoes_dashboard/_painel.html.erb @@ -0,0 +1,110 @@ +<%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional) %> +<% titulo = local_assigns[:titulo] %> +<% ins = metricas.insucessos_pct %> +<% pd = metricas.por_dia %> + +
+ <% if titulo.present? %> +
+

<%= titulo %>

+ <%= metricas.total %> entregas +
+ <% end %> + + <%# ── KPIs ─────────────────────────────────────────────── %> +
+
+

📦 Total de Entregas

+

<%= metricas.total %>

+
+
+

✅ Total de Sucesso

+

<%= metricas.sucesso %>

+
+
+

❌ Total de Recusas

+

<%= metricas.recusas %>

+
+
+

⏳ Entregas Pendentes

+

<%= metricas.pendentes %>

+
+
+ + <%# ── Insucessos % + Índices de falha + Status ─────────── %> +
+
+

Insucessos %

+ <% if metricas.total.zero? %> +

Sem dados no período.

+ <% else %> +
+ +
+
+ ● Completas <%= ins[:pct_completed] %>% + ● Falhas <%= ins[:pct_failed] %>% +
+ <% end %> +
+ +
+

Índices de Falha

+ <%= render 'tabela_simples', + cabecalhos: ['Observação', 'Total', '%'], + linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] }, + vazio: 'Nenhuma falha no período.' %> +
+ +
+

Por Status

+ <%= render 'tabela_simples', + cabecalhos: ['Status', 'Quantidade'], + linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] }, + vazio: 'Sem dados.', + total: metricas.total %> +
+
+ + <%# ── Motoristas + STS ─────────────────────────────────── %> +
+
+

Notas fiscais por Motorista

+
+ <%= render 'tabela_simples', + cabecalhos: ['Motorista', 'Notas fiscais'], + linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] }, + vazio: 'Sem dados.', + total: metricas.total %> +
+
+ +
+

Entregas por STS (Unidade)

+
+ <%= render 'tabela_simples', + cabecalhos: ['Unidade', 'NFs entregues'], + linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] }, + vazio: 'Sem dados.', + total: metricas.sucesso %> +
+
+
+ + <%# ── Entregas por dia ─────────────────────────────────── %> +
+

Entregas por Dia

+ <% if pd[:labels].empty? %> +

Sem dados no período.

+ <% else %> +
+ +
+ <% end %> +
+
diff --git a/app/views/operacoes_dashboard/_tabela_simples.html.erb b/app/views/operacoes_dashboard/_tabela_simples.html.erb new file mode 100644 index 0000000..a0a15bc --- /dev/null +++ b/app/views/operacoes_dashboard/_tabela_simples.html.erb @@ -0,0 +1,32 @@ +<%# locals: cabecalhos (Array), linhas (Array de Array), vazio (String), total (opcional) %> +<% total_rodape = local_assigns[:total] %> +<% if linhas.blank? %> +

<%= vazio %>

+<% else %> + + + + <% cabecalhos.each_with_index do |c, i| %> + + <% end %> + + + + <% linhas.each do |celulas| %> + + <% celulas.each_with_index do |cel, i| %> + + <% end %> + + <% end %> + + <% if total_rodape %> + + + + + + + <% end %> +
<%= c %>
<%= cel %>
Total<%= total_rodape %>
+<% end %> diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb new file mode 100644 index 0000000..8e9297c --- /dev/null +++ b/app/views/operacoes_dashboard/index.html.erb @@ -0,0 +1,286 @@ +<%# app/views/operacoes_dashboard/index.html.erb %> +<%# Dashboard de análise de entregas por operação (KPIs, insucessos, STS, mapa). %> + +<% content_for :head do %> + + + <%# Flatpickr (date range) — CSS no %> + + + <%# Leaflet (mapa de calor) %> + + +<% end %> + +<% hoje = Date.current %> +<% atalhos = { + 'Hoje' => [hoje, hoje], + '7 dias' => [hoje - 6, hoje], + 'Este mês' => [hoje.beginning_of_month, hoje], + '30 dias' => [hoje - 29, hoje] + } %> +<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %> +<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %> +<%# Parâmetros de operação a preservar ao trocar de modo/período %> +<% ctx_ops = { operacao: @operacao, op_a: @op_a, op_b: @op_b }.compact %> + +
+ + <%# ── Cabeçalho ─────────────────────────────────────────── %> +
+
+

Dashboard de Operações

+

+ <%= @periodo_inicio.strftime('%d/%m/%Y') %> – <%= @periodo_fim.strftime('%d/%m/%Y') %> + · Atualizado às <%= Time.now.strftime('%H:%M') %> +

+
+ + <%# Filtro de período (flatpickr) + atalhos %> +
+
+ 📅 + +
+
+ <% atalhos.each do |label, (ini, fim)| %> + <% ativo = @periodo_inicio == ini && @periodo_fim == fim %> + <%= link_to label, + operacoes_dashboard_path(ctx_ops.merge(modo: @modo, inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'))), + data: { turbo: false }, + class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %> + <% end %> +
+
+
+ + <%# ── Abas de modo ──────────────────────────────────────── %> + <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> +
+ <% modos.each do |m, label| %> + <% ativo = @modo == m %> + <%= link_to label, + operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), + data: { turbo: false }, + class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %> + <% end %> +
+ + <% if @tabelas_validas.blank? %> +
+ Nenhuma operação encontrada no banco (tabelas gade_entregas_*). +
+ <% else %> + + <%# ── Seletor de operação por modo ───────────────────── %> + <% if @modo == 'operacao' %> +
+ + + + + +
+ + <% if @metricas %> + <%= render 'painel', metricas: @metricas %> + <%= render 'mapa', metricas: @metricas %> + <% end %> + + <% elsif @modo == 'global' %> +
+ 🌐 Visão Global — todas as + <%= @tabelas_validas.size %> operações agregadas no período. +
+ <% if @metricas %> + <%= render 'painel', metricas: @metricas %> + <%= render 'mapa', metricas: @metricas %> + <% end %> + + <% else # comparar %> +
+ + + +
+ + +
+
+ + +
+
+ +
+
+ <% if @metricas_a %> + <%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %> + <% else %> +

Selecione a operação A.

+ <% end %> +
+
+ <% if @metricas_b %> + <%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %> + <% else %> +

Selecione a operação B.

+ <% end %> +
+
+ <% end %> + <% end %> +
+ +<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %> + + + + + + + diff --git a/config/routes.rb b/config/routes.rb index f617ebf..7bc0127 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,9 @@ Rails.application.routes.draw do get '/dashboard', to: 'dashboard#index', as: :dashboard get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro + # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) + get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard + # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard diff --git a/spec/requests/operacoes_dashboard_spec.rb b/spec/requests/operacoes_dashboard_spec.rb new file mode 100644 index 0000000..8b4d931 --- /dev/null +++ b/spec/requests/operacoes_dashboard_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe 'Dashboard de Operações', type: :request do + it 'redireciona usuário não autenticado para o login' do + get operacoes_dashboard_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia motorista (Pundit redireciona para a raiz)' do + sign_in create(:motorista) + get operacoes_dashboard_path + expect(response).to redirect_to(root_path) + end + + it 'permite operador (renderiza a página)' do + sign_in create(:operador) + get operacoes_dashboard_path + expect(response).to have_http_status(:ok) + end + + it 'permite gerente' do + sign_in create(:gerente) + get operacoes_dashboard_path(modo: 'global') + expect(response).to have_http_status(:ok) + end +end diff --git a/spec/services/analytics/operacao_metricas_spec.rb b/spec/services/analytics/operacao_metricas_spec.rb new file mode 100644 index 0000000..238cfaf --- /dev/null +++ b/spec/services/analytics/operacao_metricas_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe Analytics::OperacaoMetricas do + # Linhas simuladas (mesma forma do retorno de select_all) — evita depender das + # tabelas externas no teste. Stubamos #registros para focar nas agregações. + let(:rows) do + [ + row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'), + row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'), + row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'), + row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'), + row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'), + row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24') + ] + end + + subject(:metricas) do + described_class.new(tabelas: [], inicio: Date.new(2026, 6, 1), fim: Date.new(2026, 6, 30)) + end + + before do + # Isola das tabelas externas: não toca o catálogo nem db_reem_simplerout_2026. + allow(Operacao).to receive(:sanitizar).and_return([]) + allow(metricas).to receive(:registros).and_return(rows) + end + + it 'conta KPIs (total/sucesso/recusas/pendentes)' do + expect(metricas.total).to eq(6) + expect(metricas.sucesso).to eq(3) + expect(metricas.recusas).to eq(2) + expect(metricas.pendentes).to eq(1) + end + + it 'calcula insucessos %' do + ins = metricas.insucessos_pct + expect(ins[:completed]).to eq(3) + expect(ins[:failed]).to eq(2) + expect(ins[:pct_completed]).to eq(50.0) + expect(ins[:pct_failed]).to eq(33.33) + end + + it 'agrupa índices de falha por observação' do + expect(metricas.indices_falha).to eq([{ observation: 'ÓBITO', total: 2, pct: 100.0 }]) + end + + it 'agrupa por status_gade' do + expect(metricas.por_status_gade).to contain_exactly( + { status: 'RECORRENTE', total: 3 }, + { status: 'NOVO', total: 3 } + ) + end + + it 'conta NFs por motorista (todas), desc' do + expect(metricas.por_motorista.first).to eq({ nome: 'Carlos', total: 3 }) + expect(metricas.por_motorista.map { |h| h[:nome] }).to eq(%w[Carlos Pedro Marcos]) + end + + it 'conta entregas concluídas por STS (contact_name)' do + expect(metricas.por_sts).to eq([ + { nome: 'STS PERUS', total: 2 }, + { nome: 'STS PIRITUBA', total: 1 } + ]) + end + + it 'agrupa por dia (completas x falhas)' do + pd = metricas.por_dia + expect(pd[:labels]).to eq(['22/06/2026', '23/06/2026']) + expect(pd[:completed]).to eq([2, 1]) + expect(pd[:failed]).to eq([1, 1]) + end + + it 'monta pontos do mapa (prefere checkout, cai p/ checkin)' do + expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]]) + end + + context 'sem registros' do + before { allow(metricas).to receive(:registros).and_return([]) } + + it 'não divide por zero' do + expect(metricas.total).to eq(0) + expect(metricas.insucessos_pct[:pct_failed]).to eq(0.0) + expect(metricas.indices_falha).to eq([]) + expect(metricas.pontos_mapa).to eq([]) + expect(metricas.centro_mapa).to eq(described_class::SP_CENTRO) + end + end + + def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil) + { + 'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade, + 'observation' => obs, 'checkout' => checkout, 'planned_date' => planned, + 'latitude' => lat, 'longitude' => lng, + 'checkout_latitude' => clat, 'checkout_longitude' => clng + } + end +end