Compare commits

2 Commits
teste ... main

Author SHA256 Message Date
a98e7868d2 Merge pull request 'teste' (#6) from teste into main
Reviewed-on: #6
2026-07-13 12:57:26 -03:00
cb47d1c294 Merge pull request 'teste' (#5) from teste into main
Reviewed-on: #5
2026-07-03 10:52:51 -03:00
18 changed files with 18 additions and 1129 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

123
README.md
View File

@@ -1366,126 +1366,3 @@ docker compose up -d --build
</details> </details>
---
<details>
<summary><strong>🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)</strong></summary>
Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base
técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV):
cada item vira uma fase própria quando for priorizado.
### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute"
criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
(título `NF {nf} - {nome}`, janelas 08:0018:00, lat/long do mês anterior, etc.).
> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.**
- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica).
- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga".
### 2. 🧾 Relatório de comprovantes de entrega (POD)
`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada
entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`).
Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").
### 3. ⚡ Webhooks — painel em tempo real
`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`,
`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza
**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
idempotência) e URL pública estável.
### 4. 📱 Aviso ao paciente via WhatsApp
Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`):
"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE**
(motivo real da lista de observations).
### 5. 📺 Monitor de rotas ao vivo
`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada
veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).
### 6. 👷 Sincronização de motoristas/veículos
`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema
(hoje o vínculo é o nome digitado — sujeito a divergência).
### 7. 📊 Datamart e 🏷️ tags/skills
Export paginado de analytics para enriquecer dashboards; tags/skills para classificar
visitas por tipo de material.
</details>
---
<details>
<summary><strong>🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções</strong></summary>
### 📋 Planilha da Operação — página própria (`/dashboard/operacoes/planilha`)
Tabela **espelho da planilha da operação** (uma linha por NF) para pesquisar e acompanhar
ocorrências/status, acessada pelo botão **"📋 Planilha da operação"** no Dashboard de Operações
(o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos).
- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo,
**Resultado** (badge Entregue/Falha/Pendente), **Ocorrência** (motivo do insucesso) e status
gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação.
- **Busca textual server-side** (param `q`, qualquer campo) + **paginação server-side**
(param `pg`, 15/página, janela `« 1 … 7 8 9 … 42 »`) — aguenta as milhares de linhas do Global.
- Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos
filtros herdados. Reusa as linhas do `Analytics::OperacaoMetricas` (novo método `#buscar`) —
**nenhuma query nova**.
- Arquivos: rota `operacoes_planilha`, action `planilha` + `montar_tabela_espelho`
(`OperacoesDashboardController`), views `planilha.html.erb` + `_espelho.html.erb`.
### ⬇️ Download do Excel do cliente — planilha Entregas preenchida
Botão **"⬇️ Baixar Excel preenchido"** na página da planilha: gera o `.xlsx` no formato do modelo
**"Entregas SUDESTE MM.AAAA_FINAL"** com as **3 abas, 1:1** — automatiza o preenchimento que era
feito manualmente para entregar ao cliente.
- **RESUMO** — mês, Total Previsto/Realizado/Performance e os quadros **EMAD** e **UBS** por
coordenadoria (CRS fixas do modelo) com **motivos de "Não Entregue"** (Óbito, Responsável
Ausente, Endereço não localizado, Recusa, Paciente não reside, Outros) classificados a partir
da `observation` do rastreio; linhas Total e Performance %.
- **ENTREGAS** — colunas AV da própria tabela `gade_entregas_*` (ordem física de importação =
ordem da planilha original) + **WZ preenchidas pelo rastreio**: STATUS, ENTREGA (Sim/Não),
DATA OCORRÊNCIA (data real do checkout) e OCORRÊNCIA — último status de cada NF (mesma CTE
`ROW_NUMBER` dos painéis).
- **SimpliRoute** — dump cru do rastreio com as **46 colunas exatas** do export original
(todas as visitas das NFs da operação, incluindo repetidas).
- **Estilo idêntico ao modelo** (cores extraídas do `styles.xml` do próprio arquivo): cabeçalho
ENTREGAS azul `1155CC` (AT) + vermelho `C00000` (UZ), quadros do RESUMO em
`002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em
itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice.
- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário,
caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`.
- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com
fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro
download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2
**gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle
manual (sai "A iniciar").
### 📊 Dashboard financeiro
- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com
abas e contador ("110 de 47"), client-side (função genérica `paginarTabela`); some com ≤10 itens.
- **Novo gráfico "Pagamentos por dia"** abaixo da "Evolução do custo": barras verdes com o valor
pago por data de pagamento (`pago_em`) + linha tracejada do **acumulado** — o fluxo de caixa,
que não existia em nenhuma tela (`build_grafico_pagamentos`). Quando não há pagamento no
período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado.
> Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo)
> e "Resultado por dia" (já existe na tela de Operações).
- O card dos gráficos virou `flex-col` e preenche toda a altura ao lado do ranking de motoristas
(sumiu o vazio embaixo da linha).
### 🔴 Correções
- **"Rodapé quebrado" no celular:** era uma **barra de rolagem horizontal** (laranja) colada no
rodapé — a legenda dos gráficos não quebrava linha e estourava ~37px a largura em telas
<~430px. ✅ Cabeçalhos/legendas dos gráficos com `flex-wrap` + **`overflow-x-hidden` no
`<body>`** (rede de segurança; tabelas largas continuam rolando nos próprios contêineres
`overflow-x-auto`).
### 📌 Padrões (reforçados)
- Tabelas externas sempre read-only, nome de tabela via whitelist + `quote_table_name`, colunas
em whitelist fixa com fallback NULL (padrão `OperacaoMetricas`/`PlanilhaCarga`).
- Excel com `caxlsx` (mesmo padrão da planilha de carga SimpliRoute): serviço de linhas +
serviço de binário + `send_data` no controller.
> **Sem migration.** Deploy normal; em produção reiniciar o Puma após o deploy.
</details>

View File

