Compare commits

..

19 Commits

Author SHA256 Message Date
e001a48e1a Atualização READ.me 2026-07-13 19:12:07 -03:00
c0ef42a116 Modificação 1 para 1 até os estilos 2026-07-13 19:03:07 -03:00
04efe871f0 Modificação da Planilha baixarda para ficar 1 para 1 2026-07-13 18:43:16 -03:00
1d4bed785d Adição da opção de baixar a planilha preenchida para o cliente 2026-07-13 18:33:17 -03:00
75d82227c9 Inclusão de uma tabela de acompanhamento 2026-07-13 18:12:04 -03:00
bd61b32edd Atualização geral de inconsistencias da UX 2026-07-13 17:58:52 -03:00
a08a0712f0 Atualização geral de inconsistencias da UX 2026-07-13 17:49:12 -03:00
8e73bbe0ed Arrumar a tela do dashboard e correção dos graficos 2026-07-13 17:35:04 -03:00
b55b602eb5 Arrumar a tela do dashboard 2026-07-13 17:24:26 -03:00
962f79266a Arrumar a tela de pagamento pendente colocando paginação 2026-07-13 17:12:55 -03:00
df9256c556 Arrumar a tela de pagamento colocando paginação 2026-07-13 17:06:48 -03:00
1dd2610c1f Refatoraão tela de Edição de apontamento 2026-07-08 15:30:53 -03:00
96b85adbe9 Estilização da edição de apontamento 2026-07-08 15:14:36 -03:00
bb9d55675a Implantação do recurso de Gerar planilha de carga simplerout e Correção das imagens do mapa de aconpanhamento 2026-07-08 12:26:23 -03:00
3f56e87a33 fix: lat/long com ponto decimal (aceita fonte com vírgula)
O rastreio pode trazer a coordenada como texto com vírgula (-23,62); troca
por ponto antes de converter, garantindo a saída numérica com ponto no xlsx
e evitando perder a geo de recorrentes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:15:50 -03:00
44ba0a93c6 Implantação do recurso de Gerar planilha de carga simplerout 2026-07-08 12:12:50 -03:00
07fda079aa fix(docker): instala gems faltantes no boot (bundle check || bundle install)
O bundle fica no volume nomeado bundle_cache, que sombreia os gems da
imagem — adicionar uma gem (ex.: caxlsx) causava Bundler::GemNotFound ao
subir, mesmo com --build, pois o volume persistente não recebe os gems
novos. Rodar bundle install no boot resolve de forma idempotente.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:25:54 -03:00
53341ac22e Implantação do recurso de Gerar planilha de carga simplerout 2026-07-08 11:20:59 -03:00
b2695cd920 feat: gerar planilha de carga do SimpliRoute (download .xlsx)
Nova tela ADM/gerente (Administração > Planilha SimpliRoute) que gera a
planilha de importação (.xlsx) de uma operação vigente, no formato do
pt_br_example.xlsx. Preenche Latitude/Longitude reaproveitando a
geocodificação do mês anterior da mesma operação (casa nome + endereço +
supervisão; geo vem do rastreio db_reem_simplerout_2026). Pacientes novos
saem sem lat/long.

- SimpliRoute::PlanilhaCarga: monta linhas + resolve match do mês anterior
- SimpliRoute::PlanilhaCargaXlsx: builder caxlsx com cabeçalho verbatim
- Admin::PlanilhasSimpliRouteController (show/baixar) + policy admin/gerente
- Gem caxlsx (rebuild do container atualiza o Gemfile.lock)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:05:24 -03:00
32 changed files with 8424 additions and 166 deletions

View File

@@ -26,6 +26,9 @@ gem "dotenv-rails"
# PDF # PDF
gem "prawn" gem "prawn"
gem "prawn-table" gem "prawn-table"
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
gem "caxlsx"
gem "rqrcode" # QR code no holerite (login rápido motorista) gem "rqrcode" # QR code no holerite (login rápido motorista)
gem "chunky_png" # renderiza QR como PNG para embed no Prawn gem "chunky_png" # renderiza QR como PNG para embed no Prawn

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

123
README.md
View File

@@ -1366,3 +1366,126 @@ 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

@@ -48,7 +48,17 @@ class Admin::EdicaoLancamentosController < ApplicationController
notes: visita['notes'], notes: visita['notes'],
checkout_time: visita['checkout_time'], checkout_time: visita['checkout_time'],
checkout_latitude: visita['checkout_latitude'], checkout_latitude: visita['checkout_latitude'],
checkout_longitude: visita['checkout_longitude'] checkout_longitude: visita['checkout_longitude'],
planned_date: visita['planned_date'],
contato: visita['contact_name'],
telefone: visita['contact_phone'],
# Fotos do card: fachada vem do rastreio (mesma foto do mapa de operações);
# pictures/assinatura vêm da API. Só URLs http(s) chegam ao <img>.
foto_fachada: url_imagem(entrega.try(:foto_da_fachada)),
pictures: Array(visita['pictures']).filter_map { |u| url_imagem(u) },
signature: url_imagem(visita['signature']),
motorista: entrega.driver,
veiculo: entrega.vehicle
}, },
motivos: motivos_seguros motivos: motivos_seguros
} }
@@ -59,20 +69,26 @@ class Admin::EdicaoLancamentosController < ApplicationController
end end
# PATCH /admin/edicao_lancamento/atualizar # PATCH /admin/edicao_lancamento/atualizar
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
def atualizar def atualizar
authorize :edicao_lancamento authorize :edicao_lancamento
id = params[:visit_id].to_s.strip id = params[:visit_id].to_s.strip
return redirect_to(admin_edicao_lancamento_path, alert: 'Visita não identificada.') if id.blank? return responder_erro('Visita não identificada.') if id.blank?
anterior = client.visita(id) anterior = client.visita(id)
attrs = mudancas(anterior) attrs = mudancas(anterior)
# Confirmar um valor idêntico não é erro no inline edit — só não há o que fazer.
if attrs.empty? if attrs.empty?
return redirect_to(admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.') return respond_to do |format|
format.json { render json: { ok: true, campos: [], visita: {} } }
format.html { redirect_to admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.' }
end
end end
if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status']) if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
return redirect_to(admin_edicao_lancamento_path, alert: 'Status inválido.') return responder_erro('Status inválido.')
end end
atualizada = client.atualizar_visita(id, attrs) atualizada = client.atualizar_visita(id, attrs)
@@ -87,17 +103,41 @@ class Admin::EdicaoLancamentosController < ApplicationController
request: request request: request
) )
respond_to do |format|
format.json do
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
end
format.html do
redirect_to admin_edicao_lancamento_path, redirect_to admin_edicao_lancamento_path,
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \ notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
'O painel refletirá na próxima sincronização.' 'O painel refletirá na próxima sincronização.'
end
end
rescue SimpliRoute::Error => e rescue SimpliRoute::Error => e
redirect_to admin_edicao_lancamento_path, alert: "Não foi possível atualizar: #{e.message}" responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
end end
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON (best-effort). # GET /admin/edicao_lancamento/historico?visit_id=123 → JSON com as DUAS fontes:
# o histórico da API do SimpliRoute (best-effort, costuma vir vazio) e a nossa
# trilha do AuditoriaLog (quem editou, quando, de→para).
def historico def historico
authorize :edicao_lancamento authorize :edicao_lancamento
render json: { ok: true, historico: client.historico(params[:visit_id]) }
id = params[:visit_id].to_s.strip
interno = AuditoriaLog.por_entidade('SimpliRoute::Visita')
.where(entidade_id: id)
.includes(:user).recentes.limit(50)
.map do |log|
{
usuario: log.user&.nome_display || '—',
acao: log.acao,
quando: log.created_at.in_time_zone.strftime('%d/%m/%Y %H:%M'),
de: log.dados_anteriores,
para: log.dados_novos
}
end
render json: { ok: true, api: client.historico(id), interno: interno }
end end
private private
@@ -163,6 +203,21 @@ class Admin::EdicaoLancamentosController < ApplicationController
render json: { ok: false, erro: msg }, status: status render json: { ok: false, erro: msg }, status: status
end end
# Erro do atualizar nos dois formatos (JSON p/ inline edit, HTML p/ fallback).
def responder_erro(msg, status: :unprocessable_entity)
respond_to do |format|
format.json { render json: { ok: false, erro: msg }, status: status }
format.html { redirect_to admin_edicao_lancamento_path, alert: msg }
end
end
# Só aceita URL http(s) — evita injetar lixo no <img src> do card (mesmo
# filtro do foto_url do OperacaoMetricas).
def url_imagem(valor)
url = valor.to_s.strip
url.match?(%r{\Ahttps?://}i) ? url : nil
end
# Sem token não há o que fazer — avisa e volta. # Sem token não há o que fazer — avisa e volta.
def garantir_configurado def garantir_configurado
return if SimpliRoute.configurado? return if SimpliRoute.configurado?

View File

@@ -0,0 +1,43 @@
# app/controllers/admin/planilhas_simpliroute_controller.rb
#
# Gera a planilha de carga do SimpliRoute (.xlsx) a partir de UMA operação
# vigente. Lat/long são preenchidas com o histórico do mês anterior
# (SimpliRoute::PlanilhaCarga). Somente leitura das bases.
class Admin::PlanilhasSimpliRouteController < ApplicationController
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
def show
authorize :planilha_simpli_route
@operacoes_agrupadas = Operacao.agrupadas_por_mes
end
# GET /admin/planilha_simpli_route/baixar?tabela=gade_entregas_...
def baixar
authorize :planilha_simpli_route
tabela = params[:tabela].to_s
unless Operacao.valida?(tabela)
return redirect_to(admin_planilha_simpli_route_path, alert: 'Selecione uma operação válida.')
end
carga = SimpliRoute::PlanilhaCarga.new(tabela)
linhas = carga.linhas
if linhas.empty?
return redirect_to(admin_planilha_simpli_route_path,
alert: "A operação #{carga.label} não tem registros.")
end
binario = SimpliRoute::PlanilhaCargaXlsx.new(linhas).gerar
send_data binario,
filename: "carga_simpliroute_#{nome_arquivo(carga.label)}.xlsx",
type: XLSX_MIME,
disposition: 'attachment'
end
private
def nome_arquivo(label)
label.to_s.parameterize(separator: '_').presence || 'operacao'
end
end

View File

@@ -165,6 +165,9 @@ 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
@@ -241,6 +244,28 @@ 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,6 +7,11 @@
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?
@@ -34,8 +39,67 @@ 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

@@ -0,0 +1,9 @@
# app/policies/planilha_simpliroute_policy.rb
#
# Geração da planilha de carga do SimpliRoute — tarefa de preparação de operação.
# Liberada para admin e gerente (quem prepara as cargas). Policy "headless"
# (autorizada com o símbolo :planilha_simpliroute).
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
def show? = admin_ou_gerente?
def baixar? = admin_ou_gerente?
end

View File

@@ -45,6 +45,18 @@ 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

@@ -0,0 +1,242 @@
# 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

@@ -0,0 +1,211 @@
# 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

@@ -0,0 +1,173 @@
# app/services/simpli_route/planilha_carga.rb
#
# Monta as linhas da planilha de carga do SimpliRoute a partir de UMA operação
# vigente (tabela gade_entregas_*). Preenche Latitude/Longitude reaproveitando a
# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço +
# supervisão e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo.
#
# Tabelas de operação e rastreio são SOMENTE LEITURA (apenas SELECT).
module SimpliRoute
class PlanilhaCarga
# Colunas da tabela de operação usadas (nem toda operação tem todas — as que
# faltam viram NULL, no mesmo padrão do OperacaoMetricas#selects_gade).
COLUNAS_OP = %w[
nota_fiscal nome_completo endereco_sem_complemento complemento supervisao telefones
].freeze
# Colunas que formam a chave de match com o histórico.
COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento supervisao].freeze
# Mês (número) -> grafias aceitas nos nomes de tabela (abreviação e extenso).
MESES_GRAFIAS = {
1 => %w[jan janeiro], 2 => %w[fev fevereiro], 3 => %w[mar marco março],
4 => %w[abr abril], 5 => %w[mai maio], 6 => %w[jun junho], 7 => %w[jul julho],
8 => %w[ago agosto], 9 => %w[set setembro], 10 => %w[out outubro],
11 => %w[nov novembro], 12 => %w[dez dezembro]
}.freeze
GRAFIA_PARA_NUM = MESES_GRAFIAS.each_with_object({}) do |(num, grafias), h|
grafias.each { |g| h[g] = num }
end.freeze
attr_reader :tabela
def initialize(tabela)
@tabela = tabela.to_s
raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela)
end
# Nome amigável da operação (para o nome do arquivo).
def label
Operacao.label(@tabela)
end
# A tabela do mês anterior foi encontrada? (para avisar quando não houver histórico)
def mes_anterior_encontrado?
tabela_anterior.present?
end
# Array de hashes no formato das colunas da planilha (A..N).
def linhas
geo = lookup_geo
registros_operacao.map do |r|
lat, lng = geo[chave(r)] || [nil, nil]
{
titulo: "NF #{r['nota_fiscal']} - #{r['nome_completo']}".strip,
endereco: r['endereco_sem_complemento'],
carga: 1,
janela_ini: '08:00',
janela_fim: '18:00',
tempo: 10,
anotacoes: r['complemento'],
latitude: lat,
longitude: lng,
referencia: r['nota_fiscal'],
contato: r['supervisao'],
telefone: r['telefones']
}
end
end
private
def conn
ActiveRecord::Base.connection
end
# Linhas cruas da operação vigente.
def registros_operacao
sql = "SELECT #{selects(@tabela, COLUNAS_OP)} " \
"FROM #{conn.quote_table_name(@tabela)} g " \
"WHERE g.nota_fiscal IS NOT NULL"
conn.select_all(sql).to_a
end
# SELECT com as colunas existentes; as ausentes viram NULL com o mesmo alias.
# As chaves de COLUNAS_* são uma whitelist fixa (sem entrada do usuário no SQL).
def selects(tabela, colunas)
existentes = conn.columns(tabela).map(&:name)
colunas.map do |c|
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
end.join(', ')
end
# Hash { chave_normalizada => [lat, lng] } vindo do mês anterior.
def lookup_geo
prev = tabela_anterior
return {} if prev.nil?
# Sem as colunas-chave não dá pra casar com segurança.
cols_prev = conn.columns(prev).map(&:name)
return {} unless COLUNAS_CHAVE.all? { |c| cols_prev.include?(c) }
rastreio = conn.quote_table_name(Entrega.table_name)
sql = <<~SQL
WITH ultimo AS (
SELECT reference_id, latitude, longitude,
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
FROM #{rastreio}
WHERE reference_id IS NOT NULL
)
SELECT #{selects(prev, COLUNAS_CHAVE)}, r.latitude AS lat, r.longitude AS lng
FROM #{conn.quote_table_name(prev)} g
INNER JOIN ultimo r ON r.reference_id::text = g.nota_fiscal AND r.rn = 1
WHERE r.latitude IS NOT NULL AND r.longitude IS NOT NULL
SQL
conn.select_all(sql).to_a.each_with_object({}) do |r, memo|
lat = to_coord(r['lat'])
lng = to_coord(r['lng'])
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
memo[chave(r)] = [lat, lng] # o INNER JOIN já ordena pelo checkout mais recente
end
end
# Chave de match: nome | endereço | supervisão, normalizados.
def chave(row)
COLUNAS_CHAVE.map { |c| normaliza(row[c]) }.join('|')
end
def normaliza(valor)
valor.to_s.strip.gsub(/\s+/, ' ').upcase
end
# Coordenada como Float (com PONTO). O rastreio pode trazer texto com vírgula
# decimal ("-23,62"); coordenadas não usam separador de milhar, então trocar
# a vírgula por ponto é seguro e garante a saída numérica (ponto) no .xlsx.
def to_coord(valor)
Float(valor.to_s.strip.tr(',', '.'))
rescue ArgumentError, TypeError
nil
end
# Nome da tabela do mês imediatamente anterior, da MESMA operação, se existir.
def tabela_anterior
return @tabela_anterior if defined?(@tabela_anterior)
@tabela_anterior = calcular_tabela_anterior
end
def calcular_tabela_anterior
tokens = @tabela.delete_prefix(Operacao::PREFIXO).split('_')
idx = tokens.index { |t| GRAFIA_PARA_NUM.key?(t.downcase) }
return nil if idx.nil?
num = GRAFIA_PARA_NUM[tokens[idx].downcase]
tem_ano = tokens[idx + 1].to_s.match?(/\A\d{4}\z/)
ano = tem_ano ? tokens[idx + 1].to_i : nil
prev_num = num == 1 ? 12 : num - 1
prev_ano = ano && num == 1 ? ano - 1 : ano
validas = Operacao.nomes_validos
MESES_GRAFIAS[prev_num].each do |grafia|
novo = tokens.dup
novo[idx] = grafia
novo[idx + 1] = prev_ano.to_s if tem_ano
candidato = Operacao::PREFIXO + novo.join('_')
return candidato if validas.include?(candidato)
end
nil
end
end
end

View File

@@ -0,0 +1,93 @@
# app/services/simpli_route/planilha_carga_xlsx.rb
#
# Gera o binário .xlsx da carga do SimpliRoute a partir das linhas montadas pelo
# PlanilhaCarga, replicando EXATAMENTE o cabeçalho do arquivo de referência
# (Imagens para implantação/pt_br_example.xlsx) — 26 colunas A..Z.
require 'caxlsx'
module SimpliRoute
class PlanilhaCargaXlsx
# Cabeçalho verbatim do exemplo (inclui os espaços duplos em A e B).
CABECALHO = [
"Titulo* Solicitado",
"endereço completo* Solicitado",
"Carga",
"Janela de horário inicial",
"Janela de horário final",
"Tempo de serviço",
"Anotações",
"Latitude",
"Longitude",
"Identificação de referência",
"Habilidade necessária",
"Habilidade opcional",
"Pessoa de contato",
"Telefone de contato",
"Janela de horário inicial 2",
"Janela de horário final 2",
"Capacidade 2",
"Capacidade 3",
"Prioridade",
"SMS",
"Correio eletrônico de contato",
"Carga pick",
"Carga pick 2",
"Carga pick 3",
"Data agendada",
"Tipo de visita"
].freeze
# Colunas O..Z (14ª em diante) ficam vazias — 12 células.
VAZIAS_FINAIS = Array.new(CABECALHO.size - 14, '').freeze
def initialize(linhas)
@linhas = linhas
end
# String binária do .xlsx.
def gerar
pkg = Axlsx::Package.new
pkg.workbook.add_worksheet(name: 'Carga') do |sheet|
sheet.add_row(CABECALHO)
@linhas.each do |l|
valores, tipos = linha(l)
sheet.add_row(valores, types: tipos)
end
end
pkg.to_stream.read
end
private
# Monta [valores, tipos] de uma linha (A..Z). Lat/long numéricos quando há
# match; D/E como texto ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo).
def linha(l)
lat = l[:latitude]
lng = l[:longitude]
valores = [
l[:titulo].to_s, # A
l[:endereco].to_s, # B
l[:carga], # C
l[:janela_ini], # D
l[:janela_fim], # E
l[:tempo], # F
l[:anotacoes].to_s, # G
(lat.nil? ? '' : lat), # H
(lng.nil? ? '' : lng), # I
l[:referencia].to_s, # J
'', '', # K, L
l[:contato].to_s, # M
l[:telefone].to_s, # N
*VAZIAS_FINAIS # O..Z
]
tipos = [
:string, :string, :integer, :string, :string, :integer, :string,
(lat.nil? ? :string : :float), # H
(lng.nil? ? :string : :float), # I
:string, :string, :string, :string, :string,
*Array.new(VAZIAS_FINAIS.size, :string)
]
[valores, tipos]
end
end
end

View File

@@ -1,10 +1,18 @@
<%# app/views/admin/edicao_lancamentos/show.html.erb %> <%# app/views/admin/edicao_lancamentos/show.html.erb %>
<div class="space-y-6" data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"> <%# Card estilizado com foto da fachada, edição inline (click-to-edit, salva por
campo direto na API do SimpliRoute) e histórico duplo (nosso AuditoriaLog +
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
<div class="space-y-6" id="edicao-app"
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
data-csrf="<%= form_authenticity_token %>"
data-motivos="<%= @motivos.to_json %>">
<%# Header %> <%# Header %>
<div> <div>
<h1 class="text-2xl font-bold text-white">Editar Lançamento</h1> <h1 class="text-2xl font-bold text-white">Editar Lançamento</h1>
<p class="text-gray-400 text-sm mt-0.5">Corrige status, motivo e comentários de uma entrega direto no SimpliRoute</p> <p class="text-gray-400 text-sm mt-0.5">Corrige status, motivo e dados de checkout de uma entrega direto no SimpliRoute</p>
</div> </div>
<%# Flash %> <%# Flash %>
@@ -13,9 +21,9 @@
<%# Aviso de sincronização %> <%# Aviso de sincronização %>
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2"> <div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none">⚠️</span> <span class="text-base leading-none">⚠️</span>
<p>As alterações são gravadas no <strong>SimpliRoute na hora</strong>, mas o painel e as <p>As alterações são gravadas no <strong>SimpliRoute na hora</strong> (cada campo salva ao confirmar ✓),
consolidações só refletem <strong>após a próxima sincronização</strong> da base de rastreio. mas o painel e as consolidações só refletem <strong>após a próxima sincronização</strong> da base de
Toda alteração fica registrada na auditoria.</p> rastreio. Toda alteração fica registrada na auditoria.</p>
</div> </div>
<%# Busca por NF %> <%# Busca por NF %>
@@ -35,156 +43,380 @@
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p> <p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
</div> </div>
<%# Formulário de edição (oculto até achar a visita) %> <%# Card do lançamento (preenchido via JS após a busca) %>
<%= form_with url: atualizar_admin_edicao_lancamento_path, method: :patch, <div id="card" class="hidden bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
html: { id: 'form-edicao', class: 'hidden bg-[#111] border border-white/5 rounded-2xl p-5 space-y-5' } do %>
<input type="hidden" name="visit_id" id="f-visit-id">
<%# Cabeçalho da visita (somente leitura) %> <%# Header do card %>
<div class="border-b border-white/5 pb-4"> <div class="p-5 border-b border-white/5 flex flex-wrap items-start justify-between gap-3">
<p class="text-white font-semibold" id="f-titulo">—</p> <div class="min-w-0">
<p class="text-gray-500 text-sm" id="f-endereco">—</p> <div class="flex items-center gap-3 flex-wrap">
<p class="text-gray-600 text-xs mt-1">NF <span id="f-nf">—</span> · visita <span id="f-id">—</span></p> <h2 id="c-titulo" class="text-white font-bold text-lg truncate">—</h2>
<span id="c-badge" class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-gray-800 text-gray-400">—</span>
</div> </div>
<p id="c-endereco" class="text-gray-400 text-sm mt-1">—</p>
<div class="grid sm:grid-cols-2 gap-4"> <div class="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
<%# Status %> <span class="px-2 py-0.5 rounded-full bg-white/5">NF <span id="c-nf">—</span></span>
<div> <span class="px-2 py-0.5 rounded-full bg-white/5">Visita <span id="c-id">—</span></span>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Status</label> <span class="px-2 py-0.5 rounded-full bg-white/5">🚚 <span id="c-motorista">—</span></span>
<select name="status" id="f-status" <span class="px-2 py-0.5 rounded-full bg-white/5">🚗 <span id="c-veiculo">—</span></span>
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white <span class="px-2 py-0.5 rounded-full bg-white/5">📞 <span id="c-telefone">—</span></span>
focus:outline-none focus:border-orange-500 min-h-[48px]">
<% { 'completed' => 'Completa', 'failed' => 'Falha', 'pending' => 'Pendente',
'partial' => 'Parcial', 'canceled' => 'Cancelada' }.each do |v, label| %>
<option value="<%= v %>"><%= label %></option>
<% end %>
</select>
</div>
<%# Motivo (só faz sentido em falha) %>
<div id="wrap-motivo">
<label class="block text-sm font-medium text-gray-300 mb-1.5">Motivo (insucesso)</label>
<select name="checkout_observation" id="f-motivo"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
<option value="">— sem motivo —</option>
<% @motivos.each do |m| %>
<option value="<%= m['id'] %>"><%= m['label'] %></option>
<% end %>
</select>
</div> </div>
</div> </div>
<button id="btn-historico" type="button"
<%# Comentário livre %> class="px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-200
<div> text-sm font-semibold rounded-xl transition-colors whitespace-nowrap">
<label class="block text-sm font-medium text-gray-300 mb-1.5">Comentário do checkout</label> 🕑 Histórico
<textarea name="checkout_comment" id="f-comment" rows="2"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500"></textarea>
</div>
<%# Observações internas %>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Observações (notes)</label>
<textarea name="notes" id="f-notes" rows="2"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500"></textarea>
</div>
<%# Data/geo do checkout (avançado) %>
<details class="rounded-xl border border-white/5 bg-[#0d0d0d] p-4">
<summary class="cursor-pointer text-sm text-gray-400">Data e localização do checkout (avançado)</summary>
<div class="grid sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-xs text-gray-400 mb-1.5">Data/hora</label>
<input type="datetime-local" name="checkout_time" id="f-checkout-time"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">Latitude</label>
<input type="text" inputmode="decimal" name="checkout_latitude" id="f-lat"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">Longitude</label>
<input type="text" inputmode="decimal" name="checkout_longitude" id="f-lng"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
</div>
</div>
</details>
<div class="flex justify-end pt-2">
<button type="submit"
class="px-6 py-3 bg-green-600 hover:bg-green-500 text-white font-semibold rounded-xl
transition-colors min-h-[48px]">
Salvar no SimpliRoute
</button> </button>
</div> </div>
<% end %>
<div class="grid lg:grid-cols-3 gap-0">
<%# Coluna esquerda — fotos %>
<div class="p-5 border-b lg:border-b-0 lg:border-r border-white/5 space-y-3">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Foto da fachada</p>
<div id="c-foto-wrap"></div>
<div id="c-extras-wrap" class="hidden">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mt-4 mb-2">Fotos do SimpliRoute</p>
<div id="c-extras" class="grid grid-cols-3 gap-2"></div>
</div>
</div>
<%# Coluna direita — campos click-to-edit %>
<div class="lg:col-span-2 p-5">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
Dados do lançamento <span class="normal-case font-normal text-gray-600">— clique num valor para editar</span>
</p>
<div id="c-campos" class="grid sm:grid-cols-2 gap-3"></div>
</div>
</div>
</div>
<%# Modal de histórico %>
<div id="modal-hist" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
<div id="modal-hist-overlay" class="absolute inset-0 bg-black/70"></div>
<div class="relative bg-[#151515] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-white/5">
<h3 class="text-white font-bold">🕑 Histórico de alterações</h3>
<button id="modal-hist-fechar" type="button"
class="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300">✕</button>
</div>
<div id="modal-hist-corpo" class="p-4 overflow-y-auto space-y-5 text-sm">
<p class="text-gray-400">Carregando…</p>
</div>
</div>
</div>
</div> </div>
<script> <script>
(function () { (function () {
const wrap = document.querySelector('[data-buscar-url]'); const app = document.getElementById('edicao-app');
const buscarU = wrap.dataset.buscarUrl; const URLS = {
const campo = document.getElementById('campo-nf'); buscar: app.dataset.buscarUrl,
const btn = document.getElementById('btn-buscar'); atualizar: app.dataset.atualizarUrl,
const erro = document.getElementById('busca-erro'); historico: app.dataset.historicoUrl
const loading = document.getElementById('busca-loading'); };
const form = document.getElementById('form-edicao'); const CSRF = app.dataset.csrf;
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
const set = (id, v) => { document.getElementById(id).value = (v ?? ''); }; let visita = null; // estado atual do lançamento carregado
const txt = (id, v) => { document.getElementById(id).textContent = (v ?? '—'); };
function toggleMotivo() { // ── Config dos campos editáveis ─────────────────────────────
const falha = document.getElementById('f-status').value === 'failed'; const STATUS = {
document.getElementById('wrap-motivo').style.opacity = falha ? '1' : '0.45'; completed: { label: 'Completa', cls: 'bg-green-600 text-white', icone: '✅' },
} failed: { label: 'Falha', cls: 'bg-red-600 text-white', icone: '❌' },
document.getElementById('f-status').addEventListener('change', toggleMotivo); pending: { label: 'Pendente', cls: 'bg-yellow-500 text-black', icone: '🕗' },
partial: { label: 'Parcial', cls: 'bg-blue-600 text-white', icone: '◐' },
canceled: { label: 'Cancelada', cls: 'bg-gray-700 text-gray-300', icone: '🚫' }
};
const CAMPOS = [
{ campo: 'status', label: 'Status', tipo: 'status' },
{ campo: 'checkout_observation', label: 'Motivo (insucesso)', tipo: 'motivo' },
{ campo: 'checkout_comment', label: 'Comentário do checkout', tipo: 'textarea', col2: true },
{ campo: 'notes', label: 'Observações (notes)', tipo: 'textarea', col2: true },
{ campo: 'checkout_time', label: 'Data/hora do checkout', tipo: 'datetime' },
{ campo: 'checkout_latitude', label: 'Latitude', tipo: 'texto' },
{ campo: 'checkout_longitude', label: 'Longitude', tipo: 'texto' }
];
const esc = s => String(s ?? '').replace(/[&<>"']/g,
m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
const motivoLabel = uuid => (MOTIVOS.find(m => m.id === uuid) || {}).label || null;
// ── Busca por NF ────────────────────────────────────────────
const campoNf = document.getElementById('campo-nf');
const erroEl = document.getElementById('busca-erro');
const loadEl = document.getElementById('busca-loading');
const cardEl = document.getElementById('card');
async function buscar() { async function buscar() {
const nf = campo.value.trim(); const nf = campoNf.value.trim();
erro.classList.add('hidden'); erroEl.classList.add('hidden');
if (!nf) { erro.textContent = 'Informe o número da NF.'; erro.classList.remove('hidden'); return; } if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
loading.classList.remove('hidden'); loadEl.classList.remove('hidden');
form.classList.add('hidden'); cardEl.classList.add('hidden');
try { try {
const resp = await fetch(buscarU + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } }); const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
const data = await resp.json();
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
visita = data.visita;
renderCard();
cardEl.classList.remove('hidden');
} catch (e) {
mostrarErroBusca('Erro de conexão ao buscar a NF.');
} finally {
loadEl.classList.add('hidden');
}
}
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
document.getElementById('btn-buscar').addEventListener('click', buscar);
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
// ── Card ────────────────────────────────────────────────────
function renderCard() {
const v = visita;
document.getElementById('c-titulo').textContent = v.titulo || '—';
document.getElementById('c-endereco').textContent = v.endereco || '—';
document.getElementById('c-nf').textContent = v.nf ?? '—';
document.getElementById('c-id').textContent = v.id ?? '—';
document.getElementById('c-motorista').textContent = v.motorista || '—';
document.getElementById('c-veiculo').textContent = v.veiculo || '—';
document.getElementById('c-telefone').textContent = v.telefone || '—';
renderBadge();
renderFotos();
renderCampos();
}
function renderBadge() {
const cfg = STATUS[visita.status] || { label: visita.status || '—', cls: 'bg-gray-800 text-gray-400', icone: '❓' };
const b = document.getElementById('c-badge');
b.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold ' + cfg.cls;
b.textContent = cfg.icone + ' ' + cfg.label;
}
function renderFotos() {
const wrap = document.getElementById('c-foto-wrap');
if (visita.foto_fachada) {
wrap.innerHTML =
'<a href="' + esc(visita.foto_fachada) + '" target="_blank" rel="noopener" title="Abrir em tamanho real">' +
'<img src="' + esc(visita.foto_fachada) + '" alt="Fachada" loading="lazy" ' +
'class="w-full h-52 object-cover rounded-xl border border-white/10 hover:opacity-90 transition-opacity">' +
'</a>';
} else {
wrap.innerHTML =
'<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' +
'text-gray-600 text-sm">📷 Sem foto da fachada para esta entrega</div>';
}
const extras = [...(visita.pictures || [])];
if (visita.signature) extras.push(visita.signature);
const extrasWrap = document.getElementById('c-extras-wrap');
const grid = document.getElementById('c-extras');
if (extras.length) {
grid.innerHTML = extras.map(u =>
'<a href="' + esc(u) + '" target="_blank" rel="noopener">' +
'<img src="' + esc(u) + '" loading="lazy" class="w-full h-16 object-cover rounded-lg border border-white/10 hover:opacity-90">' +
'</a>').join('');
extrasWrap.classList.remove('hidden');
} else {
extrasWrap.classList.add('hidden');
grid.innerHTML = '';
}
}
// ── Campos click-to-edit ────────────────────────────────────
function valorExibicao(def) {
const v = visita[def.campo];
if (def.tipo === 'status') return (STATUS[v] || {}).label || v || '—';
if (def.tipo === 'motivo') return v ? (motivoLabel(v) || v) : '— sem motivo —';
if (def.tipo === 'datetime') {
if (!v) return '—';
const s = String(v);
return s.slice(8, 10) + '/' + s.slice(5, 7) + '/' + s.slice(0, 4) + ' ' + s.slice(11, 16);
}
return (v === null || v === undefined || v === '') ? '—' : String(v);
}
function renderCampos() {
const grid = document.getElementById('c-campos');
grid.innerHTML = '';
CAMPOS.forEach(def => {
const div = document.createElement('div');
div.className = 'campo group bg-[#1a1a1a] border border-white/5 rounded-xl px-4 py-3 cursor-pointer ' +
'hover:border-orange-500/50 transition-colors' + (def.col2 ? ' sm:col-span-2' : '');
div.dataset.campo = def.campo;
pintarExibicao(div, def);
grid.appendChild(div);
});
}
function pintarExibicao(div, def, flash) {
div.innerHTML =
'<p class="text-xs text-gray-500 mb-0.5">' + esc(def.label) + '</p>' +
'<div class="flex items-start justify-between gap-2">' +
'<p class="texto-valor text-sm text-white break-words min-h-[20px]">' + esc(valorExibicao(def)) + '</p>' +
'<span class="text-gray-600 group-hover:text-orange-400 text-sm" title="Editar">✏️</span>' +
'</div>';
div.onclick = () => abrirEdicao(div, def);
if (flash) {
div.classList.add('ring-2', 'ring-green-500/70');
setTimeout(() => div.classList.remove('ring-2', 'ring-green-500/70'), 1200);
}
}
function abrirEdicao(div, def) {
div.onclick = null;
div.classList.remove('cursor-pointer');
const atual = visita[def.campo];
let editorHtml;
const base = 'w-full bg-[#111] border border-orange-500/60 rounded-lg px-2.5 py-2 text-sm text-white focus:outline-none';
if (def.tipo === 'status') {
editorHtml = '<select class="ed ' + base + '">' +
Object.entries(STATUS).map(([v, c]) =>
'<option value="' + v + '"' + (v === atual ? ' selected' : '') + '>' + c.label + '</option>').join('') +
'</select>';
} else if (def.tipo === 'motivo') {
editorHtml = '<select class="ed ' + base + '">' +
'<option value="">— sem motivo —</option>' +
MOTIVOS.map(m =>
'<option value="' + esc(m.id) + '"' + (m.id === atual ? ' selected' : '') + '>' + esc(m.label) + '</option>').join('') +
'</select>';
} else if (def.tipo === 'textarea') {
editorHtml = '<textarea rows="2" class="ed ' + base + '">' + esc(atual ?? '') + '</textarea>';
} else if (def.tipo === 'datetime') {
const valor = atual ? String(atual).slice(0, 16) : '';
editorHtml = '<input type="datetime-local" value="' + esc(valor) + '" class="ed ' + base + '">';
} else {
editorHtml = '<input type="text" value="' + esc(atual ?? '') + '" class="ed ' + base + '">';
}
div.innerHTML =
'<p class="text-xs text-orange-400 mb-1">' + esc(def.label) + '</p>' +
editorHtml +
'<div class="flex items-center gap-2 mt-2">' +
'<button type="button" class="btn-ok px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg">✓ Salvar</button>' +
'<button type="button" class="btn-cancelar px-3 py-1.5 bg-white/5 hover:bg-white/10 text-gray-300 text-xs rounded-lg">✕ Cancelar</button>' +
'<span class="msg-salvando hidden text-xs text-gray-400">Salvando…</span>' +
'</div>' +
'<p class="msg-erro hidden mt-1.5 text-xs text-red-400"></p>';
const ed = div.querySelector('.ed');
ed.focus();
const cancelar = () => pintarExibicao(div, def) || div.classList.add('cursor-pointer');
div.querySelector('.btn-cancelar').onclick = e => { e.stopPropagation(); cancelar(); };
div.querySelector('.btn-ok').onclick = e => { e.stopPropagation(); salvar(div, def, ed.value); };
ed.addEventListener('keydown', e => {
if (e.key === 'Escape') cancelar();
if (e.key === 'Enter' && def.tipo !== 'textarea') { e.preventDefault(); salvar(div, def, ed.value); }
});
}
async function salvar(div, def, valor) {
const msgErro = div.querySelector('.msg-erro');
const msgSalvando = div.querySelector('.msg-salvando');
msgErro.classList.add('hidden');
msgSalvando.classList.remove('hidden');
try {
const body = { visit_id: visita.id };
body[def.campo] = valor;
const resp = await fetch(URLS.atualizar, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': CSRF
},
body: JSON.stringify(body)
});
const data = await resp.json(); const data = await resp.json();
if (!data.ok) { if (!data.ok) {
erro.textContent = data.erro || 'Não foi possível localizar a entrega.'; msgErro.textContent = data.erro || 'Erro ao salvar.';
erro.classList.remove('hidden'); msgErro.classList.remove('hidden');
return; return;
} }
const v = data.visita; // Atualiza o estado local com o que a API confirmou (ou mantém o atual
set('f-visit-id', v.id); // quando o valor não mudou — campos: []).
txt('f-titulo', v.titulo); if (data.campos && data.campos.length) Object.assign(visita, data.visita);
txt('f-endereco', v.endereco); pintarExibicao(div, def, data.campos && data.campos.length > 0);
txt('f-nf', v.nf); div.classList.add('cursor-pointer');
txt('f-id', v.id); if (def.campo === 'status') renderBadge();
set('f-status', v.status || 'completed');
set('f-motivo', v.checkout_observation || '');
set('f-comment', v.checkout_comment || '');
set('f-notes', v.notes || '');
set('f-lat', v.checkout_latitude ?? '');
set('f-lng', v.checkout_longitude ?? '');
// ISO -> valor de datetime-local (YYYY-MM-DDTHH:MM)
set('f-checkout-time', v.checkout_time ? String(v.checkout_time).slice(0, 16) : '');
toggleMotivo();
form.classList.remove('hidden');
} catch (e) { } catch (e) {
erro.textContent = 'Erro de conexão ao buscar a NF.'; msgErro.textContent = 'Erro de conexão ao salvar.';
erro.classList.remove('hidden'); msgErro.classList.remove('hidden');
} finally { } finally {
loading.classList.add('hidden'); msgSalvando.classList.add('hidden');
} }
} }
btn.addEventListener('click', buscar); // ── Histórico (modal) ───────────────────────────────────────
campo.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } }); const modal = document.getElementById('modal-hist');
const corpo = document.getElementById('modal-hist-corpo');
function fecharModal() { modal.classList.add('hidden'); }
document.getElementById('modal-hist-fechar').onclick = fecharModal;
document.getElementById('modal-hist-overlay').onclick = fecharModal;
document.addEventListener('keydown', e => { if (e.key === 'Escape') fecharModal(); });
document.getElementById('btn-historico').addEventListener('click', async () => {
if (!visita) return;
modal.classList.remove('hidden');
corpo.innerHTML = '<p class="text-gray-400">Carregando…</p>';
try {
const resp = await fetch(URLS.historico + '?visit_id=' + encodeURIComponent(visita.id),
{ headers: { 'Accept': 'application/json' } });
const data = await resp.json();
corpo.innerHTML = htmlHistorico(data);
} catch (e) {
corpo.innerHTML = '<p class="text-red-400">Erro ao carregar o histórico.</p>';
}
});
function nomeCampo(campo) {
const def = CAMPOS.find(c => c.campo === campo);
return def ? def.label : campo;
}
function fmtValor(campo, valor) {
if (valor === null || valor === undefined || valor === '') return '—';
if (campo === 'checkout_observation') return motivoLabel(valor) || valor;
if (campo === 'status') return (STATUS[valor] || {}).label || valor;
return String(valor);
}
function htmlHistorico(data) {
let html = '<div>' +
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">📒 Nosso sistema (auditoria)</p>';
const interno = (data.interno || []);
if (!interno.length) {
html += '<p class="text-gray-500">Nenhuma alteração registrada pelo sistema.</p>';
} else {
html += interno.map(l => {
const mudancas = Object.keys(l.para || {}).map(campo =>
'<li class="text-gray-300"><span class="text-gray-500">' + esc(nomeCampo(campo)) + ':</span> ' +
'<span class="text-red-300/80 line-through">' + esc(fmtValor(campo, (l.de || {})[campo])) + '</span>' +
' → <span class="text-green-300">' + esc(fmtValor(campo, l.para[campo])) + '</span></li>').join('');
return '<div class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 mb-2">' +
'<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">' +
'<span>👤 ' + esc(l.usuario) + '</span><span>' + esc(l.quando) + '</span>' +
'</div>' +
'<ul class="space-y-0.5 text-xs">' + (mudancas || '<li class="text-gray-500">—</li>') + '</ul>' +
'</div>';
}).join('');
}
html += '</div><div>' +
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">🌐 SimpliRoute (API)</p>';
const api = (data.api || []);
if (!api.length) {
html += '<p class="text-gray-500">Sem registros de histórico na API do SimpliRoute.</p>';
} else {
html += '<pre class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 text-xs text-gray-300 overflow-x-auto">' +
esc(JSON.stringify(api, null, 2)) + '</pre>';
}
html += '</div>';
return html;
}
})(); })();
</script> </script>

View File

@@ -0,0 +1,51 @@
<%# app/views/admin/planilhas_simpliroute/show.html.erb %>
<div class="space-y-6">
<%# Header %>
<div>
<h1 class="text-2xl font-bold text-white">Planilha SimpliRoute</h1>
<p class="text-gray-400 text-sm mt-0.5">Gera a planilha de carga (.xlsx) de uma operação vigente, já com lat/long do mês anterior</p>
</div>
<%# Flash %>
<%= render 'shared/flash' %>
<%# Explicação %>
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
<span class="text-base leading-none"></span>
<p>A <strong>Latitude/Longitude</strong> é reaproveitada do <strong>mês anterior</strong> da mesma
operação (casando nome + endereço + supervisão). Pacientes <strong>novos</strong> ou sem
correspondência saem <strong>sem</strong> lat/long — o SimpliRoute geocodifica na importação.
Se a operação não tiver mês anterior, a planilha sai inteira sem lat/long.</p>
</div>
<%# Seleção + download %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
<% if @operacoes_agrupadas.blank? %>
<p class="text-gray-400 text-sm">Nenhuma operação encontrada.</p>
<% else %>
<%= form_with url: baixar_admin_planilha_simpli_route_path, method: :get, data: { turbo: false },
class: 'flex flex-col sm:flex-row sm:items-end gap-3' do %>
<div class="flex-1">
<label class="block text-sm font-medium text-gray-300 mb-1.5">Operação vigente</label>
<select name="tabela" required
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
<% @operacoes_agrupadas.each do |titulo, ops| %>
<optgroup label="<%= titulo %>">
<% ops.each do |op| %>
<option value="<%= op[:tabela] %>"><%= op[:label] %></option>
<% end %>
</optgroup>
<% end %>
</select>
</div>
<button type="submit"
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
transition-colors min-h-[48px] whitespace-nowrap">
📥 Baixar planilha
</button>
<% end %>
<% end %>
</div>
</div>

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áfico de linha diário (Chart.js) %> <%# Gráficos diários (Chart.js) — flex-col p/ preencher a altura do card de motoristas %>
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6"> <div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 flex flex-col">
<div class="flex items-center justify-between mb-6"> <div class="flex flex-wrap items-center justify-between gap-2 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,9 +215,39 @@
<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 h-64"> <div class="relative flex-1 min-h-56">
<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 %>
@@ -348,9 +378,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> <tbody id="pagamentos-feitos-tbody">
<% @pagamentos_feitos.each do |cm| %> <% @pagamentos_feitos.each do |cm| %>
<tr class="border-b border-white/5"> <tr class="border-b border-white/5 pagamento-feito-row">
<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">
@@ -364,6 +394,10 @@
</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 %>
@@ -382,9 +416,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> <tbody id="pagamentos-pendentes-tbody">
<% @pagamentos_pendentes.each do |cm| %> <% @pagamentos_pendentes.each do |cm| %>
<tr class="border-b border-white/5"> <tr class="border-b border-white/5 pagamento-pendente-row">
<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),
@@ -396,6 +430,10 @@
</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 %>
@@ -473,6 +511,60 @@
})(); })();
</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 () {
@@ -564,6 +656,82 @@
})(); })();
</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

@@ -58,6 +58,7 @@
<%= nav_link_to '👥 Usuários', admin_usuarios_path %> <%= nav_link_to '👥 Usuários', admin_usuarios_path %>
<%= nav_link_to '⚙️ Configurações', admin_configuracoes_path %> <%= nav_link_to '⚙️ Configurações', admin_configuracoes_path %>
<%= nav_link_to '🔍 Auditoria', admin_auditoria_logs_path %> <%= nav_link_to '🔍 Auditoria', admin_auditoria_logs_path %>
<%= nav_link_to '📥 Planilha SimpliRoute', admin_planilha_simpli_route_path %>
<% if current_user.admin? %> <% if current_user.admin? %>
<%= nav_link_to '✏️ Editar Lançamento', admin_edicao_lancamento_path %> <%= nav_link_to '✏️ Editar Lançamento', admin_edicao_lancamento_path %>
<% end %> <% end %>

View File

@@ -80,7 +80,10 @@
</script> </script>
</head> </head>
<body class="bg-[#0a0a0a] text-white min-h-screen"> <%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na
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

@@ -0,0 +1,139 @@
<%# 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,9 +88,10 @@
<% end %> <% end %>
</div> </div>
<%# ── Abas de modo ──────────────────────────────────────── %> <%# ── Abas de modo + atalho para a planilha ─────────────── %>
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3"> <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">
<% modos.each do |m, label| %> <% modos.each do |m, label| %>
<% ativo = @modo == m %> <% ativo = @modo == m %>
<%= link_to label, <%= link_to label,
@@ -100,6 +101,17 @@
<% end %> <% end %>
</div> </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>
<% if @tabelas_validas.blank? %> <% if @tabelas_validas.blank? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-10 text-center text-gray-400"> <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>). Nenhuma operação encontrada no banco (tabelas <code class="text-orange-400">gade_entregas_*</code>).

View File

@@ -0,0 +1,108 @@
<%# 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

@@ -40,13 +40,17 @@ Rails.application.configure do
p.font_src :self, :data, "https://fonts.gstatic.com" p.font_src :self, :data, "https://fonts.gstatic.com"
# Imagens: data/blob (QR base64, canvas dos gráficos), ícones do Leaflet e # Imagens: data/blob (QR base64, canvas dos gráficos), ícones do Leaflet,
# tiles do mapa (ArcGIS World Imagery + OpenStreetMap). # tiles do mapa (ArcGIS World Imagery + OpenStreetMap) e as FOTOS DA FACHADA
# do popup do mapa de operações — hospedadas no S3 do SimpliRoute
# (ex.: simpli-visit-images.s3.amazonaws.com). Sem o host da Amazon o CSP
# enforcing bloqueava a foto ("Refused to load the image ... img-src").
p.img_src :self, :data, :blob, p.img_src :self, :data, :blob,
"https://unpkg.com", "https://unpkg.com",
"https://cdn.jsdelivr.net", "https://cdn.jsdelivr.net",
"https://server.arcgisonline.com", "https://server.arcgisonline.com",
"https://*.tile.openstreetmap.org" "https://*.tile.openstreetmap.org",
"https://*.amazonaws.com"
p.connect_src :self p.connect_src :self
end end

View File

@@ -18,6 +18,10 @@ 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
@@ -92,6 +96,12 @@ Rails.application.routes.draw do
patch :atualizar # aplica a alteração na API patch :atualizar # aplica a alteração na API
get :historico # histórico da visita na API (JSON, best-effort) get :historico # histórico da visita na API (JSON, best-effort)
end end
# Geração da planilha de carga do SimpliRoute (download .xlsx a partir de uma
# operação vigente + lat/long do mês anterior).
resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do
get :baixar
end
end end
# API interna — Dashboard métricas # API interna — Dashboard métricas

View File

@@ -32,7 +32,12 @@ services:
# O passo do cron é "best-effort": se o crontab/cron não estiver disponível # O passo do cron é "best-effort": se o crontab/cron não estiver disponível
# (ex.: imagem antiga sem o pacote `cron`), apenas avisa no log e SEGUE — # (ex.: imagem antiga sem o pacote `cron`), apenas avisa no log e SEGUE —
# nunca impede o Rails de subir. (Requer rebuild: docker compose up -d --build) # nunca impede o Rails de subir. (Requer rebuild: docker compose up -d --build)
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0" # `bundle check || bundle install`: os gems ficam num VOLUME nomeado
# (bundle_cache) que persiste e SOMBREIA os gems da imagem — então adicionar
# uma gem nova ao Gemfile não chega no container só com `up`/`--build`. Este
# passo instala o que faltar no volume no boot (idempotente e rápido quando já
# está tudo instalado), evitando o Bundler::GemNotFound ao subir.
command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0"
# O banco PostgreSQL já existe externamente. # O banco PostgreSQL já existe externamente.
# Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL. # Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL.