Compare commits
19 Commits
fd14c057b6
...
teste
| Author | SHA256 | Date | |
|---|---|---|---|
| e001a48e1a | |||
| c0ef42a116 | |||
| 04efe871f0 | |||
| 1d4bed785d | |||
| 75d82227c9 | |||
| bd61b32edd | |||
| a08a0712f0 | |||
| 8e73bbe0ed | |||
| b55b602eb5 | |||
| 962f79266a | |||
| df9256c556 | |||
| 1dd2610c1f | |||
| 96b85adbe9 | |||
| bb9d55675a | |||
| 3f56e87a33 | |||
| 44ba0a93c6 | |||
| 07fda079aa | |||
| 53341ac22e | |||
| b2695cd920 |
3
Gemfile
3
Gemfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
6472
Imagens para correção /reem-notas-teste-app-1.html
Normal file
6472
Imagens para correção /reem-notas-teste-app-1.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx
Normal file
BIN
Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx
Normal file
Binary file not shown.
BIN
Imagens para implantação /Imagem colada (2).png
Normal file
BIN
Imagens para implantação /Imagem colada (2).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
BIN
Imagens para implantação /Imagem colada (3).png
Normal file
BIN
Imagens para implantação /Imagem colada (3).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
BIN
Imagens para implantação /Imagem colada (4).png
Normal file
BIN
Imagens para implantação /Imagem colada (4).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
Imagens para implantação /Imagem colada (5).png
Normal file
BIN
Imagens para implantação /Imagem colada (5).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
Imagens para implantação /Imagem colada (6).png
Normal file
BIN
Imagens para implantação /Imagem colada (6).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
Imagens para implantação /Imagem colada.png
Normal file
BIN
Imagens para implantação /Imagem colada.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 599 KiB |
BIN
Imagens para implantação /pt_br_example.xlsx
Normal file
BIN
Imagens para implantação /pt_br_example.xlsx
Normal file
Binary file not shown.
123
README.md
123
README.md
@@ -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:00–18: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 A–V da própria tabela `gade_entregas_*` (ordem física de importação =
|
||||||
|
ordem da planilha original) + **W–Z 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` (A–T) + vermelho `C00000` (U–Z), 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 ("1–10 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
43
app/controllers/admin/planilhas_simpli_route_controller.rb
Normal file
43
app/controllers/admin/planilhas_simpli_route_controller.rb
Normal 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
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
9
app/policies/planilha_simpli_route_policy.rb
Normal file
9
app/policies/planilha_simpli_route_policy.rb
Normal 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
|
||||||
@@ -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)
|
||||||
|
|||||||
242
app/services/analytics/planilha_entregas.rb
Normal file
242
app/services/analytics/planilha_entregas.rb
Normal 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
|
||||||
211
app/services/analytics/planilha_entregas_xlsx.rb
Normal file
211
app/services/analytics/planilha_entregas_xlsx.rb
Normal 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
|
||||||
173
app/services/simpli_route/planilha_carga.rb
Normal file
173
app/services/simpli_route/planilha_carga.rb
Normal 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
|
||||||
93
app/services/simpli_route/planilha_carga_xlsx.rb
Normal file
93
app/services/simpli_route/planilha_carga_xlsx.rb
Normal 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
|
||||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||||
|
|||||||
51
app/views/admin/planilhas_simpli_route/show.html.erb
Normal file
51
app/views/admin/planilhas_simpli_route/show.html.erb
Normal 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>
|
||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
139
app/views/operacoes_dashboard/_espelho.html.erb
Normal file
139
app/views/operacoes_dashboard/_espelho.html.erb
Normal 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>
|
||||||
@@ -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>).
|
||||||
|
|||||||
108
app/views/operacoes_dashboard/planilha.html.erb
Normal file
108
app/views/operacoes_dashboard/planilha.html.erb
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user