@@ -165,9 +165,6 @@ class DashboardController < ApplicationController
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc) @pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc) @pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
# Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico
@grafico_pagamentos_dia = build_grafico_pagamentos(pagos)
end end
# Visão financeira (custo) das consolidações finalizadas do período, já com o # Visão financeira (custo) das consolidações finalizadas do período, já com o
@@ -244,28 +241,6 @@ class DashboardController < ApplicationController
{ labels: labels, valores: valores, qtds: qtds } { labels: labels, valores: valores, qtds: qtds }
end end
# Valor pago por dia (pela data do pagamento) + acumulado no período,
# para o gráfico de fluxo de pagamentos.
def build_grafico_pagamentos(pagos)
dias = (@periodo_inicio..@periodo_fim).to_a
if dias.size <= MAX_DIAS_GRAFICO
soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s)
labels = dias.map { |d| d.strftime('%d/%m') }
chaves = dias.map(&:to_s)
else
soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total)
meses = meses_no_periodo
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
chaves = meses
end
valores = chaves.map { |k| soma[k].to_f.round(2) }
acumulado = 0
{ labels: labels, valores: valores,
acumulado: valores.map { |v| (acumulado += v).round(2) } }
end
# Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive. # Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
def meses_no_periodo def meses_no_periodo
meses = [] meses = []

View File

@@ -7,11 +7,6 @@
class OperacoesDashboardController < ApplicationController class OperacoesDashboardController < ApplicationController
MODOS = %w[operacao global comparar].freeze MODOS = %w[operacao global comparar].freeze
# Linhas por página da tabela espelho (planilha da operação)
ESPELHO_POR_PAGINA = 15
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
def index def index
authorize :dashboard, :operacoes? authorize :dashboard, :operacoes?
@@ -39,67 +34,8 @@ class OperacoesDashboardController < ApplicationController
end end
end end
# Planilha da operação (tabela espelho): página própria com busca e paginação.
# Aceita os mesmos parâmetros do index (operacao/modo global/período/cross-
# filters), então o botão no dashboard preserva o contexto atual.
def planilha
authorize :dashboard, :operacoes?
@periodo_inicio, @periodo_fim = periodo_selecionado
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
@operacoes_agrupadas = Operacao.agrupadas_por_mes
@tabelas_validas = Operacao.nomes_validos
@filtros, @chips = cross_filtros
@data_filtro = parse_data(params[:f_data])
if @modo == 'global'
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
else
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
end
montar_tabela_espelho if @metricas
end
# GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_...
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
# do rastreio — o modelo que é entregue ao cliente.
def baixar_planilha
authorize :dashboard, :operacoes?
tabela = params[:operacao].to_s
unless Operacao.valida?(tabela)
return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.')
end
planilha = Analytics::PlanilhaEntregas.new(tabela)
if planilha.linhas.empty?
return redirect_to(operacoes_planilha_path(operacao: tabela),
alert: "A operação #{planilha.label} não tem registros.")
end
send_data Analytics::PlanilhaEntregasXlsx.new(planilha).gerar,
filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx",
type: XLSX_MIME,
disposition: 'attachment'
end
private private
# Tabela espelho da planilha da operação: busca textual (q) + paginação (pg)
# sobre as linhas já cross-filtradas. Ordena pelas mais recentes (checkout;
# sem checkout vai para o fim).
def montar_tabela_espelho
@busca = params[:q].to_s.strip
linhas = @metricas.buscar(@busca).sort_by { |r| r['checkout'].to_s }.reverse
@espelho_total = linhas.size
@espelho_paginas = [(@espelho_total / ESPELHO_POR_PAGINA.to_f).ceil, 1].max
@espelho_pagina = params[:pg].to_i.clamp(1, @espelho_paginas)
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
end
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
end end

View File

@@ -45,18 +45,6 @@ module Analytics
end end
end end
# Campos pesquisáveis da tabela espelho (planilha da operação).
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
address nome_completo endereco_completo status_gade operacao].freeze
# Busca textual da tabela espelho: filtra as linhas (já pós cross-filter)
# por qualquer campo exibido na planilha.
def buscar(texto)
termo = texto.to_s.strip.downcase
return linhas if termo.empty?
linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } }
end
# Taxas (úteis no comparativo). # Taxas (úteis no comparativo).
def taxa_sucesso def taxa_sucesso
pct(sucesso, total) pct(sucesso, total)

View File

@@ -1,242 +0,0 @@
# app/services/analytics/planilha_entregas.rb
#
# Monta os dados da "planilha Entregas" entregue ao cliente (modelo
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx"), com as 3 abas do modelo:
#
# RESUMO — totais/performance e quadro por coordenadoria (EMAD e UBS),
# com os motivos de "Não Entregue" agregados do rastreio.
# ENTREGAS — planilha do cliente (A..V da tabela gade_entregas_*) com as
# colunas de resultado W..Z preenchidas pelo rastreio (o
# preenchimento que hoje é feito manualmente).
# SimpliRoute — dump cru do rastreio (todas as visitas das NFs da operação).
#
# SEGURANÇA: tabela passa pela whitelist (Operacao.sanitizar) + quote_table_name;
# colunas são whitelist fixa e as ausentes viram NULL (mesmo padrão do
# OperacaoMetricas). Bases SOMENTE LEITURA.
module Analytics
class PlanilhaEntregas
# Colunas da tabela gade (snake_case) -> cabeçalho exato do modelo (A..V).
# Coluna que não existir em alguma operação sai vazia, preservando o layout.
COLUNAS_GADE = {
'ordem' => 'Ordem',
'operacao' => 'Operação',
'coordenadoria' => 'Coordenadoria',
'supervisao' => 'Supervisão',
'unidade' => 'Unidade',
'data_cadastro' => 'Data Cadastro',
'cartao_sus' => 'Cartão SUS',
'nome_completo' => 'Nome Completo',
'endereco_completo' => 'Endereço Completo',
'nome_responsavel' => 'Nome Responsável',
'telefones' => 'Telefones',
'area_de_risco' => 'Área de Risco',
'numero_ativo' => 'Numero Ativo',
'num_serie_base' => 'Num Serie Base',
'num_serie_pulverizador' => 'Num Serie Pulverizador',
'observacoes' => 'Observações',
'endereco_sem_complemento' => 'Endereço Sem Complemento',
'complemento' => 'Complemento',
'latitude' => 'Latitude',
'longitude' => 'Longitude',
'status' => 'STATUS',
'nota_fiscal' => 'NOTA FISCAL'
}.freeze
# Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
RASTREIO_COLUNAS = {
'Tracking ID' => %w[tracking_id],
'Referência ID' => %w[reference_id],
'Data prevista' => %w[planned_date],
'Motorista' => %w[driver],
'Co-pilotos' => %w[copilots co_pilotos copilotos],
'Veículo' => %w[vehicle],
'Título' => %w[title titulo],
'Endereço' => %w[address],
'ETA' => %w[eta],
'ETD' => %w[etd],
'Checkin' => %w[checkin],
'Checkout' => %w[checkout],
'Responsável' => %w[receiver responsavel responsible],
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
'Antecipação' => %w[early antecipacao anticipation],
'Atraso' => %w[delay atraso],
'Latitude' => %w[latitude],
'Longitude' => %w[longitude],
'Checkout latitude' => %w[checkout_latitude],
'Checkout longitude' => %w[checkout_longitude],
'Load' => %w[load load_1 carga],
'Load 2' => %w[load_2],
'Load 3' => %w[load_3],
'Load 4' => %w[load_4],
'Estado' => %w[status],
'Comentários' => %w[checkout_comment comments comentarios comment],
'Observações' => %w[observation observacoes],
'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
'Notas' => %w[notes notas],
'Nome de contato' => %w[contact_name],
'Telefone de contato' => %w[contact_phone telefone_de_contato],
'Correio eletrônico de contato' => %w[contact_email correio_eletronico_de_contato],
'ID da rota' => %w[route_id id_da_rota],
'ID da conta' => %w[account_id],
'Nome da conta' => %w[account_name nome_da_conta],
'Nota Fiscal' => %w[nota_fiscal],
'Termo de Recebimento' => %w[termo_de_recebimento],
'Foto da Fachada' => %w[foto_da_fachada],
'Relatório de visita' => %w[relatorio_de_visita visit_report],
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
}.freeze
# Quadro do RESUMO — linhas fixas do modelo.
COORDENADORIAS = ['CRS Sudeste', 'CRS Sul', 'CRS Leste', 'CRS Norte', 'CRS Oeste', 'CRS Centro'].freeze
MOTIVOS = ['Óbito', 'Responsável Ausente', 'Endereço não localizado', 'Recusa',
'Paciente não reside no local', 'Outros'].freeze
GRUPOS = %w[EMAD UBS].freeze
MESES_PT = %w[JANEIRO FEVEREIRO MARÇO ABRIL MAIO JUNHO JULHO AGOSTO SETEMBRO OUTUBRO NOVEMBRO DEZEMBRO].freeze
attr_reader :tabela
def initialize(tabela)
@tabela = Operacao.sanitizar([tabela]).first
raise ArgumentError, "Operação inválida: #{tabela}" unless @tabela
end
def label
Operacao.label(@tabela)
end
# ── Aba ENTREGAS ─────────────────────────────────────────────
# Array de hashes: chaves de COLUNAS_GADE + rastreio_status/checkout/observation.
# ORDER BY g.ctid preserva a ordem física de importação — a mesma ordem da
# planilha original do cliente (tabelas gade são read-only após o import).
def linhas
@linhas ||= begin
gade = conn.quote_table_name(@tabela)
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
)
SELECT #{selects_gade},
r.status AS rastreio_status,
r.checkout AS rastreio_checkout,
r.observation AS rastreio_observation
FROM #{gade} g
LEFT JOIN ultimo r ON r.rn = 1 AND r.reference_id::text = g.nota_fiscal
ORDER BY g.ctid
SQL
conn.select_all(sql).to_a
end
end
# ── Aba SimpliRoute ──────────────────────────────────────────
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
# como no dump original do SimpliRoute).
def linhas_rastreio
gade = conn.quote_table_name(@tabela)
sql = <<~SQL
SELECT r.*
FROM #{conn.quote_table_name(Entrega.table_name)} r
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
ORDER BY r.reference_id, r.checkout NULLS LAST
SQL
conn.select_all(sql).to_a
end
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
def colunas_rastreio
existentes = conn.columns(Entrega.table_name).map(&:name)
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
end
# ── Aba RESUMO ───────────────────────────────────────────────
# Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria:
# previsão/entregue/não entregue/pendente + motivos de "Não Entregue".
def resumo
grupos = GRUPOS.index_with { Hash.new { |h, k| h[k] = novo_bloco } }
linhas.each do |l|
bloco = grupos[grupo_de(l)][coordenadoria_de(l)]
bloco[:previsao] += 1
status = l['rastreio_status'].to_s
if status == 'completed'
bloco[:entregue] += 1
elsif Entrega::STATUS_FALHA.include?(status)
bloco[:nao_entregue] += 1
bloco[:motivos][motivo_de(l['rastreio_observation'])] += 1
else
bloco[:pendente] += 1
end
end
total_prev = linhas.size
total_real = grupos.values.sum { |coords| coords.values.sum { |b| b[:entregue] } }
{
mes: "#{MESES_PT[Date.current.month - 1]}/#{Date.current.year}",
total_previsto: total_prev,
total_realizado: total_real,
performance: (total_prev.zero? ? nil : total_real.to_f / total_prev),
grupos: grupos
}
end
# Bloco zerado do quadro RESUMO (também usado pelo gerador xlsx).
def novo_bloco
{ previsao: 0, entregue: 0, nao_entregue: 0, pendente: 0, motivos: Hash.new(0) }
end
private
def conn
ActiveRecord::Base.connection
end
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
def selects_gade
existentes = conn.columns(@tabela).map(&:name)
COLUNAS_GADE.keys.map do |c|
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
end.join(', ')
end
# Grupo do quadro (EMAD/UBS): coluna "operacao" da linha; sem ela, deduz do
# nome/label da operação. Default UBS (grupo mais comum no modelo).
def grupo_de(linha)
texto = I18n.transliterate("#{linha['operacao']} #{@tabela} #{label}").upcase
texto.include?('EMAD') ? 'EMAD' : 'UBS'
end
# Casa a coordenadoria da linha com as fixas do modelo (sem acento/caixa);
# valores fora da lista entram como estão (viram linha extra no quadro).
def coordenadoria_de(linha)
bruto = linha['coordenadoria'].to_s.strip
return COORDENADORIAS.first if bruto.empty? # sem coluna: agrega na 1ª (Sudeste)
chave = I18n.transliterate(bruto).upcase
COORDENADORIAS.find { |c| I18n.transliterate(c).upcase == chave } || bruto
end
# Classifica a observação do insucesso nos motivos fixos do RESUMO.
def motivo_de(observacao)
o = I18n.transliterate(observacao.to_s).downcase
return 'Óbito' if o.include?('obito')
return 'Responsável Ausente' if o.include?('ausente')
return 'Endereço não localizado' if o.include?('localizado') || o.include?('endereco')
return 'Recusa' if o.include?('recus')
return 'Paciente não reside no local' if o.include?('reside')
'Outros'
end
end
end

View File

@@ -1,211 +0,0 @@
# app/services/analytics/planilha_entregas_xlsx.rb
#
# Gera o binário .xlsx da planilha Entregas do cliente com as 3 abas do modelo
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx": RESUMO, ENTREGAS e SimpliRoute.
# Os dados vêm de Analytics::PlanilhaEntregas.
#
# As cores/estilos foram extraídos do próprio arquivo modelo (styles.xml):
# ENTREGAS A..T azul 1155CC e U..Z vermelho C00000 (texto branco, negrito);
# RESUMO com quadros em 002060/0070C0/C00000/A5A5A5; SimpliRoute sem estilo.
# (Os gráficos embutidos do modelo não são replicados.)
require 'caxlsx'
module Analytics
class PlanilhaEntregasXlsx
def initialize(planilha)
@planilha = planilha
end
# String binária do .xlsx.
def gerar
pkg = Axlsx::Package.new
wb = pkg.workbook
definir_estilos(wb)
aba_resumo(wb)
aba_entregas(wb)
aba_simpliroute(wb)
pkg.to_stream.read
end
private
def definir_estilos(wb)
st = wb.styles
borda = { style: :thin, color: 'FF808080', edges: %i[left right top bottom] }
centro = { horizontal: :center, vertical: :center }
# Cabeçalhos/civis do RESUMO
@titulo_mes = st.add_style(b: true)
@titulo_sec = st.add_style(b: true, sz: 16, u: true)
@rotulo_kpi = st.add_style(b: true, sz: 10)
@valor_kpi = st.add_style(b: true, sz: 10, border: { style: :thin, color: 'FF000000', edges: [:bottom] })
@valor_pct = st.add_style(b: true, sz: 10, format_code: '0.0%',
border: { style: :thin, color: 'FF000000', edges: [:bottom] })
@h_navy = st.add_style(bg_color: '002060', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
@h_azul = st.add_style(bg_color: '0070C0', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
@h_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
@h_cinza = st.add_style(bg_color: 'A5A5A5', fg_color: 'FFFFFF', sz: 10, border: borda,
alignment: centro.merge(wrap_text: true))
@h_motivo = st.add_style(b: true, sz: 10, border: borda, alignment: centro.merge(wrap_text: true))
@celula = st.add_style(sz: 11, border: borda)
@cel_num = st.add_style(sz: 11, border: borda, alignment: centro)
# Status das Entregas / Conferência Documentos (verde "bom", laranja "em
# andamento", azul "a iniciar" — como no modelo)
@st_verde = st.add_style(bg_color: 'C6EFCE', fg_color: '006100', b: true, sz: 10, border: borda, alignment: centro)
@st_laranja = st.add_style(bg_color: 'FCE4D6', fg_color: 'ED7D31', sz: 10, border: borda, alignment: centro)
@st_iniciar = st.add_style(fg_color: '0070C0', sz: 10, border: borda, alignment: centro)
@perf_rotulo = st.add_style(i: true)
@perf_pct = st.add_style(i: true, format_code: '0.0%')
# Cabeçalho da ENTREGAS
@cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
@cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
end
# ── RESUMO ───────────────────────────────────────────────────
# Mesma disposição do modelo: mês, totais/performance e um quadro por grupo
# (EMAD e UBS) com as coordenadorias fixas + motivos de "Não Entregue".
# ("Cordenadoria" sem o primeiro "o" é verbatim do modelo.)
def aba_resumo(wb)
resumo = @planilha.resumo
wb.add_worksheet(name: 'RESUMO') do |s|
s.add_row []
s.add_row ['', resumo[:mes]], style: [nil, @titulo_mes]
s.add_row []
s.add_row ['', 'Total Previsto:', resumo[:total_previsto]], style: [nil, @rotulo_kpi, @valor_kpi]
s.add_row ['', 'Total Realizado:', resumo[:total_realizado]], style: [nil, @rotulo_kpi, @valor_kpi]
s.add_row ['', 'Performance:', resumo[:performance] || ''],
style: [nil, @rotulo_kpi, (resumo[:performance] ? @valor_pct : @valor_kpi)]
PlanilhaEntregas::GRUPOS.each { |grupo| quadro_grupo(s, grupo, resumo[:grupos][grupo]) }
s.column_widths 3, 18, 10, 10, 12, 14, 14, 3, 10, 12, 14, 10, 14, 10
end
end
def quadro_grupo(s, grupo, coords)
s.add_row []
s.add_row ['', grupo], style: [nil, @titulo_sec]
# "Informações" (F:G) e 'Motivos de "Não Entregue"' (I:N) mescladas
s.add_row ['', '', '', '', '', 'Informações', '', '', 'Motivos de "Não Entregue"', '', '', '', '', ''],
style: [nil, nil, nil, nil, nil, @h_motivo, @h_motivo, nil, *Array.new(6, @h_motivo)]
r = s.rows.size
s.merge_cells("F#{r}:G#{r}")
s.merge_cells("I#{r}:N#{r}")
s.add_row ['', 'Cordenadoria', 'Previsão', 'Entregue', 'Não Entregue',
'Status das Entregas', 'Conferência Documentos', '',
*PlanilhaEntregas::MOTIVOS],
style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, @h_cinza, @h_cinza, nil,
*Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
# Coordenadorias fixas do modelo + eventuais extras vindas dos dados.
nomes = PlanilhaEntregas::COORDENADORIAS + (coords.keys - PlanilhaEntregas::COORDENADORIAS)
total = @planilha.novo_bloco
nomes.each do |nome|
b = coords[nome] || @planilha.novo_bloco
total[:previsao] += b[:previsao]
total[:entregue] += b[:entregue]
total[:nao_entregue] += b[:nao_entregue]
total[:pendente] += b[:pendente]
b[:motivos].each { |m, q| total[:motivos][m] += q }
s.add_row ['', nome, b[:previsao], b[:entregue], b[:nao_entregue],
(b[:pendente].zero? ? 'Concluído' : 'Em andamento'), 'A iniciar', '',
*PlanilhaEntregas::MOTIVOS.map { |m| b[:motivos][m] }],
style: [nil, @celula, @cel_num, @cel_num, @cel_num,
(b[:pendente].zero? ? @st_verde : @st_laranja), @st_iniciar, nil,
*Array.new(PlanilhaEntregas::MOTIVOS.size, @cel_num)]
end
s.add_row ['', 'Total', total[:previsao], total[:entregue], total[:nao_entregue], '', '', '',
*PlanilhaEntregas::MOTIVOS.map { |m| total[:motivos][m] }],
style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, nil, nil, nil,
*Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
if total[:previsao].positive?
s.add_row ['', 'Performance %', 1,
total[:entregue].to_f / total[:previsao],
total[:nao_entregue].to_f / total[:previsao]],
style: [nil, @perf_rotulo, @perf_pct, @perf_pct, @perf_pct]
else
s.add_row ['', 'Performance %', '', '', ''], style: [nil, @perf_rotulo]
end
end
# ── ENTREGAS ─────────────────────────────────────────────────
# A..V direto da tabela gade (texto); W..Z do rastreio:
# W STATUS (completed/failed...), X ENTREGA (Sim/Não), Y DATA OCORRÊNCIA
# (data real do checkout), Z OCORRÊNCIA (motivo do insucesso).
# Cabeçalho como no modelo: A..T azul, U..Z (resultado) vermelho.
def aba_entregas(wb)
linhas = @planilha.linhas
azuis = PlanilhaEntregas::COLUNAS_GADE.size - 2 # A..T
wb.add_worksheet(name: 'ENTREGAS') do |sheet|
sheet.add_row(PlanilhaEntregas::CABECALHO,
style: Array.new(azuis, @cab_azul) +
Array.new(PlanilhaEntregas::CABECALHO.size - azuis, @cab_verm))
linhas.each do |l|
valores, tipos = linha_entrega(l)
sheet.add_row(valores, types: tipos)
end
sheet.auto_filter = "A1:Z#{linhas.size + 1}"
end
end
def linha_entrega(l)
status_r = l['rastreio_status'].to_s
entregue = if status_r == 'completed'
'Sim'
elsif Entrega::STATUS_FALHA.include?(status_r)
'Não'
else
''
end
data_ocorrencia = data_de(l['rastreio_checkout'])
valores = PlanilhaEntregas::COLUNAS_GADE.keys.map { |c| l[c].to_s } +
[status_r, entregue, data_ocorrencia || '', l['rastreio_observation'].to_s]
tipos = Array.new(PlanilhaEntregas::COLUNAS_GADE.size + 2, :string) +
[(data_ocorrencia ? :date : :string), :string]
[valores, tipos]
end
# ── SimpliRoute ──────────────────────────────────────────────
# Dump cru do rastreio (todas as visitas das NFs da operação), com o
# cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
# coluna correspondente sai vazio.
def aba_simpliroute(wb)
colunas = @planilha.colunas_rastreio
linhas = @planilha.linhas_rastreio
wb.add_worksheet(name: 'SimpliRoute') do |sheet|
sheet.add_row(colunas.keys)
linhas.each do |r|
sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' },
types: Array.new(colunas.size, :string))
end
sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
end
end
def texto(valor)
valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
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
end
end

View File

@@ -204,9 +204,9 @@
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %> <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4"> <div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<%# Gráficos diários (Chart.js) — flex-col p/ preencher a altura do card de motoristas %> <%# Gráfico de linha diário (Chart.js) %>
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 flex flex-col"> <div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex flex-wrap items-center justify-between gap-2 mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h2 class="text-lg font-semibold text-white">Evolução do custo</h2> <h2 class="text-lg font-semibold text-white">Evolução do custo</h2>
<p class="text-gray-400 text-sm">Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %></p> <p class="text-gray-400 text-sm">Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %></p>
@@ -215,39 +215,9 @@
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$) <span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
</div> </div>
</div> </div>
<div class="relative flex-1 min-h-56"> <div class="relative h-64">
<canvas id="grafico-diario"></canvas> <canvas id="grafico-diario"></canvas>
</div> </div>
<%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato %>
<div class="flex flex-wrap items-center justify-between gap-2 mt-6 mb-4 pt-6 border-t border-white/5">
<div>
<h2 class="text-lg font-semibold text-white">Pagamentos por dia</h2>
<p class="text-gray-400 text-sm">Valor pago aos motoristas por data de pagamento</p>
</div>
<% if @pag_pago_qtd.positive? %>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 bg-green-500 rounded-sm"></span> Pago no dia</span>
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 bg-gray-300 rounded"></span> Acumulado</span>
</div>
<% end %>
</div>
<% if @pag_pago_qtd.positive? %>
<div class="relative flex-1 min-h-48">
<canvas id="grafico-pagamentos-dia"></canvas>
</div>
<% else %>
<div class="flex-1 min-h-48 flex flex-col items-center justify-center text-gray-400">
<div class="text-3xl mb-2">💸</div>
<p class="text-sm">Nenhum pagamento realizado neste período</p>
<% if @pag_pend_valor.positive? %>
<p class="text-xs mt-1">
<span class="text-yellow-500 font-semibold"><%= moeda(@pag_pend_valor) %></span>
aguardando pagamento (<%= @pag_pend_qtd %> motorista(s))
</p>
<% end %>
</div>
<% end %>
</div> </div>
<%# Top motoristas %> <%# Top motoristas %>
@@ -378,9 +348,9 @@
<th class="py-2 pl-3 font-medium text-right">Valor</th> <th class="py-2 pl-3 font-medium text-right">Valor</th>
</tr> </tr>
</thead> </thead>
<tbody id="pagamentos-feitos-tbody"> <tbody>
<% @pagamentos_feitos.each do |cm| %> <% @pagamentos_feitos.each do |cm| %>
<tr class="border-b border-white/5 pagamento-feito-row"> <tr class="border-b border-white/5">
<td class="py-2.5 pr-3 text-gray-300 whitespace-nowrap"><%= l cm.pago_em.to_date, format: :short %></td> <td class="py-2.5 pr-3 text-gray-300 whitespace-nowrap"><%= l cm.pago_em.to_date, format: :short %></td>
<td class="py-2.5 px-3 text-white"><%= cm.motorista_nome %></td> <td class="py-2.5 px-3 text-white"><%= cm.motorista_nome %></td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
@@ -394,10 +364,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="pagamentos-feitos-paginacao" class="flex items-center justify-between mt-3 hidden">
<p class="text-gray-400 text-xs" id="pagamentos-feitos-info"></p>
<div class="flex gap-1" id="pagamentos-feitos-abas"></div>
</div>
<% else %> <% else %>
<p class="text-gray-400 text-sm py-3">Nenhum pagamento realizado neste período.</p> <p class="text-gray-400 text-sm py-3">Nenhum pagamento realizado neste período.</p>
<% end %> <% end %>
@@ -416,9 +382,9 @@
<th class="py-2 pl-3 font-medium text-right">Valor</th> <th class="py-2 pl-3 font-medium text-right">Valor</th>
</tr> </tr>
</thead> </thead>
<tbody id="pagamentos-pendentes-tbody"> <tbody>
<% @pagamentos_pendentes.each do |cm| %> <% @pagamentos_pendentes.each do |cm| %>
<tr class="border-b border-white/5 pagamento-pendente-row"> <tr class="border-b border-white/5">
<td class="py-2.5 pr-3 text-white"><%= cm.motorista_nome %></td> <td class="py-2.5 pr-3 text-white"><%= cm.motorista_nome %></td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
<%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao), <%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao),
@@ -430,10 +396,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div id="pagamentos-pendentes-paginacao" class="flex items-center justify-between mt-3 hidden">
<p class="text-gray-400 text-xs" id="pagamentos-pendentes-info"></p>
<div class="flex gap-1" id="pagamentos-pendentes-abas"></div>
</div>
<% else %> <% else %>
<p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. 🎉</p> <p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. 🎉</p>
<% end %> <% end %>
@@ -511,60 +473,6 @@
})(); })();
</script> </script>
<%# Paginação client-side das tabelas de pagamentos (10 por página) %>
<script>
(function () {
function paginarTabela(prefixo, classeLinha, rotulo) {
const tbody = document.getElementById(prefixo + '-tbody');
const nav = document.getElementById(prefixo + '-paginacao');
if (!tbody || !nav || nav.dataset.iniciado) return;
nav.dataset.iniciado = '1';
const porPagina = 10;
const linhas = Array.from(tbody.querySelectorAll('.' + classeLinha));
const paginas = Math.ceil(linhas.length / porPagina);
if (paginas <= 1) return;
const abas = document.getElementById(prefixo + '-abas');
const info = document.getElementById(prefixo + '-info');
nav.classList.remove('hidden');
function mostrarPagina(p) {
linhas.forEach(function (tr, i) {
tr.style.display = (i >= (p - 1) * porPagina && i < p * porPagina) ? '' : 'none';
});
const de = (p - 1) * porPagina + 1;
const ate = Math.min(p * porPagina, linhas.length);
info.textContent = de + '' + ate + ' de ' + linhas.length + ' ' + rotulo;
abas.querySelectorAll('button').forEach(function (b) {
const ativa = Number(b.dataset.pagina) === p;
b.className = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ' +
(ativa ? 'bg-[#f97316] text-white'
: 'bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white');
});
}
for (let p = 1; p <= paginas; p++) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = p;
btn.dataset.pagina = p;
btn.addEventListener('click', function () { mostrarPagina(p); });
abas.appendChild(btn);
}
mostrarPagina(1);
}
function initPaginacaoPagamentos() {
paginarTabela('pagamentos-feitos', 'pagamento-feito-row', 'pagamentos');
paginarTabela('pagamentos-pendentes', 'pagamento-pendente-row', 'pendências');
}
initPaginacaoPagamentos();
document.addEventListener('turbo:load', initPaginacaoPagamentos);
})();
</script>
<%# Filtro de operação — submete o form ao marcar/desmarcar %> <%# Filtro de operação — submete o form ao marcar/desmarcar %>
<script> <script>
(function () { (function () {
@@ -656,82 +564,6 @@
})(); })();
</script> </script>
<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %>
<script>
(function () {
const ctx = document.getElementById('grafico-pagamentos-dia');
if (!ctx || typeof Chart === 'undefined') return;
Chart.getChart(ctx)?.destroy();
const labels = <%= raw @grafico_pagamentos_dia[:labels].to_json %>;
const valores = <%= raw @grafico_pagamentos_dia[:valores].to_json %>;
const acumulado = <%= raw @grafico_pagamentos_dia[:acumulado].to_json %>;
const brl = (v) => `R$ ${v.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
label: 'Pago no dia',
data: valores,
backgroundColor: 'rgba(34, 197, 94, 0.65)',
hoverBackgroundColor: '#22c55e',
borderRadius: 4,
borderSkipped: false,
maxBarThickness: 28,
order: 2
},
{
label: 'Acumulado',
data: acumulado,
type: 'line',
borderColor: '#d1d5db',
borderWidth: 2,
borderDash: [5, 4],
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.3,
order: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0a0a0a',
borderColor: '#22c55e',
borderWidth: 1,
titleColor: '#9ca3af',
bodyColor: '#ffffff',
padding: 12,
callbacks: {
label: (c) => ` ${c.dataset.label}: ${brl(c.parsed.y)}`
}
}
},
scales: {
x: {
grid: { display: false, drawBorder: false },
ticks: { color: '#6b7280', font: { size: 11 }, maxTicksLimit: 10 }
},
y: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
ticks: { color: '#6b7280', font: { size: 11 }, callback: (v) => `R$ ${v.toFixed(0)}` }
}
}
}
});
})();
</script>
<%# Rosca de pagamentos (pago x a pagar, por valor) %> <%# Rosca de pagamentos (pago x a pagar, por valor) %>
<script> <script>
(function () { (function () {

View File

@@ -80,10 +80,7 @@
</script> </script>
</head> </head>
<%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na <body class="bg-[#0a0a0a] text-white min-h-screen">
página (aparecia como uma barra laranja "quebrando" o rodapé no celular);
tabelas largas já rolam nos próprios contêineres overflow-x-auto. %>
<body class="bg-[#0a0a0a] text-white min-h-screen overflow-x-hidden">
<%# ── Animação de abertura (uma vez, logo após o login) ── %> <%# ── Animação de abertura (uma vez, logo após o login) ── %>
<% if @mostrar_splash %> <% if @mostrar_splash %>

View File

@@ -1,139 +0,0 @@
<%# app/views/operacoes_dashboard/_espelho.html.erb %>
<%# Tabela espelho da planilha da operação: uma linha por NF com resultado,
ocorrência, motorista etc. Busca textual (param q) e paginação (param pg)
são server-side; o restante da URL (modo/operação/período/cross-filters)
é preservado nos links e no form. %>
<% pg_path = ->(n) { operacoes_planilha_path(request.query_parameters.merge('pg' => n)) } %>
<% resultado_badge = ->(status) {
if status == 'completed'
['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30']
elsif Entrega::STATUS_FALHA.include?(status)
['Falha', 'bg-red-500/10 text-red-400 border-red-500/30']
else
['Pendente', 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30']
end
} %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div>
<h2 class="text-lg font-semibold text-white">📋 Planilha da operação</h2>
<p class="text-gray-400 text-sm">Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...</p>
</div>
<form method="get" action="<%= operacoes_planilha_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
<% request.query_parameters.except('q', 'pg').each do |k, v| %>
<input type="hidden" name="<%= k %>" value="<%= v %>">
<% end %>
<div class="relative">
<span class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔎</span>
<input type="search" name="q" value="<%= @busca %>" placeholder="Pesquisar..."
class="pl-9 pr-3 py-2 bg-[#0a0a0a] border border-white/10 rounded-xl text-white text-sm
w-52 sm:w-72 focus:outline-none focus:border-orange-500 placeholder-gray-500">
</div>
<button type="submit"
class="px-3 py-2 rounded-xl text-sm bg-[#0a0a0a] border border-white/10 text-gray-300 hover:border-orange-500">
Buscar
</button>
<% if @busca.present? %>
<%= link_to '✕ Limpar', operacoes_planilha_path(request.query_parameters.except('q', 'pg')),
data: { turbo: false }, class: 'text-xs text-orange-400 hover:text-orange-300 whitespace-nowrap' %>
<% end %>
</form>
</div>
<% if @espelho_linhas.blank? %>
<p class="text-gray-400 text-sm py-8 text-center">
<%= @busca.present? ? %(Nenhuma entrega encontrada para "#{@busca}".) : 'Nenhuma entrega para exibir.' %>
</p>
<% else %>
<div class="overflow-x-auto">
<table class="w-full text-sm whitespace-nowrap">
<thead>
<tr class="text-gray-400 text-left border-b border-white/5">
<th class="py-2 pr-3 font-medium">Data</th>
<th class="py-2 px-3 font-medium">NF</th>
<% if @modo == 'global' %>
<th class="py-2 px-3 font-medium">Operação</th>
<% end %>
<th class="py-2 px-3 font-medium">Destinatário</th>
<th class="py-2 px-3 font-medium">Unidade</th>
<th class="py-2 px-3 font-medium">Motorista</th>
<th class="py-2 px-3 font-medium">Veículo</th>
<th class="py-2 px-3 font-medium">Resultado</th>
<th class="py-2 px-3 font-medium">Ocorrência</th>
<th class="py-2 pl-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
<% @espelho_linhas.each do |r| %>
<% rotulo, badge_css = resultado_badge.(r['status']) %>
<% data = r['checkout'] || r['planned_date'] %>
<% endereco = r['endereco_completo'].presence || r['address'].presence %>
<tr class="border-b border-white/5 hover:bg-white/[0.02]">
<td class="py-2.5 pr-3 text-gray-300">
<%= data.respond_to?(:strftime) ? data.strftime('%d/%m/%y %H:%M') : data.to_s.first(16) %>
</td>
<td class="py-2.5 px-3 text-white font-medium"><%= r['reference_id'] %></td>
<% if @modo == 'global' %>
<td class="py-2.5 px-3 text-gray-400"><%= r['operacao'] %></td>
<% end %>
<td class="py-2.5 px-3 text-gray-300" title="<%= endereco %>">
<%= (r['nome_completo'].presence || '—').truncate(28) %>
</td>
<td class="py-2.5 px-3 text-gray-400"><%= r['contact_name'].to_s.truncate(24).presence || '—' %></td>
<td class="py-2.5 px-3 text-gray-300"><%= r['driver'].presence || '—' %></td>
<td class="py-2.5 px-3 text-gray-400"><%= r['vehicle'].presence || '—' %></td>
<td class="py-2.5 px-3">
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold border <%= badge_css %>">
<%= rotulo %>
</span>
</td>
<td class="py-2.5 px-3 text-gray-400" title="<%= r['observation'] %>">
<%= r['observation'].to_s.truncate(32).presence || '—' %>
</td>
<td class="py-2.5 pl-3 text-gray-400"><%= r['status_gade'].presence || '—' %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%# Paginação: « 1 … janela em torno da atual … última » %>
<div class="flex flex-wrap items-center justify-between gap-2 mt-3">
<p class="text-gray-400 text-xs">
<%= (@espelho_pagina - 1) * OperacoesDashboardController::ESPELHO_POR_PAGINA + 1 %><%=
[(@espelho_pagina) * OperacoesDashboardController::ESPELHO_POR_PAGINA, @espelho_total].min %>
de <%= @espelho_total %> entregas
</p>
<% if @espelho_paginas > 1 %>
<div class="flex items-center gap-1">
<% if @espelho_pagina > 1 %>
<%= link_to '', pg_path.(@espelho_pagina - 1), data: { turbo: false },
class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %>
<% end %>
<% janela = ([1, @espelho_pagina - 2].max..[@espelho_paginas, @espelho_pagina + 2].min).to_a %>
<% paginas = ([1] + janela + [@espelho_paginas]).uniq %>
<% anterior = 0 %>
<% paginas.each do |p| %>
<% if p > anterior + 1 %>
<span class="px-1 text-gray-500 text-xs">…</span>
<% end %>
<% anterior = p %>
<% if p == @espelho_pagina %>
<span class="px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#f97316] text-white"><%= p %></span>
<% else %>
<%= link_to p, pg_path.(p), data: { turbo: false },
class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %>
<% end %>
<% end %>
<% if @espelho_pagina < @espelho_paginas %>
<%= link_to '', pg_path.(@espelho_pagina + 1), data: { turbo: false },
class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@@ -88,28 +88,16 @@
<% end %> <% end %>
</div> </div>
<%# ── Abas de modo + atalho para a planilha ─────────────── %> <%# ── Abas de modo ──────────────────────────────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 pb-3"> <div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3">
<div class="flex flex-wrap items-center gap-2"> <% modos.each do |m, label| %>
<% modos.each do |m, label| %> <% ativo = @modo == m %>
<% ativo = @modo == m %> <%= link_to label,
<%= link_to label, operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)),
operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), data: { turbo: false },
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'}" %>
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 %>
<% end %>
</div>
<%# Planilha da operação (tabela espelho) — leva o contexto atual junto %>
<% planilha_ctx = request.query_parameters
.slice('f_driver', 'f_sts', 'f_status', 'f_obs', 'f_resultado', 'f_data')
.merge(modo: (@modo == 'global' ? 'global' : 'operacao'), inicio: ini_iso, fim: fim_iso) %>
<% planilha_ctx[:operacao] = @operacao if @operacao %>
<%= link_to '📋 Planilha da operação', operacoes_planilha_path(planilha_ctx),
data: { turbo: false },
class: 'px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap bg-[#1a1a1a]
text-orange-400 border border-orange-500/40 hover:bg-orange-500 hover:text-black' %>
</div> </div>
<% if @tabelas_validas.blank? %> <% if @tabelas_validas.blank? %>

View File

@@ -1,108 +0,0 @@
<%# app/views/operacoes_dashboard/planilha.html.erb %>
<%# Página da Planilha da Operação: tabela espelho (uma linha por NF) com busca
e paginação. Chega-se aqui pelo botão no Dashboard de Operações, que passa
o contexto (operação/modo global/período/cross-filters) na URL. %>
<% content_for :head do %>
<meta name="turbo-cache-control" content="no-cache">
<% end %>
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
<div class="space-y-6">
<%# ── Cabeçalho ─────────────────────────────────────────── %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">📋 Planilha da Operação</h1>
<p class="text-gray-400 text-sm mt-0.5">
<% if @modo == 'global' %>
🌐 Global · <%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
<% elsif @metricas&.data_inicio %>
<%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> <%= @metricas.data_fim.strftime('%d/%m/%Y') %>
<% elsif @operacao %>
<%= Operacao.label(@operacao) %>
<% end %>
</p>
</div>
<%= link_to '← Dashboard de Operações',
operacoes_dashboard_path({ modo: @modo, operacao: @operacao, inicio: ini_iso, fim: fim_iso }.compact),
data: { turbo: false },
class: 'px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %>
</div>
<%# ── Seleção de escopo: operação única ou global ───────── %>
<div class="flex flex-wrap items-center gap-2">
<% pills = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global' } %>
<% pills.each do |m, label| %>
<% ativo = @modo == m %>
<%= link_to label,
operacoes_planilha_path({ modo: m, operacao: @operacao, inicio: ini_iso, fim: fim_iso, q: @busca.presence }.compact),
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 @modo == 'operacao' %>
<form method="get" action="<%= operacoes_planilha_path %>" data-turbo="false" class="flex items-center gap-2">
<input type="hidden" name="modo" value="operacao">
<input type="hidden" name="inicio" value="<%= ini_iso %>">
<input type="hidden" name="fim" value="<%= fim_iso %>">
<% if @busca.present? %><input type="hidden" name="q" value="<%= @busca %>"><% end %>
<select name="operacao" id="planilha-operacao"
class="bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500 min-w-[240px]">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @operacao %>><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</form>
<%# Planilha Entregas preenchida (modelo entregue ao cliente) %>
<% if @operacao %>
<%= link_to '⬇️ Baixar Excel preenchido', operacoes_planilha_baixar_path(operacao: @operacao),
data: { turbo: false },
title: 'Planilha Entregas da operação com status/ocorrências preenchidos pelo rastreio',
class: 'px-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %>
<% end %>
<% end %>
</div>
<%# ── Filtros ativos herdados do dashboard (chips removíveis) ── %>
<% if @chips.any? %>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-gray-500 uppercase tracking-wider">Filtros ativos:</span>
<% @chips.each do |chip| %>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-300 text-xs">
<%= chip[:rotulo] %>: <strong class="text-white"><%= chip[:display] || chip[:valor] %></strong>
<%= link_to '✕', operacoes_planilha_path(request.query_parameters.except(chip[:param], 'pg')),
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
</span>
<% end %>
<%= link_to 'Limpar tudo',
operacoes_planilha_path(request.query_parameters.except('pg', *@chips.map { |c| c[:param] })),
data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %>
</div>
<% end %>
<%# ── Tabela espelho ─────────────────────────────────────── %>
<% if @metricas %>
<%= render 'espelho' %>
<% else %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400">
Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).
</div>
<% end %>
</div>
<%# Troca de operação recarrega a planilha %>
<script>
(function () {
const sel = document.getElementById('planilha-operacao');
if (sel) sel.addEventListener('change', function () { sel.form.requestSubmit(); });
})();
</script>

View File

@@ -18,10 +18,6 @@ Rails.application.routes.draw do
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
# Planilha da operação (tabela espelho pesquisável, página própria)
get '/dashboard/operacoes/planilha', to: 'operacoes_dashboard#planilha', as: :operacoes_planilha
# Download .xlsx da planilha Entregas preenchida (modelo entregue ao cliente)
get '/dashboard/operacoes/planilha/baixar', to: 'operacoes_dashboard#baixar_planilha', as: :operacoes_planilha_baixar
# Painel motorista # Painel motorista
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard