Compare commits
26 Commits
3f5829c816
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| cb47d1c294 | |||
| 069301bc2e | |||
| 0a6b5a68fc | |||
| eca6df39eb | |||
| 83310e9e66 | |||
| ca626b493b | |||
| 3b4d796f7c | |||
| 8d0d262bf8 | |||
| 96d01aa52a | |||
| ff9614d84c | |||
| 8ab2f8e72a | |||
| 0a9713c298 | |||
| cb6eda74f1 | |||
| a5742d178a | |||
| 4b3f80a2e0 | |||
| ac0524d60f | |||
| 3fb2b69399 | |||
| acd3d20caf | |||
| 3698b826c0 | |||
| 4c035d4a4c | |||
| 7d6c79f878 | |||
| 6b899ebafa | |||
| 7f21a16327 | |||
| 59c0caa774 | |||
| 11531479f8 | |||
| 16032be620 |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
BIN
Imagens para correção /BKP/Imagem colada (2).png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
Imagens para correção /BKP/Imagem colada (3).png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
Imagens para correção /BKP/Imagem colada (4).png
Normal file
|
After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
BIN
Imagens para correção /BKP/Imagem colada.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 75 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (10).png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (11).png
Normal file
|
After Width: | Height: | Size: 698 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 478 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (2).png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (3).png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (4).png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (5).png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (6).png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (7).png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (8).png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
Imagens para implantação /Bkp Imagens/Imagem colada (9).png
Normal file
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 698 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 666 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 332 KiB |
148
README.md
@@ -1032,7 +1032,7 @@ docker-compose exec app bundle exec rails db:migrate
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details>
|
||||||
<summary><strong>🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)</strong></summary>
|
<summary><strong>🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores)</strong></summary>
|
||||||
|
|
||||||
Correção da **quebra de layout** dos cards de NF na tela **Passo 2 (Validar)** em telas pequenas e
|
Correção da **quebra de layout** dos cards de NF na tela **Passo 2 (Validar)** em telas pequenas e
|
||||||
@@ -1062,3 +1062,149 @@ em tablets, reportada nos prints `Imagens para correção/Imagem colada (16).png
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa)</strong></summary>
|
||||||
|
|
||||||
|
Nova página **`/dashboard/operacoes`** (link **📈 Operações** no menu), separada do dashboard
|
||||||
|
financeiro, focada na **qualidade das entregas** por operação (UBS Norte, EMAD, …). Reproduz os
|
||||||
|
painéis das imagens de referência (`Imagens para implantação/`) e adiciona análise comparativa e mapa.
|
||||||
|
Acessível a todos menos motorista (`DashboardPolicy#operacoes?`).
|
||||||
|
|
||||||
|
### 🆕 Funcionalidades
|
||||||
|
- **3 modos na mesma página** (abas):
|
||||||
|
- **🏥 Operação** — uma operação por vez. **Sem filtro de data**: mostra o conjunto inteiro da
|
||||||
|
operação e o período exibido vem dos próprios dados (menor/maior `checkout`).
|
||||||
|
- **🌐 Global** — todas as operações agregadas, aí sim com **faixa de datas** (flatpickr + atalhos
|
||||||
|
Hoje/7d/Este mês/30d). A faixa de data **só vale no Global**.
|
||||||
|
- **⚖️ Comparar** — duas operações lado a lado com bloco **Comparativo** (tabela A | B | Δ com o
|
||||||
|
melhor valor de cada linha em verde + gráfico de barras agrupadas) e detalhe completo recolhível.
|
||||||
|
- **Painéis** (espelham as imagens 2 e 3): KPIs (Total / Sucesso / Recusas / Pendentes), donut
|
||||||
|
**Insucessos %**, **Índices de Falha** (por `observation`), **Por Status** (status_gade
|
||||||
|
RECORRENTE/NOVO), **NFs por Motorista**, **Entregas por STS/Unidade** (`contact_name`), **Entregas
|
||||||
|
por Dia** (barras empilhadas) e **Mapa**.
|
||||||
|
- **Cross-filter ao vivo (toggle)** — clicar em qualquer linha das tabelas (Motorista, Unidade,
|
||||||
|
Status, Observação) **refiltra todo o dashboard** por aquele valor; clicar de novo na linha ativa
|
||||||
|
**remove** o filtro. Chips removíveis + "Limpar tudo" no topo.
|
||||||
|
- **Mapa de entregas** com:
|
||||||
|
- alternância **🔥 Calor** (heatmap) ⇄ **📍 Pontos** (um balão por entrega na coordenada de
|
||||||
|
**check-out**);
|
||||||
|
- **camadas** 🌙 Escuro (padrão) / 🗺️ Claro / 🛰️ Satélite (todas grátis, sem chave);
|
||||||
|
- **tela cheia** (Fullscreen API);
|
||||||
|
- **popup** por entrega (NF, destinatário, endereço, unidade, motorista, data) com a **foto da
|
||||||
|
fachada** (coluna `foto_da_fachada`), aberta em tamanho cheio ao clicar.
|
||||||
|
- **Cores** dos gráficos na identidade da marca: laranja `#f97316` (completas) e vermelho `#ef4444`
|
||||||
|
(falhas); no comparativo A = laranja, B = azul.
|
||||||
|
|
||||||
|
### ⚙️ Como funciona (técnico)
|
||||||
|
- Núcleo de dados em **`app/services/analytics/operacao_metricas.rb`** (PORO). Monta um SQL que
|
||||||
|
**espelha a query de gestão**: CTE `ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout
|
||||||
|
DESC)` sobre `db_reem_simplerout_2026` + `INNER JOIN` na tabela `gade_entregas_*` por
|
||||||
|
`reference_id::text = nota_fiscal`; agrega tudo em Ruby (visão `linhas` = registros após o
|
||||||
|
cross-filter).
|
||||||
|
- **Sem filtro de conta** (`account_id`): o INNER JOIN com a tabela da operação já restringe ao
|
||||||
|
cliente — replicar `da_conta_gade` zerava tudo quando `DB_EXISTING_ACCOUNT_ID` não batia.
|
||||||
|
- **Schema gade não-uniforme**: só `nota_fiscal` é garantida. Colunas opcionais (`status`,
|
||||||
|
`nome_completo`, `endereco_completo`) são detectadas por tabela (`conn.columns`) e viram `NULL`
|
||||||
|
quando não existem — assim o `UNION ALL` entre operações no Global não quebra
|
||||||
|
(era o erro `column g.status does not exist`).
|
||||||
|
- **Segurança**: nomes de tabela passam por `Operacao.sanitizar` + `quote_table_name`; a `foto_da_fachada`
|
||||||
|
é validada como URL `http(s)` antes de ir ao `<img>`, com escape de HTML no popup.
|
||||||
|
- **Performance do mapa**: marcadores criados **sob demanda** (só ao abrir "Pontos") e o popup é uma
|
||||||
|
**função** — a foto do S3 só é requisitada **ao clicar** no balão (nada é baixado no load). Limite
|
||||||
|
de 2000 marcadores.
|
||||||
|
- **Filtro de período** opcional no serviço (`inicio:`/`fim:`): aplicado só no Global.
|
||||||
|
|
||||||
|
### 📂 Arquivos
|
||||||
|
```
|
||||||
|
app/services/analytics/operacao_metricas.rb # NOVO — núcleo de dados/agregações
|
||||||
|
app/controllers/operacoes_dashboard_controller.rb # NOVO — modos + cross-filter
|
||||||
|
app/views/operacoes_dashboard/index.html.erb # NOVO — filtros, abas, charts, mapa (JS)
|
||||||
|
app/views/operacoes_dashboard/_painel.html.erb # NOVO — KPIs/donut/tabelas/gráfico
|
||||||
|
app/views/operacoes_dashboard/_comparativo.html.erb # NOVO — tabela A|B|Δ + barras agrupadas
|
||||||
|
app/views/operacoes_dashboard/_mapa.html.erb # NOVO — calor/pontos, camadas, tela cheia
|
||||||
|
app/views/operacoes_dashboard/_tabela_simples.html.erb # NOVO — tabela reutilizável + cross-filter
|
||||||
|
spec/services/analytics/operacao_metricas_spec.rb # NOVO — specs das agregações/cross-filter
|
||||||
|
spec/requests/operacoes_dashboard_spec.rb # NOVO — autorização por role + modos
|
||||||
|
config/routes.rb # rota get /dashboard/operacoes
|
||||||
|
app/policies/dashboard_policy.rb # operacoes? (todos menos motorista)
|
||||||
|
app/views/layouts/_navbar.html.erb # link "📈 Operações"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Sem migration** — tudo lê tabelas existentes (`db_reem_simplerout_2026` read-only e
|
||||||
|
> `gade_entregas_*`). Libs front via CDN (Chart.js, Leaflet + leaflet.heat, flatpickr).
|
||||||
|
> ⚠️ Em produção, **reiniciar o servidor** (Puma) após o deploy — o Rails cacheia classes/views.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar</strong></summary>
|
||||||
|
|
||||||
|
Rodada de melhorias no **Dashboard de Operações**, na tela de **Validação do motorista**
|
||||||
|
(consolidação) e no **layout do site inteiro**.
|
||||||
|
|
||||||
|
### 🗺️ Mapa de Operações
|
||||||
|
- **Busca no mapa** — campo 🔍 que filtra os pontos por **NF, nome do paciente ou veículo**,
|
||||||
|
troca para a visão "Pontos", dá zoom nos resultados (busca client-side) e mostra contador /
|
||||||
|
"Nenhum resultado".
|
||||||
|
- **Mostra TODAS as entregas** — antes só `completed`; agora **sucesso + insucesso** (óbito e
|
||||||
|
demais pilares de falha), via `Entrega::STATUS_ATENDIDO`. Pino **laranja = sucesso**, **azul =
|
||||||
|
insucesso**; o popup mostra o status e o **motivo** (`observation`). A **foto da fachada** é
|
||||||
|
mantida para todos os casos.
|
||||||
|
- **Campo veículo** — coluna `vehicle` (de `Entrega`) adicionada à query, ao card e à busca.
|
||||||
|
- **Modo escuro mais detalhado e com mais contraste** — o tile escuro passou de CARTO `dark_all`
|
||||||
|
(minimalista) para **OpenStreetMap completo invertido** (`filter: invert(1) hue-rotate(180deg)
|
||||||
|
brightness(.95) contrast(1.05)` na classe `.tiles-escuro-contraste`): mesmo detalhe de ruas/nomes
|
||||||
|
do mapa claro, porém escuro e legível. Claro/Satélite ficam intactos.
|
||||||
|
|
||||||
|
### 📊 Dashboard de Operações (painéis)
|
||||||
|
- **Gráficos clicáveis (cross-filter)** — além das tabelas, agora o **donut "Insucessos %"**
|
||||||
|
(clique em Completas/Falhas → filtra por `status`) e as barras **"Entregas por Dia"** (clique →
|
||||||
|
filtra por **dia** + **resultado** da barra) refiltram **todo** o dashboard. Novos filtros
|
||||||
|
`f_resultado` (coluna `status`) e `f_data` (por data de checkout, tratado à parte no serviço via
|
||||||
|
`@data`). Chips removíveis + "Limpar tudo", igual às tabelas.
|
||||||
|
- **Falha em azul** — a cor de insucesso mudou de vermelho `#ef4444` para **azul `#3b82f6`** nos
|
||||||
|
KPIs e gráficos (mais contraste no tema escuro).
|
||||||
|
- **Hover nos cards de KPI** — ao passar o mouse o card destaca, o número aumenta e aparece o
|
||||||
|
**% do total** (no card Total, o detalhamento ✅/●/⏳).
|
||||||
|
- **Respiro no scroll das tabelas** — `pr-2` nos contêineres roláveis (Motoristas / STS) para a
|
||||||
|
barra de scroll não colar no texto.
|
||||||
|
|
||||||
|
### 🚚 Validação do motorista (consolidação)
|
||||||
|
- **Filtro de veículos multi-seleção** — antes "um carro de cada vez"; agora dá pra **somar 2+
|
||||||
|
veículos**. A lista, a barra de progresso, o **valor total (R$)** e a **contagem por pilar**
|
||||||
|
passam a refletir só os veículos selecionados. Chip selecionado fica **laranja** (prioridade
|
||||||
|
sobre o verde de "fechado") para a seleção ficar sempre visível.
|
||||||
|
- **Lupa de busca na listagem de notas** — campo 🔍 sticky no topo da lista que filtra os cards
|
||||||
|
por **NF, local, endereço ou veículo**; "Selecionar todos" passa a marcar **apenas os visíveis**
|
||||||
|
no filtro.
|
||||||
|
- **Barra fixa mais compacta** — paddings/margens reduzidos (`p-3 mb-3`) para melhor aproveitamento
|
||||||
|
em **monitores pequenos**.
|
||||||
|
- **Ocultar/mostrar o resumo lateral** — botão 📊 que esconde o resumo e dá **largura total** à
|
||||||
|
lista (`lg:col-span-3 → lg:col-span-4`).
|
||||||
|
|
||||||
|
### 🧭 Layout global (site inteiro)
|
||||||
|
- **Recolher/expandir a sidebar do menu principal** — botão flutuante de seta (desktop) que esconde
|
||||||
|
o menu e expande o conteúdo (`md:ml-64 → 0`). Estado **lembrado** entre páginas (`localStorage` +
|
||||||
|
classe `.sidebar-collapsed` no `<html>`, aplicada antes de pintar → sem "piscar").
|
||||||
|
- **Indicador discreto** — em repouso o botão é uma **alça fina laranja** sempre visível na borda;
|
||||||
|
ao chegar perto com o mouse (ou foco por teclado) ela **expande** no botão com a seta ◀/▶.
|
||||||
|
|
||||||
|
### 📂 Arquivos
|
||||||
|
```
|
||||||
|
app/services/analytics/operacao_metricas.rb # veículo, sucesso+insucesso, filtro por dia (@data)
|
||||||
|
app/controllers/operacoes_dashboard_controller.rb # CROSS f_resultado + filtro f_data
|
||||||
|
app/views/operacoes_dashboard/index.html.erb # busca/dark map, pinos, gráficos clicáveis, KPIs
|
||||||
|
app/views/operacoes_dashboard/_painel.html.erb # hover KPIs, cor azul, pr-2 no scroll
|
||||||
|
app/views/operacoes_dashboard/_mapa.html.erb # campo de busca no mapa
|
||||||
|
app/controllers/consolidacao_entregas_controller.rb # filtro multi-veículo + resumo por escopo
|
||||||
|
app/views/consolidacao_entregas/validar.html.erb # chips multi, lupa, barra compacta, toggle resumo
|
||||||
|
app/javascript/controllers/validacao_controller.js # veiculos[] , busca, toggleResumo
|
||||||
|
app/views/layouts/application.html.erb # CSS sidebar recolhível + script anti-flash
|
||||||
|
app/views/layouts/_navbar.html.erb # botão flutuante + alça indicadora
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Sem migration** — segue lendo as tabelas existentes (`db_reem_simplerout_2026` read-only e
|
||||||
|
> `gade_entregas_*`). ⚠️ Em produção, **reiniciar o Puma** após o deploy (cache de classes/views).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
def validar
|
def validar
|
||||||
authorize @consolidacao, :show?
|
authorize @consolidacao, :show?
|
||||||
@motorista = params[:motorista]
|
@motorista = params[:motorista]
|
||||||
@veiculo = params[:vehicle].presence # filtro "fechar por carro" (opcional)
|
@veiculos_sel = veiculos_param(params[:vehicle]) # filtro por veículo(s) — multi-seleção
|
||||||
|
|
||||||
# Entregas atendidas do período no banco existente (read-only): sucesso +
|
# Entregas atendidas do período no banco existente (read-only): sucesso +
|
||||||
# insucesso, pois o motorista foi ao local e a entrega precisa constar.
|
# insucesso, pois o motorista foi ao local e a entrega precisa constar.
|
||||||
@@ -24,7 +24,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
.do_motorista(@motorista)
|
.do_motorista(@motorista)
|
||||||
@entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
@entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||||
@entregas = @entregas.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
@entregas = @entregas.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
||||||
@entregas = filtrar_por_veiculo(@entregas, @veiculo) if @veiculo
|
@entregas = filtrar_por_veiculo(@entregas, @veiculos_sel) if @veiculos_sel.any?
|
||||||
@entregas = @entregas.order(:checkout)
|
@entregas = @entregas.order(:checkout)
|
||||||
|
|
||||||
# Classificações já feitas — agrupadas por tracking_id (uma entrega pode ter
|
# Classificações já feitas — agrupadas por tracking_id (uma entrega pode ter
|
||||||
@@ -36,10 +36,10 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
# Status "fechado" por veículo (filtro + acompanhamento) e totais com escopo:
|
# Status "fechado" por veículo (filtro + acompanhamento) e totais com escopo:
|
||||||
# quando um carro está selecionado, a barra reflete só aquele carro.
|
# quando um carro está selecionado, a barra reflete só aquele carro.
|
||||||
@veiculos_status = veiculos_status(@motorista)
|
@veiculos_status = veiculos_status(@motorista)
|
||||||
if @veiculo
|
if @veiculos_sel.any?
|
||||||
atual = @veiculos_status.find { |v| v[:vehicle] == @veiculo }
|
sel = @veiculos_status.select { |v| @veiculos_sel.include?(v[:vehicle]) }
|
||||||
@total_entregas = atual ? atual[:eligible] : 0
|
@total_entregas = sel.sum { |v| v[:eligible] }
|
||||||
@classificadas = atual ? atual[:classificadas] : 0
|
@classificadas = sel.sum { |v| v[:classificadas] }
|
||||||
else
|
else
|
||||||
@total_entregas = @consolidacao.entregas_elegiveis_count(@motorista)
|
@total_entregas = @consolidacao.entregas_elegiveis_count(@motorista)
|
||||||
@classificadas = @consolidacao.classificadas_count(@motorista)
|
@classificadas = @consolidacao.classificadas_count(@motorista)
|
||||||
@@ -88,6 +88,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
motorista_nome: params[:motorista],
|
motorista_nome: params[:motorista],
|
||||||
tipo: params[:tipo],
|
tipo: params[:tipo],
|
||||||
valor_aplicado: valor_classificacao(params[:tipo]),
|
valor_aplicado: valor_classificacao(params[:tipo]),
|
||||||
|
vehicle: veiculo_do_tracking(params[:tracking_id]), # snapshot p/ fechar por veículo
|
||||||
created_by: current_user.id
|
created_by: current_user.id
|
||||||
)
|
)
|
||||||
ativo = true
|
ativo = true
|
||||||
@@ -97,7 +98,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.json do
|
format.json do
|
||||||
render json: resumo_json(params[:motorista], vehicle: params[:vehicle].presence).merge(
|
render json: resumo_json(params[:motorista], vehicles: params[:vehicle]).merge(
|
||||||
ativo: ativo, tracking_id: params[:tracking_id], tipo: params[:tipo]
|
ativo: ativo, tracking_id: params[:tracking_id], tipo: params[:tipo]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -116,8 +117,8 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
|
|
||||||
return render_nao_elegivel unless motorista_na_consolidacao?(motorista)
|
return render_nao_elegivel unless motorista_na_consolidacao?(motorista)
|
||||||
|
|
||||||
# Recorte por veículo (fechar um carro de cada vez). Sem veículo = todos.
|
# Recorte por veículo(s) selecionado(s). Sem veículo = todos.
|
||||||
elegiveis = tracking_ids_elegiveis(motorista, params[:vehicle].presence)
|
elegiveis = tracking_ids_elegiveis(motorista, params[:vehicle])
|
||||||
# Sem tracking_ids = todas as elegíveis do período/veículo (botão "todos").
|
# Sem tracking_ids = todas as elegíveis do período/veículo (botão "todos").
|
||||||
# Com tracking_ids → mantém só as que de fato são elegíveis para o motorista.
|
# Com tracking_ids → mantém só as que de fato são elegíveis para o motorista.
|
||||||
tracking_ids = tracking_ids.empty? ? elegiveis : (tracking_ids & elegiveis)
|
tracking_ids = tracking_ids.empty? ? elegiveis : (tracking_ids & elegiveis)
|
||||||
@@ -132,11 +133,13 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
.destroy_all
|
.destroy_all
|
||||||
else
|
else
|
||||||
# ADICIONA o pilar às entregas (não remove os demais já marcados).
|
# ADICIONA o pilar às entregas (não remove os demais já marcados).
|
||||||
valor = valor_classificacao(tipo)
|
valor = valor_classificacao(tipo)
|
||||||
|
veiculos = veiculos_dos_trackings(tracking_ids) # tracking_id => vehicle (snapshot)
|
||||||
tracking_ids.each do |tid|
|
tracking_ids.each do |tid|
|
||||||
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid, tipo: tipo)
|
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid, tipo: tipo)
|
||||||
ce.assign_attributes(motorista_nome: motorista,
|
ce.assign_attributes(motorista_nome: motorista,
|
||||||
valor_aplicado: valor, created_by: current_user.id)
|
valor_aplicado: valor, created_by: current_user.id)
|
||||||
|
ce.vehicle = veiculos[tid] if ce.vehicle.blank?
|
||||||
ce.save!
|
ce.save!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -148,10 +151,10 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
qtd: tracking_ids.size, motorista: motorista })
|
qtd: tracking_ids.size, motorista: motorista })
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.json { render json: resumo_json(motorista, vehicle: params[:vehicle].presence) }
|
format.json { render json: resumo_json(motorista, vehicles: params[:vehicle]) }
|
||||||
format.html do
|
format.html do
|
||||||
verbo = remover ? 'desmarcadas de' : 'marcadas como'
|
verbo = remover ? 'desmarcadas de' : 'marcadas como'
|
||||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista, vehicle: params[:vehicle].presence),
|
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle]).presence),
|
||||||
notice: "#{tracking_ids.size} entregas #{verbo} #{tipo.humanize}."
|
notice: "#{tracking_ids.size} entregas #{verbo} #{tipo.humanize}."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -246,6 +249,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
nf: params[:nf],
|
nf: params[:nf],
|
||||||
obs: params[:obs],
|
obs: params[:obs],
|
||||||
valor_extra: valor_extraordinaria_param,
|
valor_extra: valor_extraordinaria_param,
|
||||||
|
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
|
||||||
user: current_user
|
user: current_user
|
||||||
)
|
)
|
||||||
auditar!(:editar, @consolidacao,
|
auditar!(:editar, @consolidacao,
|
||||||
@@ -271,6 +275,7 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
motorista = @consolidacao.adicionar_termos!(
|
motorista = @consolidacao.adicionar_termos!(
|
||||||
motorista: params[:motorista].to_s.strip,
|
motorista: params[:motorista].to_s.strip,
|
||||||
quantidade: params[:quantidade],
|
quantidade: params[:quantidade],
|
||||||
|
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
|
||||||
user: current_user
|
user: current_user
|
||||||
)
|
)
|
||||||
auditar!(:editar, @consolidacao,
|
auditar!(:editar, @consolidacao,
|
||||||
@@ -315,12 +320,104 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/fechar_veiculo
|
||||||
|
# Consolida (fecha) UM veículo do motorista. Exige que o veículo esteja 100%
|
||||||
|
# classificado. O pagamento continua sendo por motorista (não é pago aqui).
|
||||||
|
def fechar_veiculo
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
motorista = params[:motorista]
|
||||||
|
veiculo = params[:vehicle].to_s
|
||||||
|
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
|
||||||
|
|
||||||
|
status = veiculos_status(motorista).find { |v| v[:vehicle] == veiculo }
|
||||||
|
return render_veiculo_erro('Classifique todas as entregas deste veículo antes de consolidar.') if status && !status[:completo]
|
||||||
|
|
||||||
|
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
|
||||||
|
cv.fechar!(current_user)
|
||||||
|
auditar!(:editar, @consolidacao, dados_novos: { fechar_veiculo: veiculo, motorista: motorista })
|
||||||
|
|
||||||
|
responder_veiculo(motorista, "Veículo #{veiculo} consolidado.")
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST .../reabrir_veiculo — reabre um veículo já consolidado (reversível).
|
||||||
|
def reabrir_veiculo
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
motorista = params[:motorista]
|
||||||
|
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
|
||||||
|
return render_veiculo_erro('Veículo não encontrado.') unless cv
|
||||||
|
|
||||||
|
cv.reabrir!(current_user)
|
||||||
|
auditar!(:editar, @consolidacao, dados_novos: { reabrir_veiculo: cv.vehicle, motorista: motorista })
|
||||||
|
responder_veiculo(motorista, "Veículo #{cv.vehicle} reaberto.")
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST .../arquivar_veiculo — tira um veículo do gate de finalização (reversível).
|
||||||
|
# As entregas já classificadas dele continuam somando no pagamento do motorista.
|
||||||
|
def arquivar_veiculo
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
motorista = params[:motorista]
|
||||||
|
veiculo = params[:vehicle].to_s
|
||||||
|
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
|
||||||
|
|
||||||
|
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
|
||||||
|
cv.arquivar!(current_user)
|
||||||
|
auditar!(:editar, @consolidacao, dados_novos: { arquivar_veiculo: veiculo, motorista: motorista })
|
||||||
|
responder_veiculo(motorista, "Veículo #{veiculo} arquivado — fora da finalização.")
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST .../desarquivar_veiculo — restaura um veículo arquivado.
|
||||||
|
def desarquivar_veiculo
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
motorista = params[:motorista]
|
||||||
|
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
|
||||||
|
return render_veiculo_erro('Veículo não encontrado.') unless cv
|
||||||
|
|
||||||
|
cv.desarquivar!(current_user)
|
||||||
|
auditar!(:editar, @consolidacao, dados_novos: { desarquivar_veiculo: cv.vehicle, motorista: motorista })
|
||||||
|
responder_veiculo(motorista, "Veículo #{cv.vehicle} restaurado.")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_consolidacao
|
def set_consolidacao
|
||||||
@consolidacao = Consolidacao.find(params[:consolidacao_id])
|
@consolidacao = Consolidacao.find(params[:consolidacao_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Resposta padrão das ações de veículo: JSON com o resumo atualizado (barra +
|
||||||
|
# chips com o novo estado) ou redirect de volta pra tela de validação.
|
||||||
|
def responder_veiculo(motorista, aviso)
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { render json: resumo_json(motorista, vehicles: params[:vehicle_sel]) }
|
||||||
|
format.html do
|
||||||
|
redirect_to validar_consolidacao_consolidacao_entregas_path(
|
||||||
|
@consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle_sel]).presence
|
||||||
|
), notice: aviso
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_veiculo_erro(msg)
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity }
|
||||||
|
format.html do
|
||||||
|
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: params[:motorista]),
|
||||||
|
alert: msg
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Veículo (snapshot da rastreio) de UM tracking_id. nil para sintéticos.
|
||||||
|
def veiculo_do_tracking(tracking_id)
|
||||||
|
Entrega.da_conta_gade.find_by(tracking_id: tracking_id)&.vehicle
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mapa tracking_id => vehicle para um lote (uma query só).
|
||||||
|
def veiculos_dos_trackings(ids)
|
||||||
|
return {} if ids.blank?
|
||||||
|
|
||||||
|
Entrega.da_conta_gade.where(tracking_id: ids).pluck(:tracking_id, :vehicle).to_h
|
||||||
|
end
|
||||||
|
|
||||||
def bloquear_se_finalizada
|
def bloquear_se_finalizada
|
||||||
return unless @consolidacao.finalizada? && current_user.operador?
|
return unless @consolidacao.finalizada? && current_user.operador?
|
||||||
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
||||||
@@ -347,35 +444,29 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
rel.pluck(:tracking_id)
|
rel.pluck(:tracking_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Recorte por um único veículo (trata o grupo "sem veículo").
|
# Normaliza o parâmetro de veículo (string única OU array vindo de vehicle[])
|
||||||
def filtrar_por_veiculo(rel, veiculo)
|
# numa lista limpa de selecionados.
|
||||||
veiculo == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.da_veiculo(veiculo)
|
def veiculos_param(raw)
|
||||||
|
Array(raw).reject(&:blank?)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recorte por um OU MAIS veículos (trata o grupo "sem veículo"). Lista vazia
|
||||||
|
# = sem recorte (todos os veículos).
|
||||||
|
def filtrar_por_veiculo(rel, veiculos)
|
||||||
|
lista = veiculos_param(veiculos)
|
||||||
|
return rel if lista.empty?
|
||||||
|
valores = lista.reject { |v| v == SEM_VEICULO }
|
||||||
|
valores += [nil, ''] if lista.include?(SEM_VEICULO)
|
||||||
|
rel.where(vehicle: valores)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Status "fechado" por veículo do motorista: lista de
|
# Status "fechado" por veículo do motorista: lista de
|
||||||
# { vehicle:, eligible:, classificadas:, fechado: }. Fonte única do filtro de
|
# { vehicle:, eligible:, classificadas:, fechado: }. Fonte única do filtro de
|
||||||
# carros e do acompanhamento (chips). 2 queries: elegíveis (com a coluna do
|
# carros e do acompanhamento (chips). 2 queries: elegíveis (com a coluna do
|
||||||
# veículo) e as já classificadas (não-manuais).
|
# veículo) e as já classificadas (não-manuais).
|
||||||
|
# Status por veículo do motorista (fonte única no model: Consolidacao#veiculos_do_motorista).
|
||||||
def veiculos_status(motorista)
|
def veiculos_status(motorista)
|
||||||
return [] unless motorista.present?
|
@consolidacao.veiculos_do_motorista(motorista)
|
||||||
|
|
||||||
rel = Entrega.atendidas.da_conta_gade
|
|
||||||
.no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim)
|
|
||||||
.do_motorista(motorista)
|
|
||||||
rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
|
||||||
rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
|
||||||
|
|
||||||
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
|
|
||||||
classificadas = @consolidacao.consolidacao_entregas
|
|
||||||
.where(motorista_nome: motorista, manual: false)
|
|
||||||
.distinct.pluck(:tracking_id).to_set
|
|
||||||
|
|
||||||
por_veiculo.map do |veiculo, linhas|
|
|
||||||
ids = linhas.map(&:first)
|
|
||||||
feito = ids.count { |id| classificadas.include?(id) }
|
|
||||||
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
|
|
||||||
fechado: ids.size.positive? && feito >= ids.size }
|
|
||||||
end.sort_by { |v| v[:vehicle] }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Valida que a entrega pertence ao motorista informado e ao período/veículo da
|
# Valida que a entrega pertence ao motorista informado e ao período/veículo da
|
||||||
@@ -447,21 +538,28 @@ class ConsolidacaoEntregasController < ApplicationController
|
|||||||
rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id)
|
rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
# `vehicle` (opcional): quando presente, `classificadas_escopo` reflete só aquele
|
# `vehicles` (opcional, string ou array): quando presente, TODO o resumo
|
||||||
# carro (para a barra de progresso por veículo). `veiculos_status` acompanha o
|
# (barra de progresso, contagem por pilar e valor total) reflete apenas o(s)
|
||||||
# payload para o front atualizar os chips sem recarregar.
|
# veículo(s) selecionado(s). `veiculos_status` acompanha o payload para o front
|
||||||
def resumo_json(motorista, vehicle: nil)
|
# atualizar os chips sem recarregar.
|
||||||
|
def resumo_json(motorista, vehicles: nil)
|
||||||
|
lista = veiculos_param(vehicles)
|
||||||
classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||||
status = veiculos_status(motorista)
|
status = veiculos_status(motorista)
|
||||||
classificadas = classificacoes.distinct.count(:tracking_id)
|
classificadas_motorista = classificacoes.distinct.count(:tracking_id)
|
||||||
escopo = if vehicle.present?
|
|
||||||
(status.find { |v| v[:vehicle] == vehicle } || {})[:classificadas] || 0
|
if lista.any?
|
||||||
else
|
# Recorta as classificações às NFs dos veículos selecionados.
|
||||||
classificadas
|
ids = tracking_ids_elegiveis(motorista, lista)
|
||||||
end
|
classificacoes = classificacoes.where(tracking_id: ids)
|
||||||
|
escopo = status.select { |v| lista.include?(v[:vehicle]) }.sum { |v| v[:classificadas] }
|
||||||
|
else
|
||||||
|
escopo = classificadas_motorista
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
classificadas: classificadas,
|
classificadas: classificadas_motorista,
|
||||||
classificadas_escopo: escopo,
|
classificadas_escopo: escopo,
|
||||||
manuais: contar_manuais(classificacoes.where(manual: true)),
|
manuais: contar_manuais(classificacoes.where(manual: true)),
|
||||||
por_tipo: classificacoes.group(:tipo).sum(:quantidade),
|
por_tipo: classificacoes.group(:tipo).sum(:quantidade),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
include Auditavel
|
include Auditavel
|
||||||
|
|
||||||
before_action :set_consolidacao,
|
before_action :set_consolidacao,
|
||||||
only: %i[show edit update destroy finalizar arquivar reativar wizard
|
only: %i[show edit update destroy finalizar finalizar_parcial arquivar reativar wizard
|
||||||
registrar_pagamento cancelar_pagamento
|
registrar_pagamento cancelar_pagamento
|
||||||
arquivar_motorista desarquivar_motorista
|
arquivar_motorista desarquivar_motorista
|
||||||
excluir_motorista adicionar_motorista
|
excluir_motorista adicionar_motorista
|
||||||
@@ -159,6 +159,10 @@ class ConsolidacoesController < ApplicationController
|
|||||||
@apontamentos = @consolidacao.consolidacao_entregas.apontamentos
|
@apontamentos = @consolidacao.consolidacao_entregas.apontamentos
|
||||||
.order(:motorista_nome, :tracking_id)
|
.order(:motorista_nome, :tracking_id)
|
||||||
.group_by { |ce| [ce.motorista_nome, ce.tracking_id] }
|
.group_by { |ce| [ce.motorista_nome, ce.tracking_id] }
|
||||||
|
# Veículos consolidados por motorista (breakdown informativo no card).
|
||||||
|
@veiculos_por_motorista = @consolidacao.consolidacao_veiculos.ativos
|
||||||
|
.order(:vehicle)
|
||||||
|
.group_by(&:motorista_nome)
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista
|
# GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista
|
||||||
@@ -177,14 +181,20 @@ class ConsolidacoesController < ApplicationController
|
|||||||
.apontamentos
|
.apontamentos
|
||||||
.where(motorista_nome: cm.motorista_nome)
|
.where(motorista_nome: cm.motorista_nome)
|
||||||
.distinct.count(:tracking_id)
|
.distinct.count(:tracking_id)
|
||||||
|
# Status por veículo (breakdown do card): total usados, consolidados (🔒),
|
||||||
|
# pendentes (não dá p/ consolidar ainda) e prontos (100%, falta consolidar).
|
||||||
|
vinfo = @consolidacao.veiculos_do_motorista(cm.motorista_nome).reject { |v| v[:arquivado] }
|
||||||
{
|
{
|
||||||
registro: cm,
|
registro: cm,
|
||||||
total: total,
|
total: total,
|
||||||
classificadas: classificadas,
|
classificadas: classificadas,
|
||||||
apontamentos: apontamentos,
|
apontamentos: apontamentos,
|
||||||
# Espelha o gate do servidor (todas_entregas_classificadas?): um motorista
|
veiculos_total: vinfo.size,
|
||||||
# só com apontamento manual tem 0 entregas elegíveis e já está "completo".
|
veiculos_fechados: vinfo.count { |v| v[:fechado] },
|
||||||
completo: total.zero? || classificadas >= total
|
veiculos_prontos: vinfo.count { |v| !v[:fechado] && v[:completo] },
|
||||||
|
veiculos_pendentes: vinfo.select { |v| !v[:fechado] && !v[:completo] && v[:eligible].positive? }
|
||||||
|
.map { |v| v[:vehicle] },
|
||||||
|
completo: @consolidacao.motorista_completo?(cm.motorista_nome)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -199,7 +209,26 @@ class ConsolidacoesController < ApplicationController
|
|||||||
redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.'
|
redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.'
|
||||||
else
|
else
|
||||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||||
alert: 'Não é possível finalizar: existem entregas não classificadas.'
|
alert: 'Não é possível finalizar: ainda há entregas não classificadas. Classifique-as ou arquive o veículo pendente.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:id/finalizar_parcial
|
||||||
|
# Atalho "finalizar só os veículos consolidados": arquiva os veículos que ainda
|
||||||
|
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
|
||||||
|
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
|
||||||
|
def finalizar_parcial
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
|
||||||
|
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
|
||||||
|
if @consolidacao.finalizar!(current_user)
|
||||||
|
auditar!(:finalizar, @consolidacao, dados_novos: { finalizar_parcial: true, veiculos_arquivados: arquivados })
|
||||||
|
NotificacaoService.notificar_finalizacao(@consolidacao)
|
||||||
|
redirect_to @consolidacao,
|
||||||
|
notice: "Consolidação finalizada. #{arquivados.size} veículo(s) pendente(s) ficaram de fora. Motoristas notificados."
|
||||||
|
else
|
||||||
|
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||||
|
alert: 'Não foi possível finalizar: ainda há entregas pendentes fora dos veículos arquivados.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -320,6 +349,7 @@ class ConsolidacoesController < ApplicationController
|
|||||||
nome = cm.motorista_nome
|
nome = cm.motorista_nome
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@consolidacao.consolidacao_entregas.where(motorista_nome: nome).delete_all
|
@consolidacao.consolidacao_entregas.where(motorista_nome: nome).delete_all
|
||||||
|
@consolidacao.consolidacao_veiculos.where(motorista_nome: nome).delete_all
|
||||||
cm.destroy!
|
cm.destroy!
|
||||||
end
|
end
|
||||||
# cm já não existe — atualiza o total da consolidação direto (soma dos ativos).
|
# cm já não existe — atualiza o total da consolidação direto (soma dos ativos).
|
||||||
|
|||||||
88
app/controllers/operacoes_dashboard_controller.rb
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# app/controllers/operacoes_dashboard_controller.rb
|
||||||
|
#
|
||||||
|
# Dashboard de análise de ENTREGAS por operação (separado do dashboard
|
||||||
|
# financeiro). O operador escolhe uma operação (UBS Norte, EMAD, ...) e vê os
|
||||||
|
# KPIs/insucessos/STS/mapa; também há os modos "global" (todas no período) e
|
||||||
|
# "comparar" (uma operação vs outra). Os números vêm de Analytics::OperacaoMetricas.
|
||||||
|
class OperacoesDashboardController < ApplicationController
|
||||||
|
MODOS = %w[operacao global comparar].freeze
|
||||||
|
|
||||||
|
def index
|
||||||
|
authorize :dashboard, :operacoes?
|
||||||
|
|
||||||
|
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||||
|
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||||
|
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||||
|
@tabelas_validas = Operacao.nomes_validos
|
||||||
|
@filtros, @chips = cross_filtros
|
||||||
|
@data_filtro = parse_data(params[:f_data]) # clique no gráfico "Entregas por Dia"
|
||||||
|
|
||||||
|
case @modo
|
||||||
|
when 'global'
|
||||||
|
# Global: agrega TODAS as operações dentro da faixa de datas escolhida.
|
||||||
|
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||||
|
when 'comparar'
|
||||||
|
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
||||||
|
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
||||||
|
@op_b = Operacao.sanitizar([params[:op_b]]).first || @tabelas_validas[1]
|
||||||
|
@metricas_a = montar([@op_a]) if @op_a
|
||||||
|
@metricas_b = montar([@op_b]) if @op_b
|
||||||
|
else
|
||||||
|
# Operação única: a operação inteira (a data vem dos próprios dados).
|
||||||
|
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||||
|
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||||
|
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cross-filter por clique nas tabelas. Cada param vira um filtro de coluna e um
|
||||||
|
# "chip" removível na tela. Retorna [filtros (coluna=>valor), chips (p/ a view)].
|
||||||
|
CROSS = {
|
||||||
|
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
|
||||||
|
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
|
||||||
|
'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
|
||||||
|
'f_obs' => { coluna: 'observation', rotulo: 'Observação' },
|
||||||
|
'f_resultado' => { coluna: 'status', rotulo: 'Resultado' }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# Rótulos amigáveis para o filtro de Resultado (clique no donut "Insucessos %").
|
||||||
|
RESULTADO_LABEL = { 'completed' => 'Completas', 'failed' => 'Falhas' }.freeze
|
||||||
|
|
||||||
|
def cross_filtros
|
||||||
|
filtros = {}
|
||||||
|
chips = []
|
||||||
|
CROSS.each do |param, cfg|
|
||||||
|
valor = params[param].to_s.strip
|
||||||
|
next if valor.empty?
|
||||||
|
filtros[cfg[:coluna]] = valor
|
||||||
|
display = param == 'f_resultado' ? (RESULTADO_LABEL[valor] || valor) : valor
|
||||||
|
chips << { param: param, valor: valor, display: display, rotulo: cfg[:rotulo] }
|
||||||
|
end
|
||||||
|
# Filtro por DIA (clique no gráfico "Entregas por Dia") — tratado à parte (data).
|
||||||
|
if (dia = parse_data(params[:f_data]))
|
||||||
|
chips << { param: 'f_data', valor: params[:f_data].to_s, display: dia.strftime('%d/%m/%Y'), rotulo: 'Dia' }
|
||||||
|
end
|
||||||
|
[filtros, chips]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.
|
||||||
|
# Espelha DashboardController#periodo_selecionado.
|
||||||
|
def periodo_selecionado
|
||||||
|
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
|
||||||
|
fim = parse_data(params[:fim]) || Date.current
|
||||||
|
inicio, fim = fim, inicio if fim < inicio
|
||||||
|
[inicio, fim]
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_data(str)
|
||||||
|
return nil if str.blank?
|
||||||
|
Date.parse(str)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra"]
|
static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra", "veiculo"]
|
||||||
static values = { apontarManualUrl: String }
|
static values = { apontarManualUrl: String }
|
||||||
|
|
||||||
abrir() {
|
abrir() {
|
||||||
@@ -52,7 +52,8 @@ export default class extends Controller {
|
|||||||
nf: this.nfTarget.value.trim(),
|
nf: this.nfTarget.value.trim(),
|
||||||
obs: this.obsTarget.value.trim(),
|
obs: this.obsTarget.value.trim(),
|
||||||
tipos: tipos,
|
tipos: tipos,
|
||||||
valor: valor
|
valor: valor,
|
||||||
|
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["modal", "quantidade", "erro"]
|
static targets = ["modal", "quantidade", "erro", "veiculo"]
|
||||||
static values = { apontarTermoUrl: String, motorista: String }
|
static values = { apontarTermoUrl: String, motorista: String }
|
||||||
|
|
||||||
abrir() {
|
abrir() {
|
||||||
@@ -55,7 +55,11 @@ export default class extends Controller {
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ motorista: motorista, quantidade: quantidade })
|
body: JSON.stringify({
|
||||||
|
motorista: motorista,
|
||||||
|
quantidade: quantidade,
|
||||||
|
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
|
||||||
|
})
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
if (!resp.ok || data.ok === false) {
|
if (!resp.ok || data.ok === false) {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Controller } from "@hotwired/stimulus"
|
|||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = [
|
static targets = [
|
||||||
"linha", "checkbox", "selecionarTodos",
|
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
|
||||||
|
"resumo", "listaCol", "btnResumoLabel",
|
||||||
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
|
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
|
||||||
|
"consolidarBtn", "consolidarHint",
|
||||||
"contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo",
|
"contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo",
|
||||||
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria",
|
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria",
|
||||||
"qtdTermo", "qtdManuais", "valorTotal"
|
"qtdTermo", "qtdManuais", "valorTotal"
|
||||||
@@ -18,7 +20,7 @@ export default class extends Controller {
|
|||||||
massaUrl: String, // POST classificar_em_massa
|
massaUrl: String, // POST classificar_em_massa
|
||||||
motorista: String,
|
motorista: String,
|
||||||
total: Number,
|
total: Number,
|
||||||
veiculo: String, // veículo selecionado (filtro "fechar por carro")
|
veiculos: Array, // veículo(s) selecionado(s) — filtro multi-seleção
|
||||||
precoExtraordinaria: Number // preço padrão sugerido no prompt
|
precoExtraordinaria: Number // preço padrão sugerido no prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ export default class extends Controller {
|
|||||||
tracking_id: tracking,
|
tracking_id: tracking,
|
||||||
tipo: tipo,
|
tipo: tipo,
|
||||||
motorista: this.motoristaValue,
|
motorista: this.motoristaValue,
|
||||||
vehicle: this.veiculoValue,
|
vehicle: this.veiculosValue,
|
||||||
valor: valor
|
valor: valor
|
||||||
})
|
})
|
||||||
// resp.ativo indica se o pilar ficou ligado ou desligado (toggle)
|
// resp.ativo indica se o pilar ficou ligado ou desligado (toggle)
|
||||||
@@ -64,10 +66,38 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Selecionar / desmarcar todos os checkboxes ─────────────
|
// ── Oculta/mostra o resumo lateral (lista ocupa a largura toda) ──
|
||||||
|
toggleResumo() {
|
||||||
|
const escondido = this.resumoTarget.classList.toggle("hidden")
|
||||||
|
if (this.hasListaColTarget) {
|
||||||
|
this.listaColTarget.classList.toggle("lg:col-span-4", escondido)
|
||||||
|
this.listaColTarget.classList.toggle("lg:col-span-3", !escondido)
|
||||||
|
}
|
||||||
|
if (this.hasBtnResumoLabelTarget)
|
||||||
|
this.btnResumoLabelTarget.textContent = escondido ? "Mostrar resumo" : "Ocultar resumo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Busca na listagem de notas (filtra os cards por NF/local/endereço/veículo) ──
|
||||||
|
filtrarBusca(event) {
|
||||||
|
const q = (event.currentTarget.value || "").trim().toLowerCase()
|
||||||
|
let visiveis = 0
|
||||||
|
this.linhaTargets.forEach(l => {
|
||||||
|
const ok = q === "" || (l.dataset.busca || "").includes(q)
|
||||||
|
l.classList.toggle("hidden", !ok)
|
||||||
|
if (ok) visiveis++
|
||||||
|
})
|
||||||
|
if (this.hasBuscaVaziaTarget)
|
||||||
|
this.buscaVaziaTarget.classList.toggle("hidden", q === "" || visiveis > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selecionar / desmarcar todos os checkboxes (só os visíveis na busca) ──
|
||||||
selecionarTodos(event) {
|
selecionarTodos(event) {
|
||||||
const marcar = event.currentTarget.checked
|
const marcar = event.currentTarget.checked
|
||||||
this.checkboxTargets.forEach(c => { c.checked = marcar })
|
this.checkboxTargets.forEach(c => {
|
||||||
|
const linha = c.closest("[data-busca]")
|
||||||
|
if (linha && linha.classList.contains("hidden")) return
|
||||||
|
c.checked = marcar
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// O pilar está ativo (ring-2) na entrega indicada?
|
// O pilar está ativo (ring-2) na entrega indicada?
|
||||||
@@ -101,7 +131,7 @@ export default class extends Controller {
|
|||||||
const resp = await this.post(this.massaUrlValue, {
|
const resp = await this.post(this.massaUrlValue, {
|
||||||
tipo: tipo,
|
tipo: tipo,
|
||||||
motorista: this.motoristaValue,
|
motorista: this.motoristaValue,
|
||||||
vehicle: this.veiculoValue,
|
vehicle: this.veiculosValue,
|
||||||
tracking_ids: ids,
|
tracking_ids: ids,
|
||||||
acao: remover ? "remover" : "adicionar",
|
acao: remover ? "remover" : "adicionar",
|
||||||
valor: valor
|
valor: valor
|
||||||
@@ -188,6 +218,23 @@ export default class extends Controller {
|
|||||||
this.barraTarget.classList.toggle("bg-green-500", pct >= 100)
|
this.barraTarget.classList.toggle("bg-green-500", pct >= 100)
|
||||||
this.barraTarget.classList.toggle("bg-orange-500", pct < 100)
|
this.barraTarget.classList.toggle("bg-orange-500", pct < 100)
|
||||||
|
|
||||||
|
// Botão "Consolidar este veículo" (só existe quando UM veículo está
|
||||||
|
// selecionado e ainda não fechado): habilita quando o escopo chega a 100%.
|
||||||
|
if (this.hasConsolidarBtnTarget) {
|
||||||
|
const pronto = pct >= 100
|
||||||
|
this.consolidarBtnTarget.disabled = !pronto
|
||||||
|
this.consolidarBtnTarget.classList.toggle("bg-green-600", pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("hover:bg-green-500", pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("text-white", pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("cursor-pointer", pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("bg-[#2a2a2a]", !pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("text-gray-600", !pronto)
|
||||||
|
this.consolidarBtnTarget.classList.toggle("cursor-not-allowed", !pronto)
|
||||||
|
if (this.hasConsolidarHintTarget)
|
||||||
|
this.consolidarHintTarget.textContent =
|
||||||
|
pronto ? "Pronto para consolidar." : "Classifique 100% deste veículo para consolidar."
|
||||||
|
}
|
||||||
|
|
||||||
if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status)
|
if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,9 +245,12 @@ export default class extends Controller {
|
|||||||
const porVeiculo = {}
|
const porVeiculo = {}
|
||||||
status.forEach(v => { porVeiculo[v.vehicle] = v })
|
status.forEach(v => { porVeiculo[v.vehicle] = v })
|
||||||
|
|
||||||
const VERDE = ["border-green-500", "text-green-400", "bg-green-500/10"]
|
const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"]
|
||||||
const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"]
|
const VERDE_FECHADO = ["border-green-500", "text-green-400", "bg-green-500/10"]
|
||||||
const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"]
|
const VERDE_PRONTO = ["border-green-600/50", "text-green-400"]
|
||||||
|
const ARQUIVADO = ["border-dashed", "border-[#3a3a3a]", "text-gray-500"]
|
||||||
|
const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"]
|
||||||
|
const TODAS = [...LARANJA, ...VERDE_FECHADO, ...VERDE_PRONTO, ...ARQUIVADO, ...NEUTRO]
|
||||||
|
|
||||||
this.chipVeiculoTargets.forEach(chip => {
|
this.chipVeiculoTargets.forEach(chip => {
|
||||||
const dados = porVeiculo[chip.dataset.vehicle]
|
const dados = porVeiculo[chip.dataset.vehicle]
|
||||||
@@ -208,16 +258,23 @@ export default class extends Controller {
|
|||||||
const feito = chip.querySelector("[data-chip-feito]")
|
const feito = chip.querySelector("[data-chip-feito]")
|
||||||
const total = chip.querySelector("[data-chip-total]")
|
const total = chip.querySelector("[data-chip-total]")
|
||||||
const check = chip.querySelector("[data-chip-check]")
|
const check = chip.querySelector("[data-chip-check]")
|
||||||
|
const lock = chip.querySelector("[data-chip-lock]")
|
||||||
if (feito) feito.textContent = dados.classificadas
|
if (feito) feito.textContent = dados.classificadas
|
||||||
if (total) total.textContent = dados.eligible
|
if (total) total.textContent = dados.eligible
|
||||||
if (check) check.textContent = dados.fechado ? "✓" : ""
|
if (check) check.textContent = dados.completo ? "✓" : ""
|
||||||
|
if (lock) lock.classList.toggle("hidden", !dados.fechado)
|
||||||
|
|
||||||
// Estado de cor determinístico: verde (fechado) > laranja (selecionado) > neutro.
|
// Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
|
||||||
chip.classList.remove(...VERDE, ...LARANJA, ...NEUTRO)
|
// pronto(verde) > arquivado(cinza) > neutro.
|
||||||
if (dados.fechado) {
|
chip.classList.remove(...TODAS)
|
||||||
chip.classList.add(...VERDE)
|
if (this.veiculosValue.includes(chip.dataset.vehicle)) {
|
||||||
} else if (chip.dataset.vehicle === this.veiculoValue) {
|
|
||||||
chip.classList.add(...LARANJA)
|
chip.classList.add(...LARANJA)
|
||||||
|
} else if (dados.fechado) {
|
||||||
|
chip.classList.add(...VERDE_FECHADO)
|
||||||
|
} else if (dados.completo) {
|
||||||
|
chip.classList.add(...VERDE_PRONTO)
|
||||||
|
} else if (dados.arquivado) {
|
||||||
|
chip.classList.add(...ARQUIVADO)
|
||||||
} else {
|
} else {
|
||||||
chip.classList.add(...NEUTRO)
|
chip.classList.add(...NEUTRO)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ class Consolidacao < ApplicationRecord
|
|||||||
|
|
||||||
has_many :consolidacao_motoristas, dependent: :destroy
|
has_many :consolidacao_motoristas, dependent: :destroy
|
||||||
has_many :consolidacao_entregas, dependent: :destroy
|
has_many :consolidacao_entregas, dependent: :destroy
|
||||||
|
has_many :consolidacao_veiculos, dependent: :destroy
|
||||||
|
|
||||||
|
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Mesmo
|
||||||
|
# sentinel do controller e de ConsolidacaoVeiculo.
|
||||||
|
SEM_VEICULO = ConsolidacaoVeiculo::SEM_VEICULO
|
||||||
|
|
||||||
# ── Enums ───────────────────────────────────────────────────
|
# ── Enums ───────────────────────────────────────────────────
|
||||||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||||
@@ -111,21 +116,34 @@ class Consolidacao < ApplicationRecord
|
|||||||
def finalizar!(user)
|
def finalizar!(user)
|
||||||
return false unless todas_entregas_classificadas?
|
return false unless todas_entregas_classificadas?
|
||||||
|
|
||||||
update!(
|
transaction do
|
||||||
status: :finalizada,
|
# Finalizar a consolidação fecha (consolida) os veículos que ainda estavam
|
||||||
finalizado_por: user.id,
|
# abertos — mantém o estado por veículo coerente com o todo finalizado.
|
||||||
finalizado_em: Time.current
|
consolidacao_veiculos.ativos.abertos.update_all(
|
||||||
)
|
fechado_em: Time.current, fechado_por: user.id, updated_at: Time.current
|
||||||
|
)
|
||||||
|
update!(
|
||||||
|
status: :finalizada,
|
||||||
|
finalizado_por: user.id,
|
||||||
|
finalizado_em: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Só está completa quando CADA motorista tem todas as suas entregas elegíveis
|
# Só está completa quando CADA motorista ativo está completo (nada por
|
||||||
# classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas
|
# classificar, descontados os veículos arquivados). Fechar veículo é um passo
|
||||||
# pendentes quando outro tinha entregas a mais.)
|
# independente; ao finalizar, os veículos abertos são consolidados juntos.
|
||||||
def todas_entregas_classificadas?
|
def todas_entregas_classificadas?
|
||||||
consolidacao_motoristas.ativos.all? do |cm|
|
consolidacao_motoristas.ativos.all? { |cm| motorista_completo?(cm.motorista_nome) }
|
||||||
total = entregas_elegiveis_count(cm.motorista_nome)
|
end
|
||||||
total.zero? || classificadas_count(cm.motorista_nome) >= total
|
|
||||||
end
|
# Completude de UM motorista: não sobra entrega elegível por classificar. Os
|
||||||
|
# veículos ARQUIVADOS saem da conta (suas entregas deixam de ser exigidas),
|
||||||
|
# permitindo finalizar deixando um carro problemático de fora. Fechar um veículo
|
||||||
|
# é um passo independente (lock) — a finalização em si fecha os que restarem.
|
||||||
|
def motorista_completo?(motorista)
|
||||||
|
total = entregas_elegiveis_count(motorista) - eligible_veiculos_arquivados(motorista)
|
||||||
|
total.zero? || classificadas_count(motorista) >= total
|
||||||
end
|
end
|
||||||
|
|
||||||
def percentual_classificado
|
def percentual_classificado
|
||||||
@@ -153,6 +171,28 @@ class Consolidacao < ApplicationRecord
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Elegíveis de UM veículo específico do motorista (respeita filtros da
|
||||||
|
# consolidação). O bucket "(sem veículo)" casa as entregas com vehicle NULL/vazio.
|
||||||
|
def entregas_elegiveis_count_veiculo(motorista, vehicle)
|
||||||
|
rel = Entrega.atendidas.da_conta_gade
|
||||||
|
.no_periodo_checkout(data_inicio, data_fim)
|
||||||
|
.do_motorista(motorista)
|
||||||
|
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||||||
|
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||||||
|
rel = vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
|
||||||
|
rel.count
|
||||||
|
end
|
||||||
|
|
||||||
|
# Total de elegíveis dos veículos ARQUIVADOS do motorista — descontado do gate
|
||||||
|
# para que arquivar um carro problemático libere a finalização. Só toca a base
|
||||||
|
# externa quando existe algum veículo arquivado (mantém os testes isolados).
|
||||||
|
def eligible_veiculos_arquivados(motorista)
|
||||||
|
arquivados = consolidacao_veiculos.arquivados.where(motorista_nome: motorista)
|
||||||
|
return 0 if arquivados.empty?
|
||||||
|
|
||||||
|
arquivados.sum { |cv| entregas_elegiveis_count_veiculo(motorista, cv.vehicle) }
|
||||||
|
end
|
||||||
|
|
||||||
# Rótulos amigáveis das operações vinculadas (para exibição).
|
# Rótulos amigáveis das operações vinculadas (para exibição).
|
||||||
def operacoes_label
|
def operacoes_label
|
||||||
Array(operacoes).map { |t| Operacao.label(t) }
|
Array(operacoes).map { |t| Operacao.label(t) }
|
||||||
@@ -168,6 +208,54 @@ class Consolidacao < ApplicationRecord
|
|||||||
.distinct.count(:tracking_id)
|
.distinct.count(:tracking_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Status por veículo do motorista: array de { vehicle, eligible, classificadas,
|
||||||
|
# completo, fechado, arquivado }. `completo` = 100% classificado (pode consolidar);
|
||||||
|
# `fechado` = veículo consolidado (🔒); `arquivado` = fora do gate. Une os veículos
|
||||||
|
# com entregas elegíveis (rastreio) com os que só têm linha em consolidacao_veiculos
|
||||||
|
# (arquivados / buckets manuais). Fonte única do filtro de carros e do wizard.
|
||||||
|
def veiculos_do_motorista(motorista)
|
||||||
|
return [] unless motorista.present?
|
||||||
|
|
||||||
|
rel = Entrega.atendidas.da_conta_gade
|
||||||
|
.no_periodo_checkout(data_inicio, data_fim)
|
||||||
|
.do_motorista(motorista)
|
||||||
|
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||||||
|
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||||||
|
|
||||||
|
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
|
||||||
|
classificadas = consolidacao_entregas.where(motorista_nome: motorista, manual: false)
|
||||||
|
.distinct.pluck(:tracking_id).to_set
|
||||||
|
estados = consolidacao_veiculos.where(motorista_nome: motorista).index_by(&:vehicle)
|
||||||
|
|
||||||
|
(por_veiculo.keys + estados.keys).uniq.map do |veiculo|
|
||||||
|
ids = (por_veiculo[veiculo] || []).map(&:first)
|
||||||
|
feito = ids.count { |id| classificadas.include?(id) }
|
||||||
|
cv = estados[veiculo]
|
||||||
|
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
|
||||||
|
completo: ids.size.positive? && feito >= ids.size,
|
||||||
|
fechado: cv&.fechado? || false,
|
||||||
|
arquivado: cv&.arquivado? || false }
|
||||||
|
end.sort_by { |v| v[:vehicle] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Arquiva os veículos PENDENTES (com entregas elegíveis mas não 100% classificadas)
|
||||||
|
# de todos os motoristas ativos, deixando-os de fora da finalização. Usado pelo
|
||||||
|
# atalho "arquivar pendentes e finalizar" — os prontos/consolidados seguem no todo.
|
||||||
|
# Retorna a lista "Motorista · Veículo" arquivada.
|
||||||
|
def arquivar_veiculos_pendentes!(user)
|
||||||
|
arquivados = []
|
||||||
|
consolidacao_motoristas.ativos.each do |cm|
|
||||||
|
veiculos_do_motorista(cm.motorista_nome).each do |v|
|
||||||
|
next if v[:fechado] || v[:arquivado] || v[:completo] || v[:eligible].zero?
|
||||||
|
|
||||||
|
cv = consolidacao_veiculos.find_or_create_by!(motorista_nome: cm.motorista_nome, vehicle: v[:vehicle])
|
||||||
|
cv.arquivar!(user)
|
||||||
|
arquivados << "#{cm.motorista_nome} · #{v[:vehicle]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
arquivados
|
||||||
|
end
|
||||||
|
|
||||||
# ── Apontamento manual ──────────────────────────────────────
|
# ── Apontamento manual ──────────────────────────────────────
|
||||||
# Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares
|
# Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares
|
||||||
# (tipos). O motorista vem da própria entrega e é incluído na consolidação se
|
# (tipos). O motorista vem da própria entrega e é incluído na consolidação se
|
||||||
@@ -190,6 +278,7 @@ class Consolidacao < ApplicationRecord
|
|||||||
motorista_nome: motorista,
|
motorista_nome: motorista,
|
||||||
valor_aplicado: ConsolidacaoEntrega.valor_para(tipo),
|
valor_aplicado: ConsolidacaoEntrega.valor_para(tipo),
|
||||||
manual: true,
|
manual: true,
|
||||||
|
vehicle: entrega.try(:vehicle).presence, # snapshot do veículo da rastreio
|
||||||
created_by: user&.id
|
created_by: user&.id
|
||||||
)
|
)
|
||||||
ce.save!
|
ce.save!
|
||||||
@@ -208,7 +297,7 @@ class Consolidacao < ApplicationRecord
|
|||||||
# O motorista vem digitado (não de uma Entrega) e é incluído se ainda não
|
# O motorista vem digitado (não de uma Entrega) e é incluído se ainda não
|
||||||
# estiver. tracking_id é sintético (não há Entrega), e a NF/observação ficam
|
# estiver. tracking_id é sintético (não há Entrega), e a NF/observação ficam
|
||||||
# guardadas em nf_manual/obs_manual.
|
# guardadas em nf_manual/obs_manual.
|
||||||
def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil)
|
def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil, vehicle: nil)
|
||||||
tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq
|
tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq
|
||||||
raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty?
|
raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty?
|
||||||
|
|
||||||
@@ -232,6 +321,7 @@ class Consolidacao < ApplicationRecord
|
|||||||
motorista_nome: nome,
|
motorista_nome: nome,
|
||||||
valor_aplicado: valor,
|
valor_aplicado: valor,
|
||||||
manual: true,
|
manual: true,
|
||||||
|
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||||||
nf_manual: nf.presence,
|
nf_manual: nf.presence,
|
||||||
obs_manual: obs.presence,
|
obs_manual: obs.presence,
|
||||||
created_by: user&.id
|
created_by: user&.id
|
||||||
@@ -249,7 +339,7 @@ class Consolidacao < ApplicationRecord
|
|||||||
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
|
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
|
||||||
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
|
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
|
||||||
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
|
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
|
||||||
def adicionar_termos!(motorista:, quantidade:, user:)
|
def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil)
|
||||||
nome = motorista.to_s.strip
|
nome = motorista.to_s.strip
|
||||||
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
|
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
|
||||||
|
|
||||||
@@ -266,6 +356,7 @@ class Consolidacao < ApplicationRecord
|
|||||||
quantidade: qtd,
|
quantidade: qtd,
|
||||||
valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote
|
valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote
|
||||||
manual: true,
|
manual: true,
|
||||||
|
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||||||
created_by: user&.id
|
created_by: user&.id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -286,6 +377,27 @@ class Consolidacao < ApplicationRecord
|
|||||||
end
|
end
|
||||||
cm.update_column(:valor_total, total)
|
cm.update_column(:valor_total, total)
|
||||||
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
||||||
|
sincronizar_veiculos!(nome)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
||||||
|
# pilares já classificados (100% DB — não toca a base externa). Cria/atualiza uma
|
||||||
|
# linha por veículo presente nas classificações e remove as que ficaram sem
|
||||||
|
# pilares e ainda não foram fechadas/arquivadas. Veículos elegíveis mas 100% sem
|
||||||
|
# classificar não geram linha aqui — o gate já os pega por classificadas < total.
|
||||||
|
def sincronizar_veiculos!(nome)
|
||||||
|
entregas = consolidacao_entregas.where(motorista_nome: nome)
|
||||||
|
presentes = entregas.pluck(:vehicle).map { |v| v.presence || SEM_VEICULO }.uniq
|
||||||
|
|
||||||
|
presentes.each do |veic|
|
||||||
|
cv = consolidacao_veiculos.find_or_initialize_by(motorista_nome: nome, vehicle: veic)
|
||||||
|
cv.save! # before_save :recalcular_valor atualiza o valor_total do veículo
|
||||||
|
end
|
||||||
|
|
||||||
|
consolidacao_veiculos.where(motorista_nome: nome)
|
||||||
|
.where(fechado_em: nil, arquivado_em: nil)
|
||||||
|
.where.not(vehicle: presentes)
|
||||||
|
.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
71
app/models/consolidacao_veiculo.rb
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# app/models/consolidacao_veiculo.rb
|
||||||
|
# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha os padrões
|
||||||
|
# de ConsolidacaoMotorista (arquivamento reversível + valor_total recalculado dos
|
||||||
|
# pilares), mas o que ela controla é o estado "fechado/consolidado" de cada carro.
|
||||||
|
# O PAGAMENTO continua no motorista (ConsolidacaoMotorista) — ver [[consolidacao]].
|
||||||
|
class ConsolidacaoVeiculo < ApplicationRecord
|
||||||
|
# Nome da tabela fixado (mesmo motivo dos outros: inflector pt-BR).
|
||||||
|
self.table_name = 'consolidacao_veiculos'
|
||||||
|
|
||||||
|
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Igual ao
|
||||||
|
# sentinel usado no controller e na migração — NULL quebraria o índice único.
|
||||||
|
SEM_VEICULO = '(sem veículo)'
|
||||||
|
|
||||||
|
belongs_to :consolidacao
|
||||||
|
belongs_to :fechador, class_name: 'User', foreign_key: :fechado_por, optional: true
|
||||||
|
belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true
|
||||||
|
|
||||||
|
validates :motorista_nome, presence: true
|
||||||
|
validates :vehicle, presence: true
|
||||||
|
validates :vehicle, uniqueness: { scope: %i[consolidacao_id motorista_nome] }
|
||||||
|
|
||||||
|
before_save :recalcular_valor
|
||||||
|
|
||||||
|
# ── Fechamento (consolidação do veículo, reversível) ────────
|
||||||
|
scope :fechados, -> { where.not(fechado_em: nil) }
|
||||||
|
scope :abertos, -> { where(fechado_em: nil) }
|
||||||
|
|
||||||
|
# ── Arquivamento (reversível) — sai do gate, mantém os dados ─
|
||||||
|
scope :ativos, -> { where(arquivado_em: nil) }
|
||||||
|
scope :arquivados, -> { where.not(arquivado_em: nil) }
|
||||||
|
|
||||||
|
def fechado?
|
||||||
|
fechado_em.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def arquivado?
|
||||||
|
arquivado_em.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# update_columns: não dispara before_save (o valor já está correto no fechamento).
|
||||||
|
def fechar!(user)
|
||||||
|
update_columns(fechado_em: Time.current, fechado_por: user&.id, updated_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reabrir!(_user)
|
||||||
|
update_columns(fechado_em: nil, fechado_por: nil, updated_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def arquivar!(user)
|
||||||
|
update_columns(arquivado_em: Time.current, arquivado_por: user&.id, updated_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
def desarquivar!(_user)
|
||||||
|
update_columns(arquivado_em: nil, arquivado_por: nil, updated_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Soma dos pilares deste (motorista + veículo). Mesma regra do ConsolidacaoMotorista:
|
||||||
|
# o valor_aplicado capturado na classificação é a fonte da verdade e o desconto
|
||||||
|
# subtrai. O bucket "(sem veículo)" casa os pilares com vehicle NULL/vazio.
|
||||||
|
def recalcular_valor
|
||||||
|
self.valor_total = escopo_entregas
|
||||||
|
.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def escopo_entregas
|
||||||
|
rel = consolidacao.consolidacao_entregas.where(motorista_nome: motorista_nome)
|
||||||
|
vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -37,17 +37,28 @@ class Operacao
|
|||||||
nomes_validos.include?(nome.to_s)
|
nomes_validos.include?(nome.to_s)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Rótulo amigável: tira o prefixo e formata.
|
# Rótulo amigável: tira o prefixo e formata em CAIXA ALTA (para não parecer o
|
||||||
|
# nome cru da tabela do banco).
|
||||||
def label(tabela)
|
def label(tabela)
|
||||||
tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.titleize
|
tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.upcase
|
||||||
end
|
end
|
||||||
|
|
||||||
# Abreviações de mês usadas nos nomes das tabelas (gade_entregas_emad_fev_2026).
|
# Meses usados nos nomes das tabelas (gade_entregas_emad_fev_2026). Aceita a
|
||||||
|
# abreviação (fev) E o nome por extenso (fevereiro) — o cliente usa os dois
|
||||||
|
# (ex.: "..._ubs_norte_maio_2026"), então ambos precisam cair no mês certo.
|
||||||
MESES = {
|
MESES = {
|
||||||
'jan' => [1, 'Janeiro'], 'fev' => [2, 'Fevereiro'], 'mar' => [3, 'Março'],
|
'jan' => [1, 'Janeiro'], 'janeiro' => [1, 'Janeiro'],
|
||||||
'abr' => [4, 'Abril'], 'mai' => [5, 'Maio'], 'jun' => [6, 'Junho'],
|
'fev' => [2, 'Fevereiro'], 'fevereiro' => [2, 'Fevereiro'],
|
||||||
'jul' => [7, 'Julho'], 'ago' => [8, 'Agosto'], 'set' => [9, 'Setembro'],
|
'mar' => [3, 'Março'], 'marco' => [3, 'Março'], 'março' => [3, 'Março'],
|
||||||
'out' => [10, 'Outubro'], 'nov' => [11, 'Novembro'], 'dez' => [12, 'Dezembro']
|
'abr' => [4, 'Abril'], 'abril' => [4, 'Abril'],
|
||||||
|
'mai' => [5, 'Maio'], 'maio' => [5, 'Maio'],
|
||||||
|
'jun' => [6, 'Junho'], 'junho' => [6, 'Junho'],
|
||||||
|
'jul' => [7, 'Julho'], 'julho' => [7, 'Julho'],
|
||||||
|
'ago' => [8, 'Agosto'], 'agosto' => [8, 'Agosto'],
|
||||||
|
'set' => [9, 'Setembro'], 'setembro' => [9, 'Setembro'],
|
||||||
|
'out' => [10, 'Outubro'], 'outubro' => [10, 'Outubro'],
|
||||||
|
'nov' => [11, 'Novembro'], 'novembro' => [11, 'Novembro'],
|
||||||
|
'dez' => [12, 'Dezembro'], 'dezembro' => [12, 'Dezembro']
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
# Agrupa as operações por mês/ano (extraído do nome da tabela), do mais recente
|
# Agrupa as operações por mês/ano (extraído do nome da tabela), do mais recente
|
||||||
@@ -99,7 +110,7 @@ class Operacao
|
|||||||
resto = (tokens[0...idx] + tokens[(ano ? idx + 2 : idx + 1)..]).join(' ').strip
|
resto = (tokens[0...idx] + tokens[(ano ? idx + 2 : idx + 1)..]).join(' ').strip
|
||||||
{
|
{
|
||||||
titulo: ano ? "#{nome} #{ano}" : nome,
|
titulo: ano ? "#{nome} #{ano}" : nome,
|
||||||
item: resto.presence&.titleize || nome,
|
item: (resto.presence || nome).to_s.upcase,
|
||||||
# mais recente primeiro; sem ano vai pro fim do bloco do mês
|
# mais recente primeiro; sem ano vai pro fim do bloco do mês
|
||||||
ordem: [-(ano || 0), -num]
|
ordem: [-(ano || 0), -num]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard)
|
|||||||
def index?
|
def index?
|
||||||
user.admin? || user.gerente?
|
user.admin? || user.gerente?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
|
||||||
|
def operacoes?
|
||||||
|
!user.motorista?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
296
app/services/analytics/operacao_metricas.rb
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# app/services/analytics/operacao_metricas.rb
|
||||||
|
#
|
||||||
|
# Núcleo de dados do "Dashboard de Operações".
|
||||||
|
#
|
||||||
|
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
|
||||||
|
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
||||||
|
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
||||||
|
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
||||||
|
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
|
||||||
|
# e o mapa de calor).
|
||||||
|
#
|
||||||
|
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
|
||||||
|
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||||
|
# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA.
|
||||||
|
module Analytics
|
||||||
|
class OperacaoMetricas
|
||||||
|
# Centro padrão do mapa quando não há coordenadas (Grande São Paulo).
|
||||||
|
SP_CENTRO = [-23.55, -46.63].freeze
|
||||||
|
|
||||||
|
# inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a
|
||||||
|
# própria operação inteira (visão de operação única). O filtro de período só é
|
||||||
|
# usado na visão Global.
|
||||||
|
#
|
||||||
|
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
|
||||||
|
# numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
|
||||||
|
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
|
||||||
|
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||||
|
@tabelas = Operacao.sanitizar(Array(tabelas))
|
||||||
|
@inicio = inicio&.to_date
|
||||||
|
@fim = fim&.to_date
|
||||||
|
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
|
||||||
|
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
|
||||||
|
end
|
||||||
|
|
||||||
|
def operacoes_label
|
||||||
|
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
||||||
|
def linhas
|
||||||
|
@linhas ||= begin
|
||||||
|
base = @filtros.empty? ? registros
|
||||||
|
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
||||||
|
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Taxas (úteis no comparativo).
|
||||||
|
def taxa_sucesso
|
||||||
|
pct(sucesso, total)
|
||||||
|
end
|
||||||
|
|
||||||
|
def taxa_insucesso
|
||||||
|
pct(recusas, total)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Faixa real das entregas carregadas (pela data de checkout) — para exibir o
|
||||||
|
# período natural da operação no cabeçalho/painel.
|
||||||
|
def data_inicio
|
||||||
|
datas_checkout.min
|
||||||
|
end
|
||||||
|
|
||||||
|
def data_fim
|
||||||
|
datas_checkout.max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Linhas cruas (Array de Hash com chave string) — base de todas as agregações.
|
||||||
|
def registros
|
||||||
|
@registros ||= carregar
|
||||||
|
end
|
||||||
|
|
||||||
|
def vazio?
|
||||||
|
registros.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── KPIs ─────────────────────────────────────────────────────
|
||||||
|
def total
|
||||||
|
linhas.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def sucesso
|
||||||
|
linhas.count { |r| r['status'] == 'completed' }
|
||||||
|
end
|
||||||
|
|
||||||
|
def recusas
|
||||||
|
linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Em aberto: nem concluídas nem falhadas.
|
||||||
|
def pendentes
|
||||||
|
total - sucesso - recusas
|
||||||
|
end
|
||||||
|
|
||||||
|
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||||
|
def insucessos_pct
|
||||||
|
{
|
||||||
|
completed: sucesso,
|
||||||
|
failed: recusas,
|
||||||
|
pct_completed: pct(sucesso, total),
|
||||||
|
pct_failed: pct(recusas, total)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Falhas agrupadas por observação (motivo), % sobre o total de falhas.
|
||||||
|
def indices_falha
|
||||||
|
falhas = linhas.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||||
|
total_falhas = falhas.size
|
||||||
|
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
|
||||||
|
.map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
|
||||||
|
.sort_by { |h| -h[:total] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Contagem por status da tabela gade (RECORRENTE/NOVO/...).
|
||||||
|
def por_status_gade
|
||||||
|
linhas.group_by { |r| r['status_gade'].presence || '—' }
|
||||||
|
.map { |st, rows| { status: st, total: rows.size } }
|
||||||
|
.sort_by { |h| -h[:total] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# NFs por motorista (todas as entregas), desc.
|
||||||
|
def por_motorista
|
||||||
|
contagem(linhas, 'driver')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
|
||||||
|
def por_sts
|
||||||
|
contagem(linhas.select { |r| r['status'] == 'completed' }, 'contact_name')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
|
||||||
|
def por_dia
|
||||||
|
por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
|
||||||
|
linhas.each do |r|
|
||||||
|
d = data_de(r['checkout'])
|
||||||
|
next unless d
|
||||||
|
if r['status'] == 'completed'
|
||||||
|
por_data[d][:completed] += 1
|
||||||
|
elsif Entrega::STATUS_FALHA.include?(r['status'])
|
||||||
|
por_data[d][:failed] += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
datas = por_data.keys.sort
|
||||||
|
{
|
||||||
|
labels: datas.map { |d| d.strftime('%d/%m/%Y') },
|
||||||
|
completed: datas.map { |d| por_data[d][:completed] },
|
||||||
|
failed: datas.map { |d| por_data[d][:failed] }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pontos [lat, lng] das entregas ATENDIDAS (sucesso + falhas) para o heatmap.
|
||||||
|
# Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
|
||||||
|
def pontos_mapa
|
||||||
|
@pontos_mapa ||= linhas.filter_map do |r|
|
||||||
|
next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
|
||||||
|
lat = num(r['checkout_latitude']) || num(r['latitude'])
|
||||||
|
lng = num(r['checkout_longitude']) || num(r['longitude'])
|
||||||
|
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||||
|
[lat, lng]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def centro_mapa
|
||||||
|
pontos_mapa.first || SP_CENTRO
|
||||||
|
end
|
||||||
|
|
||||||
|
# Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares).
|
||||||
|
MAX_PONTOS = 2000
|
||||||
|
|
||||||
|
# Pontos detalhados (modo "Pontos" do mapa) — um por entrega ATENDIDA
|
||||||
|
# (sucesso OU insucesso: óbito e demais pilares de falha), com coordenada de
|
||||||
|
# CHECKOUT (ou planejada como fallback). Inclui dados para o popup (NF,
|
||||||
|
# destinatário, endereço, unidade, motorista, veículo, data, status, motivo).
|
||||||
|
def pontos_detalhados
|
||||||
|
@pontos_detalhados ||= linhas.filter_map do |r|
|
||||||
|
next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
|
||||||
|
lat = num(r['checkout_latitude']) || num(r['latitude'])
|
||||||
|
lng = num(r['checkout_longitude']) || num(r['longitude'])
|
||||||
|
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||||
|
{
|
||||||
|
lat: lat, lng: lng,
|
||||||
|
nf: r['reference_id'],
|
||||||
|
nome: r['nome_completo'].presence || r['contact_name'].presence || '—',
|
||||||
|
endereco: r['endereco_completo'].presence || r['address'].presence,
|
||||||
|
unidade: r['contact_name'],
|
||||||
|
motorista: r['driver'],
|
||||||
|
veiculo: r['vehicle'],
|
||||||
|
checkout: data_de(r['checkout'])&.strftime('%d/%m/%Y'),
|
||||||
|
sucesso: r['status'] == 'completed',
|
||||||
|
motivo: r['observation'].presence,
|
||||||
|
foto: foto_url(r['foto_da_fachada'])
|
||||||
|
}
|
||||||
|
end.first(MAX_PONTOS)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def datas_checkout
|
||||||
|
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def contagem(rows, coluna)
|
||||||
|
rows.group_by { |r| r[coluna].presence || '—' }
|
||||||
|
.map { |nome, grupo| { nome: nome, total: grupo.size } }
|
||||||
|
.sort_by { |h| -h[:total] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def pct(parte, total)
|
||||||
|
return 0.0 if total.to_i.zero?
|
||||||
|
(parte.to_f / total * 100).round(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
def num(valor)
|
||||||
|
return nil if valor.nil? || valor.to_s.strip.empty?
|
||||||
|
Float(valor)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def data_de(valor)
|
||||||
|
return nil if valor.nil?
|
||||||
|
return valor.to_date if valor.respond_to?(:to_date)
|
||||||
|
Date.parse(valor.to_s)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Só aceita URL http(s) — evita injetar lixo no <img src> do popup.
|
||||||
|
def foto_url(valor)
|
||||||
|
url = valor.to_s.strip
|
||||||
|
url.match?(%r{\Ahttps?://}i) ? url : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def carregar
|
||||||
|
return [] if @tabelas.empty?
|
||||||
|
|
||||||
|
conn = ActiveRecord::Base.connection
|
||||||
|
|
||||||
|
unions = @tabelas.map do |tabela|
|
||||||
|
gade = conn.quote_table_name(tabela)
|
||||||
|
label = conn.quote(Operacao.label(tabela))
|
||||||
|
<<~SQL.strip
|
||||||
|
SELECT #{label} AS operacao,
|
||||||
|
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
||||||
|
r.checkout, r.planned_date, r.foto_da_fachada,
|
||||||
|
r.latitude, r.longitude,
|
||||||
|
r.checkout_latitude, r.checkout_longitude,
|
||||||
|
#{selects_gade(conn, tabela)}
|
||||||
|
FROM ultimo r
|
||||||
|
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||||
|
WHERE r.rn = 1#{filtro_periodo(conn)}
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
||||||
|
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
||||||
|
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
|
||||||
|
)
|
||||||
|
#{unions.join("\nUNION ALL\n")}
|
||||||
|
SQL
|
||||||
|
|
||||||
|
conn.select_all(sql).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||||
|
# ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
|
||||||
|
# o UNION entre operações continua consistente. (A única coluna garantida em
|
||||||
|
# toda tabela gade é nota_fiscal.) As chaves são uma whitelist fixa (não entram
|
||||||
|
# dados do usuário no SQL).
|
||||||
|
GADE_OPCIONAIS = { 'status' => 'status_gade', 'nome_completo' => 'nome_completo',
|
||||||
|
'endereco_completo' => 'endereco_completo' }.freeze
|
||||||
|
|
||||||
|
def selects_gade(conn, tabela)
|
||||||
|
colunas = conn.columns(tabela).map(&:name)
|
||||||
|
GADE_OPCIONAIS.map do |origem, apelido|
|
||||||
|
colunas.include?(origem) ? "g.#{origem} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
|
||||||
|
end.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
|
||||||
|
# Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
|
||||||
|
# data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
|
||||||
|
def filtro_periodo(conn)
|
||||||
|
return '' unless @inicio && @fim
|
||||||
|
ini = conn.quote(@inicio)
|
||||||
|
fim_excl = conn.quote(@fim + 1)
|
||||||
|
fim_dia = conn.quote(@fim.end_of_day)
|
||||||
|
" AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
|
||||||
|
" OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,14 +6,14 @@
|
|||||||
data-validacao-massa-url-value="<%= classificar_em_massa_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-validacao-massa-url-value="<%= classificar_em_massa_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
data-validacao-motorista-value="<%= @motorista %>"
|
data-validacao-motorista-value="<%= @motorista %>"
|
||||||
data-validacao-total-value="<%= @total_entregas %>"
|
data-validacao-total-value="<%= @total_entregas %>"
|
||||||
data-validacao-veiculo-value="<%= @veiculo %>"
|
data-validacao-veiculos-value="<%= @veiculos_sel.to_json %>"
|
||||||
data-validacao-preco-extraordinaria-value="<%= @precos[:extraordinaria] %>">
|
data-validacao-preco-extraordinaria-value="<%= @precos[:extraordinaria] %>">
|
||||||
|
|
||||||
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
|
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
|
||||||
<div class="lg:shrink-0">
|
<div class="lg:shrink-0">
|
||||||
|
|
||||||
<%# Header %>
|
<%# Header %>
|
||||||
<div class="mb-6">
|
<div class="mb-3">
|
||||||
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao),
|
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao),
|
||||||
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Barra de progresso (reflete o veículo selecionado, se houver) %>
|
<%# Barra de progresso (reflete o veículo selecionado, se houver) %>
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
||||||
<div class="flex justify-between text-sm mb-2">
|
<div class="flex justify-between text-sm mb-2">
|
||||||
<span class="text-white font-semibold">
|
<span class="text-white font-semibold">
|
||||||
<% if @veiculo.present? %>🚚 <%= @veiculo %> — <% end %>
|
<% if @veiculos_sel.any? %>🚚 <%= @veiculos_sel.join(', ') %> — <% end %>
|
||||||
<span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>
|
<span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>
|
||||||
de <span data-validacao-target="contadorTotal"><%= @total_entregas %></span> entregas classificadas
|
de <span data-validacao-target="contadorTotal"><%= @total_entregas %></span> entregas classificadas
|
||||||
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400 font-normal">·
|
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400 font-normal">·
|
||||||
@@ -46,41 +46,95 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# Fechar por veículo — filtra a tela por carro e mostra quais já estão fechados.
|
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
||||||
|
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
|
||||||
Só aparece quando o motorista usou mais de um veículo no período. %>
|
Só aparece quando o motorista usou mais de um veículo no período. %>
|
||||||
|
<% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %>
|
||||||
<% if @veiculos_status.size > 1 %>
|
<% if @veiculos_status.size > 1 %>
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span class="text-gray-400 text-sm font-semibold">🚗 Fechar por veículo:</span>
|
<span class="text-gray-400 text-sm font-semibold">🚗 Fechar por veículo:</span>
|
||||||
<span class="text-gray-600 text-xs">clique para filtrar e classificar um carro de cada vez</span>
|
<span class="text-gray-600 text-xs">clique no nome para filtrar; consolide o carro quando estiver 100% (🔒)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<%# Chip "Todos" %>
|
<%# Chip "Todos" — limpa a seleção (mostra todos os carros) %>
|
||||||
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
||||||
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center
|
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center
|
||||||
#{@veiculo.blank? ? 'border-orange-500 text-orange-400 bg-orange-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500'}" do %>
|
#{@veiculos_sel.empty? ? 'border-orange-500 text-orange-400 bg-orange-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500'}" do %>
|
||||||
Todos
|
Todos
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% @veiculos_status.each do |v| %>
|
<% @veiculos_status.each do |v| %>
|
||||||
<% selecionado = (@veiculo == v[:vehicle]) %>
|
<% selecionado = @veiculos_sel.include?(v[:vehicle]) %>
|
||||||
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: v[:vehicle]),
|
<%# Alterna este veículo dentro da seleção atual (multi-seleção) %>
|
||||||
|
<% nova_sel = selecionado ? (@veiculos_sel - [v[:vehicle]]) : (@veiculos_sel + [v[:vehicle]]) %>
|
||||||
|
<%# Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
|
||||||
|
pronto(verde) > arquivado(cinza tracejado) > neutro. %>
|
||||||
|
<% cor = if selecionado then 'border-orange-500 text-orange-400 bg-orange-500/10'
|
||||||
|
elsif v[:fechado] then 'border-green-500 text-green-400 bg-green-500/10'
|
||||||
|
elsif v[:completo] then 'border-green-600/50 text-green-400'
|
||||||
|
elsif v[:arquivado] then 'border-dashed border-[#3a3a3a] text-gray-500'
|
||||||
|
else 'border-[#2a2a2a] text-gray-300 hover:border-orange-500' end %>
|
||||||
|
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: nova_sel.presence),
|
||||||
data: { 'validacao-target': 'chipVeiculo', vehicle: v[:vehicle], eligible: v[:eligible] },
|
data: { 'validacao-target': 'chipVeiculo', vehicle: v[:vehicle], eligible: v[:eligible] },
|
||||||
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors
|
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors #{cor}" do %>
|
||||||
#{v[:fechado] ? 'border-green-500 text-green-400 bg-green-500/10' : (selecionado ? 'border-orange-500 text-orange-400 bg-orange-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500')}" do %>
|
<span data-chip-lock class="<%= 'hidden' unless v[:fechado] %>">🔒</span>
|
||||||
<span><%= v[:vehicle] %></span>
|
<span><%= v[:vehicle] %></span>
|
||||||
<span class="text-xs <%= v[:fechado] ? 'text-green-400' : 'text-gray-500' %>">
|
<span class="text-xs <%= v[:completo] ? 'text-green-400' : 'text-gray-500' %>">
|
||||||
<span data-chip-feito><%= v[:classificadas] %></span>/<span data-chip-total><%= v[:eligible] %></span>
|
<span data-chip-feito><%= v[:classificadas] %></span>/<span data-chip-total><%= v[:eligible] %></span>
|
||||||
<span data-chip-check><%= '✓' if v[:fechado] %></span>
|
<span data-chip-check><%= '✓' if v[:completo] %></span>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# Ação de consolidação — opera sobre o veículo único selecionado. %>
|
||||||
|
<% if pode_fechar_veiculo %>
|
||||||
|
<% sel_unico = @veiculos_sel.size == 1 ? @veiculos_sel.first : nil %>
|
||||||
|
<% status_unico = sel_unico && @veiculos_status.find { |v| v[:vehicle] == sel_unico } %>
|
||||||
|
<div class="mt-3 pt-3 border-t border-[#2a2a2a]">
|
||||||
|
<% if status_unico.nil? %>
|
||||||
|
<p class="text-gray-500 text-xs">Selecione <strong>um</strong> veículo (clique no nome) para consolidá-lo separadamente.</p>
|
||||||
|
<% elsif status_unico[:fechado] %>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-green-400 text-sm font-semibold">🔒 <%= sel_unico %> consolidado.</span>
|
||||||
|
<%= button_to '🔓 Reabrir veículo',
|
||||||
|
reabrir_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||||
|
data: { turbo_confirm: "Reabrir o veículo #{sel_unico} para edição?" },
|
||||||
|
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<%# Habilita só quando o carro está 100% classificado; o JS liga/desliga
|
||||||
|
conforme a barra do escopo selecionado chega (ou não) a 100%. %>
|
||||||
|
<%= button_to '🔒 Consolidar este veículo',
|
||||||
|
fechar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||||
|
disabled: !status_unico[:completo],
|
||||||
|
data: { 'validacao-target': 'consolidarBtn',
|
||||||
|
turbo_confirm: "Consolidar o veículo #{sel_unico}? Você poderá reabrir depois." },
|
||||||
|
class: "font-bold px-4 py-2 rounded-lg text-xs min-h-[40px] #{status_unico[:completo] ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
|
||||||
|
<%= button_to '🗄️ Arquivar veículo',
|
||||||
|
arquivar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||||
|
data: { turbo_confirm: "Arquivar o veículo #{sel_unico}? Ele sai da finalização (as entregas já classificadas continuam no pagamento)." },
|
||||||
|
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||||
|
<span class="text-gray-500 text-xs" data-validacao-target="consolidarHint">
|
||||||
|
<%= status_unico[:completo] ? 'Pronto para consolidar.' : 'Classifique 100% deste veículo para consolidar.' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Ações em massa + filtro %>
|
<%# Ações em massa + filtro %>
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4 flex flex-wrap items-center gap-3">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3 flex flex-wrap items-center gap-3">
|
||||||
<label class="flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none">
|
<label class="flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none">
|
||||||
<input type="checkbox" data-validacao-target="selecionarTodos"
|
<input type="checkbox" data-validacao-target="selecionarTodos"
|
||||||
data-action="change->validacao#selecionarTodos"
|
data-action="change->validacao#selecionarTodos"
|
||||||
@@ -102,9 +156,14 @@
|
|||||||
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
|
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
|
||||||
|
<button type="button" data-action="validacao#toggleResumo"
|
||||||
|
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg whitespace-nowrap">
|
||||||
|
📊 <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
|
||||||
|
</button>
|
||||||
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
||||||
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculo,
|
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
|
||||||
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
|
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
|
||||||
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
|
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +173,7 @@
|
|||||||
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
|
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
|
||||||
controllers existentes são reaproveitados; só o gatilho mudou. %>
|
controllers existentes são reaproveitados; só o gatilho mudou. %>
|
||||||
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
||||||
<div class="relative mb-4"
|
<div class="relative mb-3"
|
||||||
data-controller="ferramentas apontamento-manual apontamento-termo"
|
data-controller="ferramentas apontamento-manual apontamento-termo"
|
||||||
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
@@ -165,6 +224,16 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</datalist>
|
</datalist>
|
||||||
|
|
||||||
|
<%# Veículo (Q3) — a qual carro este lançamento pertence (soma no balde dele) %>
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
|
||||||
|
<select data-apontamento-manual-target="veiculo"
|
||||||
|
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none mb-3">
|
||||||
|
<option value="">— sem veículo —</option>
|
||||||
|
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
|
||||||
|
<option value="<%= veic %>"><%= veic %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
|
||||||
<%# NF + observação (texto livre) %>
|
<%# NF + observação (texto livre) %>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -235,6 +304,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%# Veículo (Q3) — a qual carro estes termos pertencem %>
|
||||||
|
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
|
||||||
|
<select data-apontamento-termo-target="veiculo"
|
||||||
|
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-blue-500 focus:outline-none mb-4">
|
||||||
|
<option value="">— sem veículo —</option>
|
||||||
|
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
|
||||||
|
<option value="<%= veic %>"><%= veic %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
|
||||||
<%# Stepper de quantidade %>
|
<%# Stepper de quantidade %>
|
||||||
<label class="block text-gray-400 text-sm mb-2">Quantidade de termos</label>
|
<label class="block text-gray-400 text-sm mb-2">Quantidade de termos</label>
|
||||||
<div class="flex items-center justify-center gap-3 mb-5">
|
<div class="flex items-center justify-center gap-3 mb-5">
|
||||||
@@ -257,16 +336,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
|
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4 lg:flex-1 lg:min-h-0">
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3 lg:flex-1 lg:min-h-0">
|
||||||
|
|
||||||
<%# ── Lista de entregas (3 colunas) — área rolável ──── %>
|
<%# ── Lista de entregas (3 colunas) — área rolável ──── %>
|
||||||
<div class="lg:col-span-3 space-y-2 lg:overflow-y-auto lg:pr-1">
|
<div class="lg:col-span-3 space-y-2 lg:overflow-y-auto lg:pr-1" data-validacao-target="listaCol">
|
||||||
|
<%# 🔍 Busca na listagem — filtra os cards por NF, local, endereço ou veículo %>
|
||||||
|
<div class="sticky top-0 z-20 bg-[#0a0a0a] pb-2">
|
||||||
|
<div class="relative">
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">🔍</span>
|
||||||
|
<input type="search" data-validacao-target="busca" data-action="input->validacao#filtrarBusca"
|
||||||
|
placeholder="Buscar NF, local, endereço ou veículo…" autocomplete="off"
|
||||||
|
class="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg pl-10 pr-3 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-orange-500 focus:outline-none">
|
||||||
|
</div>
|
||||||
|
<p data-validacao-target="buscaVazia" class="hidden text-gray-500 text-xs mt-2 px-1">Nenhuma nota encontrada para a busca.</p>
|
||||||
|
</div>
|
||||||
<% if @entregas.any? %>
|
<% if @entregas.any? %>
|
||||||
<% @entregas.each do |e| %>
|
<% @entregas.each do |e| %>
|
||||||
<% tipos_ativos = (@classificacoes[e.tracking_id] || []).map(&:tipo) %>
|
<% tipos_ativos = (@classificacoes[e.tracking_id] || []).map(&:tipo) %>
|
||||||
<div class="bg-[#1a1a1a] border <%= e.falhada? ? 'border-red-500/50' : 'border-[#2a2a2a]' %> rounded-xl p-4 flex flex-col lg:flex-row lg:items-center gap-3"
|
<div class="bg-[#1a1a1a] border <%= e.falhada? ? 'border-red-500/50' : 'border-[#2a2a2a]' %> rounded-xl p-4 flex flex-col lg:flex-row lg:items-center gap-3"
|
||||||
data-validacao-target="linha" data-tracking="<%= e.tracking_id %>"
|
data-validacao-target="linha" data-tracking="<%= e.tracking_id %>"
|
||||||
data-vehicle="<%= e.vehicle.presence || '(sem veículo)' %>"
|
data-vehicle="<%= e.vehicle.presence || '(sem veículo)' %>"
|
||||||
|
data-busca="<%= [e.numero_nf, e.local, e.address, e.vehicle].compact.join(' ').downcase %>"
|
||||||
data-classificada="<%= tipos_ativos.any? ? '1' : '0' %>">
|
data-classificada="<%= tipos_ativos.any? ? '1' : '0' %>">
|
||||||
|
|
||||||
<%# Checkbox seleção em massa %>
|
<%# Checkbox seleção em massa %>
|
||||||
@@ -318,7 +408,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%# ── Resumo lateral (1 coluna) — rola junto se precisar ─────── %>
|
<%# ── Resumo lateral (1 coluna) — rola junto se precisar ─────── %>
|
||||||
<div class="lg:col-span-1 lg:overflow-y-auto">
|
<div class="lg:col-span-1 lg:overflow-y-auto" data-validacao-target="resumo">
|
||||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 space-y-4">
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 space-y-4">
|
||||||
<h3 class="text-white font-bold">📊 Resumo</h3>
|
<h3 class="text-white font-bold">📊 Resumo</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,18 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-orange-500 font-black text-xl"><%= moeda(cm.valor_total) %></p>
|
<p class="text-orange-500 font-black text-xl"><%= moeda(cm.valor_total) %></p>
|
||||||
|
<%# Breakdown por veículo (consolidados 🔒 / abertos). Informativo — o
|
||||||
|
pagamento continua sendo do motorista inteiro. %>
|
||||||
|
<% veiculos = @veiculos_por_motorista[cm.motorista_nome] %>
|
||||||
|
<% if veiculos.present? %>
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<% veiculos.each do |cv| %>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded border <%= cv.fechado? ? 'border-green-500/50 text-green-400' : 'border-[#2a2a2a] text-gray-400' %>">
|
||||||
|
<%= cv.fechado? ? '🔒' : '○' %> <%= cv.vehicle %> · <%= moeda(cv.valor_total) %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% if cm.pago? %>
|
<% if cm.pago? %>
|
||||||
<p class="text-green-400 text-xs mt-1">
|
<p class="text-green-400 text-xs mt-1">
|
||||||
💰 Pago em <%= l cm.pago_em, format: :short %>
|
💰 Pago em <%= l cm.pago_em, format: :short %>
|
||||||
|
|||||||
@@ -187,6 +187,16 @@
|
|||||||
· <span class="text-orange-400">➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
|
· <span class="text-orange-400">➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
<% if m[:veiculos_total] > 0 %>
|
||||||
|
<p class="text-xs mt-0.5">
|
||||||
|
<span class="<%= m[:veiculos_fechados] == m[:veiculos_total] ? 'text-green-400' : 'text-gray-400' %>">
|
||||||
|
🚗 <%= m[:veiculos_fechados] %>/<%= m[:veiculos_total] %> veículo<%= 's' if m[:veiculos_total] > 1 %> consolidado<%= 's' if m[:veiculos_fechados] != 1 %>
|
||||||
|
</span>
|
||||||
|
<% if m[:veiculos_pendentes].any? %>
|
||||||
|
<span class="text-orange-400">· pendente<%= 's' if m[:veiculos_pendentes].size > 1 %>: <%= m[:veiculos_pendentes].join(', ') %></span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right <%= 'pr-10' if pode_arquivar_motorista %>">
|
<div class="text-right <%= 'pr-10' if pode_arquivar_motorista %>">
|
||||||
@@ -248,23 +258,39 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Finalizar — só habilita se tudo classificado %>
|
<%# Finalizar — habilita quando tudo classificado. Se sobram veículos pendentes
|
||||||
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
|
mas já há veículos consolidados, oferece "arquivar pendentes e finalizar". %>
|
||||||
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 flex items-center justify-between">
|
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
|
||||||
<div>
|
<% tem_consolidados = @motoristas.any? { |m| m[:veiculos_fechados] > 0 || m[:veiculos_prontos] > 0 } %>
|
||||||
<p class="text-white font-bold">Total da consolidação</p>
|
<% tem_pendentes = @motoristas.any? { |m| m[:veiculos_pendentes].any? } %>
|
||||||
<p class="text-orange-500 font-black text-3xl"><%= moeda(@consolidacao.valor_total) %></p>
|
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||||
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold">Total da consolidação</p>
|
||||||
|
<p class="text-orange-500 font-black text-3xl"><%= moeda(@consolidacao.valor_total) %></p>
|
||||||
|
</div>
|
||||||
|
<% if @consolidacao.rascunho? %>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<% if !tudo_completo && tem_consolidados && tem_pendentes %>
|
||||||
|
<%= button_to '🔒 Arquivar pendentes e finalizar',
|
||||||
|
finalizar_parcial_consolidacao_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
data: { turbo_confirm: 'Arquivar TODOS os veículos ainda não classificados 100% e finalizar? Eles ficam de fora desta consolidação (você resolve depois). Os veículos consolidados são pagos normalmente.' },
|
||||||
|
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %>
|
||||||
|
<% end %>
|
||||||
|
<%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
disabled: !tudo_completo,
|
||||||
|
data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' },
|
||||||
|
class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors
|
||||||
|
#{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% if @consolidacao.rascunho? %>
|
|
||||||
<%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao),
|
|
||||||
method: :post,
|
|
||||||
disabled: !tudo_completo,
|
|
||||||
data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' },
|
|
||||||
class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors
|
|
||||||
#{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<% unless tudo_completo %>
|
<% unless tudo_completo %>
|
||||||
<p class="text-gray-500 text-sm mt-2 text-center">⚠️ Classifique todas as entregas de todos os motoristas para poder finalizar.</p>
|
<p class="text-gray-500 text-sm mt-2 text-center">
|
||||||
|
⚠️ Classifique todas as entregas de todos os motoristas para finalizar tudo<% if tem_consolidados && tem_pendentes %> — ou use <strong>“Arquivar pendentes e finalizar”</strong> para fechar só os veículos já consolidados.<% end %>
|
||||||
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
|
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
|
||||||
<% unless current_user.motorista? %>
|
<% unless current_user.motorista? %>
|
||||||
<%= nav_link_to '📊 Dashboard', dashboard_path %>
|
<%= nav_link_to '📊 Dashboard', dashboard_path %>
|
||||||
|
<%= nav_link_to '📈 Operações', operacoes_dashboard_path %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
|
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
|
||||||
@@ -70,6 +71,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<%# Botão flutuante (desktop) — esconde/mostra a sidebar do site inteiro.
|
||||||
|
Em repouso mostra uma ALÇA fina laranja (indicador de que dá p/ recolher);
|
||||||
|
ao chegar perto com o mouse, ela expande no botão com a seta. %>
|
||||||
|
<div id="sidebar-edge" class="hidden md:flex group fixed top-1/2 -translate-y-1/2 z-40 h-32 w-12 items-center">
|
||||||
|
<button id="sidebar-toggle-btn" type="button" onclick="toggleSidebarCollapse()"
|
||||||
|
class="flex items-center justify-center rounded-r-lg shadow-lg transition-all duration-200
|
||||||
|
w-1.5 h-14 bg-orange-500/60 text-transparent
|
||||||
|
group-hover:w-7 group-hover:h-16 group-hover:bg-[#1a1a1a] group-hover:text-orange-500
|
||||||
|
group-hover:border group-hover:border-orange-500/70
|
||||||
|
focus:w-7 focus:h-16 focus:bg-[#1a1a1a] focus:text-orange-500"
|
||||||
|
title="Esconder/mostrar menu" aria-label="Esconder ou mostrar o menu">
|
||||||
|
<span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%# Topbar mobile %>
|
<%# Topbar mobile %>
|
||||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||||
<button onclick="toggleSidebar()"
|
<button onclick="toggleSidebar()"
|
||||||
@@ -85,10 +101,24 @@
|
|||||||
<div class="h-14 md:hidden"></div>
|
<div class="h-14 md:hidden"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Mobile: abre/fecha a sidebar por cima do conteúdo (hamburguer).
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
const s = document.getElementById('sidebar');
|
const s = document.getElementById('sidebar');
|
||||||
const o = document.getElementById('sidebar-overlay');
|
const o = document.getElementById('sidebar-overlay');
|
||||||
s.classList.toggle('-translate-x-full');
|
s.classList.toggle('-translate-x-full');
|
||||||
o.classList.toggle('hidden');
|
o.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Desktop: recolhe/expande a sidebar (estado salvo p/ o site inteiro).
|
||||||
|
function toggleSidebarCollapse() {
|
||||||
|
const collapsed = document.documentElement.classList.toggle('sidebar-collapsed');
|
||||||
|
try { localStorage.setItem('sidebarCollapsed', collapsed ? '1' : '0'); } catch (e) {}
|
||||||
|
syncSidebarToggleIcon();
|
||||||
|
}
|
||||||
|
function syncSidebarToggleIcon() {
|
||||||
|
const icon = document.getElementById('sidebar-toggle-icon');
|
||||||
|
if (icon) icon.textContent =
|
||||||
|
document.documentElement.classList.contains('sidebar-collapsed') ? '▶' : '◀';
|
||||||
|
}
|
||||||
|
syncSidebarToggleIcon();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -55,10 +55,29 @@
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
/* Sidebar recolhível no desktop (botão flutuante esconde/mostra o menu).
|
||||||
|
O estado fica na classe .sidebar-collapsed do <html>. */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
main, footer { transition: margin-left .3s ease; }
|
||||||
|
#sidebar-edge { left: 16rem; transition: left .3s ease; }
|
||||||
|
.sidebar-collapsed #sidebar { transform: translateX(-100%); }
|
||||||
|
.sidebar-collapsed main, .sidebar-collapsed footer { margin-left: 0; }
|
||||||
|
.sidebar-collapsed #sidebar-edge { left: 0; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<%= javascript_importmap_tags %>
|
<%= javascript_importmap_tags %>
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
|
|
||||||
|
<%# Aplica o estado recolhido da sidebar antes de pintar (evita "piscar") %>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('sidebarCollapsed') === '1')
|
||||||
|
document.documentElement.classList.add('sidebar-collapsed');
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||||
|
|||||||
56
app/views/operacoes_dashboard/_comparativo.html.erb
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<%# locals: a, b (Analytics::OperacaoMetricas), label_a, label_b %>
|
||||||
|
<%
|
||||||
|
linhas_cmp = [
|
||||||
|
{ nome: 'Total de Entregas', a: a.total, b: b.total, maior_melhor: true, suf: '' },
|
||||||
|
{ nome: 'Sucesso', a: a.sucesso, b: b.sucesso, maior_melhor: true, suf: '' },
|
||||||
|
{ nome: 'Recusas', a: a.recusas, b: b.recusas, maior_melhor: false, suf: '' },
|
||||||
|
{ nome: 'Pendentes', a: a.pendentes, b: b.pendentes, maior_melhor: false, suf: '' },
|
||||||
|
{ nome: 'Taxa de Sucesso', a: a.taxa_sucesso, b: b.taxa_sucesso, maior_melhor: true, suf: '%' },
|
||||||
|
{ nome: 'Taxa de Insucesso', a: a.taxa_insucesso, b: b.taxa_insucesso, maior_melhor: false, suf: '%' }
|
||||||
|
]
|
||||||
|
%>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 space-y-6">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<h2 class="text-lg font-bold text-white">Comparativo</h2>
|
||||||
|
<div class="flex flex-wrap items-center gap-4 text-xs">
|
||||||
|
<span class="text-orange-400">● A · <%= label_a %> <span class="text-gray-500">(<%= a.total %>)</span></span>
|
||||||
|
<span class="text-blue-400">● B · <%= label_b %> <span class="text-gray-500">(<%= b.total %>)</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Tabela comparativa (verde = melhor desempenho na linha) %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||||
|
<th class="pb-2 font-medium pr-3">Métrica</th>
|
||||||
|
<th class="pb-2 font-medium pl-4 text-right text-orange-400">A</th>
|
||||||
|
<th class="pb-2 font-medium pl-4 text-right text-blue-400">B</th>
|
||||||
|
<th class="pb-2 font-medium pl-4 text-right">Δ (A−B)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% linhas_cmp.each do |l| %>
|
||||||
|
<% delta = (l[:a] - l[:b]).round(2) %>
|
||||||
|
<% melhor_a = l[:maior_melhor] ? l[:a] > l[:b] : l[:a] < l[:b] %>
|
||||||
|
<% melhor_b = l[:maior_melhor] ? l[:b] > l[:a] : l[:b] < l[:a] %>
|
||||||
|
<tr class="border-b border-white/5">
|
||||||
|
<td class="py-2 pr-3 text-gray-300"><%= l[:nome] %></td>
|
||||||
|
<td class="py-2 pl-4 text-right whitespace-nowrap font-semibold <%= melhor_a ? 'text-green-400' : 'text-white' %>"><%= l[:a] %><%= l[:suf] %></td>
|
||||||
|
<td class="py-2 pl-4 text-right whitespace-nowrap font-semibold <%= melhor_b ? 'text-green-400' : 'text-white' %>"><%= l[:b] %><%= l[:suf] %></td>
|
||||||
|
<td class="py-2 pl-4 text-right whitespace-nowrap text-gray-400"><%= delta > 0 ? "+#{delta}" : delta %><%= l[:suf] %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Barras agrupadas A x B %>
|
||||||
|
<div class="relative h-72">
|
||||||
|
<canvas class="cmp-bars"
|
||||||
|
data-label-a="<%= label_a %>"
|
||||||
|
data-label-b="<%= label_b %>"
|
||||||
|
data-a="<%= [a.total, a.sucesso, a.recusas, a.pendentes].to_json %>"
|
||||||
|
data-b="<%= [b.total, b.sucesso, b.recusas, b.pendentes].to_json %>"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
36
app/views/operacoes_dashboard/_mapa.html.erb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<%# locals: metricas (Analytics::OperacaoMetricas) %>
|
||||||
|
<% heat = metricas.pontos_mapa %>
|
||||||
|
<% pontos = metricas.pontos_detalhados %>
|
||||||
|
<div class="mapa-box bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<p class="text-gray-400 text-sm font-medium">Mapa de Entregas</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="search" class="mapa-busca w-56 max-w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:outline-none focus:border-orange-500"
|
||||||
|
placeholder="Buscar NF, paciente ou veículo…" autocomplete="off">
|
||||||
|
<span class="mapa-busca-info text-xs text-gray-500"></span>
|
||||||
|
</div>
|
||||||
|
<div class="map-toggle inline-flex rounded-lg overflow-hidden border border-white/10 text-xs">
|
||||||
|
<button type="button" data-view="heat"
|
||||||
|
class="px-3 py-1.5 bg-orange-500 text-black font-semibold">🔥 Calor</button>
|
||||||
|
<button type="button" data-view="pontos"
|
||||||
|
class="px-3 py-1.5 text-gray-300 hover:text-white">📍 Pontos (<%= pontos.size %>)</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="map-fullscreen px-3 py-1.5 rounded-lg border border-white/10 text-gray-300 hover:text-white text-xs" title="Ver em tela cheia">
|
||||||
|
⛶ Tela cheia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if heat.empty? && pontos.empty? %>
|
||||||
|
<p class="text-center py-8 text-gray-500 text-sm">Sem coordenadas para exibir no período.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="mapa-calor w-full h-[70vh] min-h-[480px] rounded-xl overflow-hidden bg-[#1a1a1a]"
|
||||||
|
data-heat="<%= heat.to_json %>"
|
||||||
|
data-pontos="<%= pontos.to_json %>"
|
||||||
|
data-centro="<%= metricas.centro_mapa.to_json %>"></div>
|
||||||
|
<p class="text-xs text-gray-600 mt-2">
|
||||||
|
🔥 Calor mostra a densidade · 📍 Pontos mostra cada entrega (clique no marcador para detalhes; coordenada de check-out).
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
131
app/views/operacoes_dashboard/_painel.html.erb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<%# locals: metricas (Analytics::OperacaoMetricas), titulo (opcional),
|
||||||
|
filtravel (opcional, default false) — torna as tabelas clicáveis p/ cross-filter %>
|
||||||
|
<% titulo = local_assigns[:titulo] %>
|
||||||
|
<% filtravel = local_assigns.fetch(:filtravel, false) %>
|
||||||
|
<% ins = metricas.insucessos_pct %>
|
||||||
|
<% pd = metricas.por_dia %>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% if titulo.present? %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-white"><%= titulo %></h2>
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
<%= metricas.total %> entregas<% if metricas.data_inicio %> · <%= metricas.data_inicio.strftime('%d/%m/%Y') %>–<%= metricas.data_fim.strftime('%d/%m/%Y') %><% end %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# ── KPIs ─────────────────────────────────────────────── %>
|
||||||
|
<% pct_total = ->(parte) { metricas.total.zero? ? 0 : (parte * 100.0 / metricas.total).round(1) } %>
|
||||||
|
<div class="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
||||||
|
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 transition-all duration-200 hover:border-white/20 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-2">📦 Total de Entregas</p>
|
||||||
|
<p class="text-4xl font-black text-white inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.total %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
||||||
|
✅ <%= metricas.sucesso %> · <span class="text-blue-500">●</span> <%= metricas.recusas %> · ⏳ <%= metricas.pendentes %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 transition-all duration-200 hover:border-green-500/40 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-2">✅ Total de Sucesso</p>
|
||||||
|
<p class="text-4xl font-black text-green-400 inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.sucesso %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
||||||
|
<%= pct_total.(metricas.sucesso) %>% do total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 transition-all duration-200 hover:border-blue-500/40 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-2">❌ Total de Recusas</p>
|
||||||
|
<p class="text-4xl font-black text-blue-500 inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.recusas %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
||||||
|
<%= pct_total.(metricas.recusas) %>% do total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="group bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 transition-all duration-200 hover:border-yellow-500/40 hover:bg-[#202020] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/40">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-2">⏳ Entregas Pendentes</p>
|
||||||
|
<p class="text-4xl font-black text-yellow-400 inline-block origin-left transition-transform duration-200 group-hover:scale-110"><%= metricas.pendentes %></p>
|
||||||
|
<p class="text-xs text-gray-500 mt-2 h-0 overflow-hidden opacity-0 transition-all duration-200 group-hover:h-4 group-hover:opacity-100">
|
||||||
|
<%= pct_total.(metricas.pendentes) %>% do total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Insucessos % + Índices de falha + Status ─────────── %>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Insucessos %</p>
|
||||||
|
<% if metricas.total.zero? %>
|
||||||
|
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="relative h-56">
|
||||||
|
<canvas class="dash-donut"
|
||||||
|
data-completed="<%= ins[:completed] %>"
|
||||||
|
data-failed="<%= ins[:failed] %>"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center gap-4 mt-3 text-xs">
|
||||||
|
<span class="text-orange-400">● Completas <%= ins[:pct_completed] %>%</span>
|
||||||
|
<span class="text-blue-500">● Falhas <%= ins[:pct_failed] %>%</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Índices de Falha</p>
|
||||||
|
<%= render 'tabela_simples',
|
||||||
|
cabecalhos: ['Observação', 'Total', '%'],
|
||||||
|
linhas: metricas.indices_falha.map { |h| [h[:observation], h[:total], "#{h[:pct]}%"] },
|
||||||
|
vazio: 'Nenhuma falha no período.',
|
||||||
|
filtro: (filtravel ? 'f_obs' : nil) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Por Status</p>
|
||||||
|
<%= render 'tabela_simples',
|
||||||
|
cabecalhos: ['Status', 'Quantidade'],
|
||||||
|
linhas: metricas.por_status_gade.map { |h| [h[:status], h[:total]] },
|
||||||
|
vazio: 'Sem dados.',
|
||||||
|
total: metricas.total,
|
||||||
|
filtro: (filtravel ? 'f_status' : nil) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Motoristas + STS ─────────────────────────────────── %>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Notas fiscais por Motorista</p>
|
||||||
|
<div class="max-h-80 overflow-y-auto pr-2">
|
||||||
|
<%= render 'tabela_simples',
|
||||||
|
cabecalhos: ['Motorista', 'Notas fiscais'],
|
||||||
|
linhas: metricas.por_motorista.map { |h| [h[:nome], h[:total]] },
|
||||||
|
vazio: 'Sem dados.',
|
||||||
|
total: metricas.total,
|
||||||
|
filtro: (filtravel ? 'f_driver' : nil) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por STS (Unidade)</p>
|
||||||
|
<div class="max-h-80 overflow-y-auto pr-2">
|
||||||
|
<%= render 'tabela_simples',
|
||||||
|
cabecalhos: ['Unidade', 'NFs entregues'],
|
||||||
|
linhas: metricas.por_sts.map { |h| [h[:nome], h[:total]] },
|
||||||
|
vazio: 'Sem dados.',
|
||||||
|
total: metricas.sucesso,
|
||||||
|
filtro: (filtravel ? 'f_sts' : nil) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Entregas por dia ─────────────────────────────────── %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||||
|
<p class="text-gray-400 text-sm font-medium mb-4">Entregas por Dia</p>
|
||||||
|
<% if pd[:labels].empty? %>
|
||||||
|
<p class="text-center py-8 text-gray-500 text-sm">Sem dados no período.</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="relative h-72">
|
||||||
|
<canvas class="dash-bars"
|
||||||
|
data-labels="<%= pd[:labels].to_json %>"
|
||||||
|
data-completed="<%= pd[:completed].to_json %>"
|
||||||
|
data-failed="<%= pd[:failed].to_json %>"></canvas>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
app/views/operacoes_dashboard/_tabela_simples.html.erb
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<%# locals:
|
||||||
|
cabecalhos (Array), linhas (Array de Array), vazio (String),
|
||||||
|
total (opcional) — linha de rodapé "Total"
|
||||||
|
filtro (opcional) — nome do param (ex.: 'f_driver') que torna a 1ª coluna
|
||||||
|
clicável para cross-filter %>
|
||||||
|
<% total_rodape = local_assigns[:total] %>
|
||||||
|
<% filtro = local_assigns[:filtro] %>
|
||||||
|
<% base = request.query_parameters %>
|
||||||
|
<% if linhas.blank? %>
|
||||||
|
<p class="text-center py-6 text-gray-500 text-sm"><%= vazio %></p>
|
||||||
|
<% else %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||||
|
<% cabecalhos.each_with_index do |c, i| %>
|
||||||
|
<th class="pb-2 font-medium <%= i.zero? ? 'pr-3' : 'pl-4 text-right whitespace-nowrap' %>"><%= c %></th>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% linhas.each do |celulas| %>
|
||||||
|
<% ativo = filtro && base[filtro].to_s == celulas.first.to_s %>
|
||||||
|
<tr class="border-b border-white/5 <%= 'bg-orange-500/10' if ativo %>">
|
||||||
|
<% celulas.each_with_index do |cel, i| %>
|
||||||
|
<td class="py-2 align-top <%= i.zero? ? 'pr-3 text-gray-300' : 'pl-4 text-right whitespace-nowrap font-semibold text-white' %>">
|
||||||
|
<% if i.zero? && filtro %>
|
||||||
|
<% destino = ativo ? base.except(filtro) : base.merge(filtro => cel.to_s) %>
|
||||||
|
<%= link_to operacoes_dashboard_path(destino),
|
||||||
|
data: { turbo: false },
|
||||||
|
title: (ativo ? 'Clique para remover o filtro' : "Filtrar por #{cel}"),
|
||||||
|
class: "inline-flex items-center gap-1 hover:text-orange-400 #{'text-orange-400 font-semibold' if ativo}" do %>
|
||||||
|
<%= cel %><% if ativo %> <span aria-hidden="true">✕</span><% end %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%= cel %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
<% if total_rodape %>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="text-white font-bold">
|
||||||
|
<td class="pt-2 pr-3">Total</td>
|
||||||
|
<td class="pt-2 pl-4 text-right whitespace-nowrap" colspan="<%= cabecalhos.size - 1 %>"><%= total_rodape %></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
<% end %>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
566
app/views/operacoes_dashboard/index.html.erb
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
<%# app/views/operacoes_dashboard/index.html.erb %>
|
||||||
|
<%# Dashboard de análise de entregas por operação (KPIs, insucessos, STS, mapa). %>
|
||||||
|
|
||||||
|
<% content_for :head do %>
|
||||||
|
<meta name="turbo-cache-control" content="no-cache">
|
||||||
|
|
||||||
|
<%# Flatpickr (date range) — CSS no <head> %>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
|
||||||
|
<%# Leaflet (mapa de calor) %>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<style>
|
||||||
|
.flatpickr-calendar { background: #1a1a1a; border: 1px solid rgba(255,255,255,.1); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
|
||||||
|
.flatpickr-months, .flatpickr-weekdays, .flatpickr-weekday { background: #1a1a1a; color: #9ca3af; }
|
||||||
|
.flatpickr-current-month, .flatpickr-monthDropdown-months, .numInput { color: #fff; }
|
||||||
|
.flatpickr-day { color: #e5e7eb; }
|
||||||
|
.flatpickr-day:hover { background: #2a2a2a; border-color: #2a2a2a; }
|
||||||
|
.flatpickr-day.today { border-color: #f97316; }
|
||||||
|
.flatpickr-day.selected, .flatpickr-day.startRange, .flatpickr-day.endRange,
|
||||||
|
.flatpickr-day.selected:hover, .flatpickr-day.startRange:hover, .flatpickr-day.endRange:hover {
|
||||||
|
background: #f97316; border-color: #f97316; color: #0a0a0a;
|
||||||
|
}
|
||||||
|
.flatpickr-day.inRange { background: rgba(249,115,22,.18); border-color: rgba(249,115,22,.18); box-shadow: -5px 0 0 rgba(249,115,22,.18), 5px 0 0 rgba(249,115,22,.18); }
|
||||||
|
.flatpickr-months .flatpickr-prev-month svg, .flatpickr-months .flatpickr-next-month svg { fill: #f97316; }
|
||||||
|
/* O mapa do Leaflet usa fundo claro — moldura escura ao redor */
|
||||||
|
.leaflet-container { background: #1a1a1a; }
|
||||||
|
/* Modo escuro = OpenStreetMap completo (detalhe igual ao claro) invertido,
|
||||||
|
para ruas e nomes ficarem bem visíveis sobre fundo escuro. */
|
||||||
|
.tiles-escuro-contraste { filter: invert(1) hue-rotate(180deg) brightness(0.95) contrast(1.05); }
|
||||||
|
</style>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% hoje = Date.current %>
|
||||||
|
<% atalhos = {
|
||||||
|
'Hoje' => [hoje, hoje],
|
||||||
|
'7 dias' => [hoje - 6, hoje],
|
||||||
|
'Este mês' => [hoje.beginning_of_month, hoje],
|
||||||
|
'30 dias' => [hoje - 29, hoje]
|
||||||
|
} %>
|
||||||
|
<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %>
|
||||||
|
<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %>
|
||||||
|
<%# Parâmetros de operação a preservar ao trocar de modo/período %>
|
||||||
|
<% ctx_ops = { operacao: @operacao, op_a: @op_a, op_b: @op_b }.compact %>
|
||||||
|
|
||||||
|
<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">Dashboard de Operações</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 @modo == 'comparar' %>
|
||||||
|
⚖️ Comparação entre operações (cada uma no seu próprio período)
|
||||||
|
<% 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 %>
|
||||||
|
· Atualizado às <%= Time.now.strftime('%H:%M') %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Filtro de período — só na visão Global (a faixa de data não se aplica a
|
||||||
|
operação única, que usa o período natural dos próprios dados). %>
|
||||||
|
<% if @modo == 'global' %>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<div class="relative">
|
||||||
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
|
||||||
|
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
|
||||||
|
value="<%= @periodo_inicio.strftime('%d/%m/%Y') %> até <%= @periodo_fim.strftime('%d/%m/%Y') %>"
|
||||||
|
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
|
||||||
|
text-white text-sm w-full sm:w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
|
<% atalhos.each do |label, (ini, fim)| %>
|
||||||
|
<% ativo = @periodo_inicio == ini && @periodo_fim == fim %>
|
||||||
|
<%= link_to label,
|
||||||
|
operacoes_dashboard_path(modo: 'global', inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d')),
|
||||||
|
data: { turbo: false },
|
||||||
|
class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Abas de modo ──────────────────────────────────────── %>
|
||||||
|
<% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 border-b border-white/5 pb-3">
|
||||||
|
<% modos.each do |m, label| %>
|
||||||
|
<% ativo = @modo == m %>
|
||||||
|
<%= link_to label,
|
||||||
|
operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)),
|
||||||
|
data: { turbo: false },
|
||||||
|
class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @tabelas_validas.blank? %>
|
||||||
|
<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>
|
||||||
|
<% else %>
|
||||||
|
|
||||||
|
<%# ── Filtros ativos (cross-filter por clique nas tabelas) ── %>
|
||||||
|
<% if @chips.any? && %w[operacao global].include?(@modo) %>
|
||||||
|
<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_dashboard_path(request.query_parameters.except(chip[:param])),
|
||||||
|
data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to 'Limpar tudo',
|
||||||
|
operacoes_dashboard_path(request.query_parameters.except(*@chips.map { |c| c[:param] })),
|
||||||
|
data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# ── Seletor de operação por modo ───────────────────── %>
|
||||||
|
<% if @modo == 'operacao' %>
|
||||||
|
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="flex flex-wrap 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 %>">
|
||||||
|
<label class="text-gray-400 text-sm">Operação:</label>
|
||||||
|
<select name="operacao" class="auto-submit 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>
|
||||||
|
|
||||||
|
<% if @metricas %>
|
||||||
|
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||||
|
<%= render 'mapa', metricas: @metricas %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% elsif @modo == 'global' %>
|
||||||
|
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-4 text-sm text-gray-300">
|
||||||
|
🌐 Visão <strong class="text-white">Global</strong> — todas as
|
||||||
|
<%= @tabelas_validas.size %> operações agregadas no período.
|
||||||
|
</div>
|
||||||
|
<% if @metricas %>
|
||||||
|
<%= render 'painel', metricas: @metricas, filtravel: true %>
|
||||||
|
<%= render 'mapa', metricas: @metricas %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% else # comparar %>
|
||||||
|
<form method="get" action="<%= operacoes_dashboard_path %>" data-turbo="false" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<input type="hidden" name="modo" value="comparar">
|
||||||
|
<input type="hidden" name="inicio" value="<%= ini_iso %>">
|
||||||
|
<input type="hidden" name="fim" value="<%= fim_iso %>">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-orange-400 text-sm font-semibold">A:</label>
|
||||||
|
<select name="op_a" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-orange-500">
|
||||||
|
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||||
|
<optgroup label="<%= titulo %>">
|
||||||
|
<% ops.each do |op| %>
|
||||||
|
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_a %>><%= op[:label] %></option>
|
||||||
|
<% end %>
|
||||||
|
</optgroup>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-blue-400 text-sm font-semibold">B:</label>
|
||||||
|
<select name="op_b" class="auto-submit flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm px-3 py-2.5 focus:outline-none focus:border-blue-500">
|
||||||
|
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||||
|
<optgroup label="<%= titulo %>">
|
||||||
|
<% ops.each do |op| %>
|
||||||
|
<option value="<%= op[:tabela] %>" <%= 'selected' if op[:tabela] == @op_b %>><%= op[:label] %></option>
|
||||||
|
<% end %>
|
||||||
|
</optgroup>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<%# Comparativo direto A x B (tabela + barras agrupadas) %>
|
||||||
|
<% if @metricas_a && @metricas_b %>
|
||||||
|
<%= render 'comparativo', a: @metricas_a, b: @metricas_b,
|
||||||
|
label_a: @metricas_a.operacoes_label, label_b: @metricas_b.operacoes_label %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Detalhe completo de cada operação (recolhível) %>
|
||||||
|
<details class="bg-[#1a1a1a] rounded-2xl border border-white/5">
|
||||||
|
<summary class="cursor-pointer px-6 py-4 text-sm text-gray-300 hover:text-white select-none">
|
||||||
|
🔎 Ver detalhe completo de cada operação
|
||||||
|
</summary>
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 p-4 pt-0">
|
||||||
|
<div>
|
||||||
|
<% if @metricas_a %>
|
||||||
|
<%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500 text-sm">Selecione a operação A.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<% if @metricas_b %>
|
||||||
|
<%= render 'painel', metricas: @metricas_b, titulo: "B · #{@metricas_b.operacoes_label}" %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500 text-sm">Selecione a operação B.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const ISO = '<%= ini_iso %>';
|
||||||
|
const ISO_FIM = '<%= fim_iso %>';
|
||||||
|
|
||||||
|
// ── Flatpickr (período) ──
|
||||||
|
function initPeriodo() {
|
||||||
|
const input = document.getElementById('periodo-range');
|
||||||
|
if (!input || typeof flatpickr === 'undefined' || input._flatpickr) return;
|
||||||
|
const toISO = (d) =>
|
||||||
|
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||||
|
flatpickr(input, {
|
||||||
|
mode: 'range',
|
||||||
|
locale: Object.assign({}, flatpickr.l10ns.pt, { rangeSeparator: ' até ' }),
|
||||||
|
dateFormat: 'd/m/Y',
|
||||||
|
defaultDate: [parseISO(ISO), parseISO(ISO_FIM)],
|
||||||
|
maxDate: 'today',
|
||||||
|
onClose: function (selectedDates) {
|
||||||
|
if (selectedDates.length === 0) return;
|
||||||
|
const ini = toISO(selectedDates[0]);
|
||||||
|
const fim = toISO(selectedDates[selectedDates.length - 1]);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('inicio', ini);
|
||||||
|
url.searchParams.set('fim', fim);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selects que submetem o form ao mudar ──
|
||||||
|
function initAutoSubmit() {
|
||||||
|
document.querySelectorAll('select.auto-submit').forEach(function (s) {
|
||||||
|
if (s.dataset.bound) return;
|
||||||
|
s.dataset.bound = '1';
|
||||||
|
s.addEventListener('change', function () { s.form.requestSubmit(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cores da marca (laranja + azul p/ falha) ──
|
||||||
|
const COR_OK = '#f97316'; // laranja Reem (completas)
|
||||||
|
const COR_FAIL = '#3b82f6'; // azul (falhas), igual ao card de Recusas
|
||||||
|
const COR_A = '#f97316'; // comparativo: A = laranja da marca
|
||||||
|
const COR_B = '#3b82f6'; // comparativo: B = azul (contraste)
|
||||||
|
|
||||||
|
// Cross-filter por clique nos gráficos: recarrega a página com o filtro,
|
||||||
|
// preservando os demais parâmetros (mesma lógica dos links das tabelas).
|
||||||
|
function aplicarFiltroDash(novos) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
Object.keys(novos).forEach(function (k) {
|
||||||
|
const v = novos[k];
|
||||||
|
if (v === null || v === '') url.searchParams.delete(k);
|
||||||
|
else url.searchParams.set(k, v);
|
||||||
|
});
|
||||||
|
window.location.assign(url.toString());
|
||||||
|
}
|
||||||
|
function cursorPonteiro(e, els) { e.native.target.style.cursor = els.length ? 'pointer' : 'default'; }
|
||||||
|
|
||||||
|
// ── Charts (donut + barras + comparativo) ──
|
||||||
|
function initCharts() {
|
||||||
|
if (typeof Chart === 'undefined') return;
|
||||||
|
|
||||||
|
document.querySelectorAll('canvas.dash-donut').forEach(function (ctx) {
|
||||||
|
Chart.getChart(ctx)?.destroy();
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: ['Completas', 'Falhas'],
|
||||||
|
datasets: [{
|
||||||
|
data: [Number(ctx.dataset.completed), Number(ctx.dataset.failed)],
|
||||||
|
backgroundColor: [COR_OK, COR_FAIL],
|
||||||
|
borderColor: '#1a1a1a', borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, cutout: '62%',
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
onHover: cursorPonteiro,
|
||||||
|
onClick: function (e, els) {
|
||||||
|
if (!els.length) return;
|
||||||
|
aplicarFiltroDash({ f_resultado: els[0].index === 0 ? 'completed' : 'failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('canvas.dash-bars').forEach(function (ctx) {
|
||||||
|
Chart.getChart(ctx)?.destroy();
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: JSON.parse(ctx.dataset.labels),
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Completas', data: JSON.parse(ctx.dataset.completed), backgroundColor: COR_OK },
|
||||||
|
{ label: 'Falhas', data: JSON.parse(ctx.dataset.failed), backgroundColor: COR_FAIL }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
onHover: cursorPonteiro,
|
||||||
|
onClick: function (e, els) {
|
||||||
|
if (!els.length) return;
|
||||||
|
const el = els[0];
|
||||||
|
const iso = String(this.data.labels[el.index]).split('/').reverse().join('-');
|
||||||
|
aplicarFiltroDash({ f_data: iso, f_resultado: el.datasetIndex === 0 ? 'completed' : 'failed' });
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true, ticks: { color: '#6b7280', maxTicksLimit: 12 }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
||||||
|
y: { stacked: true, beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { color: '#9ca3af' } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comparativo A x B (barras agrupadas)
|
||||||
|
document.querySelectorAll('canvas.cmp-bars').forEach(function (ctx) {
|
||||||
|
Chart.getChart(ctx)?.destroy();
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: ['Total', 'Sucesso', 'Recusas', 'Pendentes'],
|
||||||
|
datasets: [
|
||||||
|
{ label: 'A · ' + (ctx.dataset.labelA || 'A'), data: JSON.parse(ctx.dataset.a), backgroundColor: COR_A },
|
||||||
|
{ label: 'B · ' + (ctx.dataset.labelB || 'B'), data: JSON.parse(ctx.dataset.b), backgroundColor: COR_B }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
||||||
|
y: { beginAtZero: true, ticks: { color: '#6b7280' }, grid: { color: 'rgba(255,255,255,0.04)' } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { color: '#9ca3af' } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapa de calor (Leaflet) ──
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function popupHtml(p) {
|
||||||
|
const linha = (icone, val) => val ? ('<div>' + icone + ' ' + escapeHtml(val) + '</div>') : '';
|
||||||
|
const foto = p.foto
|
||||||
|
? '<a href="' + escapeHtml(p.foto) + '" target="_blank" rel="noopener">' +
|
||||||
|
'<img src="' + escapeHtml(p.foto) + '" alt="Fachada" loading="lazy" ' +
|
||||||
|
'onerror="this.style.display=\'none\';this.parentNode.nextElementSibling.style.display=\'block\'" ' +
|
||||||
|
'style="margin-top:8px;width:100%;max-height:160px;object-fit:cover;border-radius:6px;display:block"></a>' +
|
||||||
|
'<div style="display:none;margin-top:6px;color:#9ca3af;font-size:11px">📷 Foto indisponível (link expirado/sem acesso)</div>'
|
||||||
|
: '<div style="margin-top:6px;color:#6b7280;font-size:11px">📷 Sem foto da fachada para esta entrega</div>';
|
||||||
|
const statusLinha = p.sucesso
|
||||||
|
? '<div style="color:#16a34a;font-size:11px;margin-bottom:6px">✔ Entrega realizada</div>'
|
||||||
|
: '<div style="color:#3b82f6;font-size:11px;margin-bottom:6px">✖ Insucesso' +
|
||||||
|
(p.motivo ? ' — ' + escapeHtml(p.motivo) : '') + '</div>';
|
||||||
|
return '' +
|
||||||
|
'<div style="min-width:210px;max-width:240px">' +
|
||||||
|
'<div style="font-weight:700;color:#f97316">NF ' + escapeHtml(p.nf) + '</div>' +
|
||||||
|
'<div style="font-weight:600;margin-bottom:4px">' + escapeHtml(p.nome) + '</div>' +
|
||||||
|
statusLinha +
|
||||||
|
linha('📍', p.endereco) +
|
||||||
|
linha('🏥', p.unidade) +
|
||||||
|
linha('🚚', p.motorista) +
|
||||||
|
linha('🚗', p.veiculo) +
|
||||||
|
linha('🕒', p.checkout) +
|
||||||
|
foto +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ícone de localização (balão) — sem depender de imagem externa.
|
||||||
|
// Cor por resultado: laranja = sucesso, azul = insucesso (falha/óbito).
|
||||||
|
function pinIcon(cor) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'pin-entrega',
|
||||||
|
html: '<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">' +
|
||||||
|
'<path d="M14 0C6.8 0 1 5.8 1 13c0 9.7 13 27 13 27s13-17.3 13-27C27 5.8 21.2 0 14 0z" ' +
|
||||||
|
'fill="' + cor + '" stroke="#0a0a0a" stroke-width="1.5"/>' +
|
||||||
|
'<circle cx="14" cy="13" r="5" fill="#0a0a0a"/></svg>',
|
||||||
|
iconSize: [28, 40],
|
||||||
|
iconAnchor: [14, 40],
|
||||||
|
popupAnchor: [0, -36]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMapas() {
|
||||||
|
if (typeof L === 'undefined') return;
|
||||||
|
const ICONE_OK = pinIcon('#f97316'); // sucesso — laranja
|
||||||
|
const ICONE_FALHA = pinIcon('#3b82f6'); // insucesso — azul
|
||||||
|
document.querySelectorAll('.mapa-box').forEach(function (box) {
|
||||||
|
const el = box.querySelector('.mapa-calor');
|
||||||
|
if (!el || el._leaflet_id) return;
|
||||||
|
|
||||||
|
const heat = JSON.parse(el.dataset.heat || '[]');
|
||||||
|
const pontos = JSON.parse(el.dataset.pontos || '[]');
|
||||||
|
const centro = JSON.parse(el.dataset.centro || '[-23.55,-46.63]');
|
||||||
|
|
||||||
|
// Camadas base (todas grátis, sem chave): escuro (padrão, combina com o
|
||||||
|
// tema), claro e satélite. O escuro usa o OpenStreetMap COMPLETO (mesmo
|
||||||
|
// detalhe do claro: ruas, nomes, POIs) e é escurecido por filtro CSS de
|
||||||
|
// inversão (.tiles-escuro-contraste).
|
||||||
|
const escuro = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap', maxZoom: 19,
|
||||||
|
className: 'tiles-escuro-contraste'
|
||||||
|
});
|
||||||
|
const claro = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap', maxZoom: 19
|
||||||
|
});
|
||||||
|
const satelite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||||
|
attribution: 'Tiles © Esri', maxZoom: 19
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapa = L.map(el, { layers: [escuro] }).setView(centro, 11);
|
||||||
|
L.control.layers(
|
||||||
|
{ '🌙 Escuro': escuro, '🗺️ Claro': claro, '🛰️ Satélite': satelite },
|
||||||
|
{}, { position: 'topright', collapsed: true }
|
||||||
|
).addTo(mapa);
|
||||||
|
|
||||||
|
// Camada de calor (leve — entra por padrão)
|
||||||
|
const heatLayer = (heat.length && L.heatLayer)
|
||||||
|
? L.heatLayer(heat, { radius: 18, blur: 22, maxZoom: 14 }) : null;
|
||||||
|
|
||||||
|
// Camada de pontos: criada SOB DEMANDA (só na 1ª vez que abrir "Pontos").
|
||||||
|
// O popup é uma FUNÇÃO, então o HTML da foto só é montado/baixado quando
|
||||||
|
// o balão é clicado — nada de imagem é requisitado ao carregar o mapa.
|
||||||
|
let marcadores = null;
|
||||||
|
let entradas = []; // [{ marker, p }] — para a busca filtrar sem recriar
|
||||||
|
const resultadosBusca = L.layerGroup();
|
||||||
|
function construirMarcadores() {
|
||||||
|
if (marcadores) return marcadores;
|
||||||
|
marcadores = L.layerGroup();
|
||||||
|
pontos.forEach(function (p) {
|
||||||
|
const marker = L.marker([p.lat, p.lng], { icon: p.sucesso ? ICONE_OK : ICONE_FALHA })
|
||||||
|
.bindPopup(function () { return popupHtml(p); }, { maxWidth: 260 });
|
||||||
|
marker.addTo(marcadores);
|
||||||
|
entradas.push({ marker: marker, p: p });
|
||||||
|
});
|
||||||
|
return marcadores;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Começa no Calor (cai para Pontos se não houver calor)
|
||||||
|
let vistaAtual = heatLayer ? 'heat' : 'pontos';
|
||||||
|
(heatLayer || construirMarcadores()).addTo(mapa);
|
||||||
|
|
||||||
|
function mostrar(view) {
|
||||||
|
vistaAtual = view;
|
||||||
|
if (heatLayer) mapa.removeLayer(heatLayer);
|
||||||
|
if (marcadores) mapa.removeLayer(marcadores);
|
||||||
|
if (view === 'pontos') { construirMarcadores().addTo(mapa); }
|
||||||
|
else { (heatLayer || construirMarcadores()).addTo(mapa); }
|
||||||
|
box.querySelectorAll('.map-toggle button').forEach(function (b) {
|
||||||
|
const on = b.dataset.view === view;
|
||||||
|
b.classList.toggle('bg-orange-500', on);
|
||||||
|
b.classList.toggle('text-black', on);
|
||||||
|
b.classList.toggle('font-semibold', on);
|
||||||
|
b.classList.toggle('text-gray-300', !on);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
box.querySelectorAll('.map-toggle button').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
const inputBusca = box.querySelector('.mapa-busca');
|
||||||
|
if (inputBusca && inputBusca.value) { inputBusca.value = ''; }
|
||||||
|
limparBusca();
|
||||||
|
mostrar(b.dataset.view);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Busca por NF / paciente / veículo ──
|
||||||
|
const inputBusca = box.querySelector('.mapa-busca');
|
||||||
|
const infoBusca = box.querySelector('.mapa-busca-info');
|
||||||
|
|
||||||
|
function limparBusca() {
|
||||||
|
mapa.removeLayer(resultadosBusca);
|
||||||
|
resultadosBusca.clearLayers();
|
||||||
|
if (infoBusca) infoBusca.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarBusca(termo) {
|
||||||
|
const q = String(termo || '').trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
limparBusca();
|
||||||
|
mostrar(vistaAtual); // restaura a visão padrão (Calor ou Pontos)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
construirMarcadores();
|
||||||
|
if (heatLayer) mapa.removeLayer(heatLayer);
|
||||||
|
if (marcadores) mapa.removeLayer(marcadores);
|
||||||
|
resultadosBusca.clearLayers();
|
||||||
|
|
||||||
|
const casa = function (v) { return String(v == null ? '' : v).toLowerCase().indexOf(q) !== -1; };
|
||||||
|
const achados = entradas.filter(function (e) {
|
||||||
|
return casa(e.p.nf) || casa(e.p.nome) || casa(e.p.veiculo);
|
||||||
|
});
|
||||||
|
achados.forEach(function (e) { e.marker.addTo(resultadosBusca); });
|
||||||
|
resultadosBusca.addTo(mapa);
|
||||||
|
|
||||||
|
if (achados.length) {
|
||||||
|
const grupo = L.featureGroup(achados.map(function (e) { return e.marker; }));
|
||||||
|
mapa.fitBounds(grupo.getBounds(), { padding: [40, 40], maxZoom: 16 });
|
||||||
|
if (achados.length === 1) { achados[0].marker.openPopup(); }
|
||||||
|
if (infoBusca) infoBusca.textContent = achados.length + (achados.length === 1 ? ' resultado' : ' resultados');
|
||||||
|
} else {
|
||||||
|
if (infoBusca) infoBusca.textContent = 'Nenhum resultado';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputBusca) {
|
||||||
|
let debounce = null;
|
||||||
|
inputBusca.addEventListener('input', function () {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const valor = inputBusca.value;
|
||||||
|
debounce = setTimeout(function () { aplicarBusca(valor); }, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tela cheia (Fullscreen API sobre o container do mapa)
|
||||||
|
const btnFs = box.querySelector('.map-fullscreen');
|
||||||
|
if (btnFs) {
|
||||||
|
btnFs.addEventListener('click', function () {
|
||||||
|
if (document.fullscreenElement) { document.exitFullscreen(); }
|
||||||
|
else if (el.requestFullscreen) { el.requestFullscreen(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', function () {
|
||||||
|
setTimeout(function () { mapa.invalidateSize(); }, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(function () { mapa.invalidateSize(); }, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTudo() { initPeriodo(); initAutoSubmit(); initCharts(); initMapas(); }
|
||||||
|
initTudo();
|
||||||
|
document.addEventListener('turbo:load', initTudo);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -16,6 +16,9 @@ Rails.application.routes.draw do
|
|||||||
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
||||||
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro
|
||||||
|
|
||||||
|
# Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...)
|
||||||
|
get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard
|
||||||
|
|
||||||
# Painel motorista
|
# Painel motorista
|
||||||
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
|
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ Rails.application.routes.draw do
|
|||||||
member do
|
member do
|
||||||
get :wizard # Passo 1 — selecionar motorista
|
get :wizard # Passo 1 — selecionar motorista
|
||||||
post :finalizar
|
post :finalizar
|
||||||
|
post :finalizar_parcial # arquiva veículos pendentes e finaliza (só os consolidados)
|
||||||
post :arquivar
|
post :arquivar
|
||||||
post :reativar # Restaura uma consolidação arquivada
|
post :reativar # Restaura uma consolidação arquivada
|
||||||
post :registrar_pagamento # Marca 1 motorista (ou todos) como pago
|
post :registrar_pagamento # Marca 1 motorista (ou todos) como pago
|
||||||
@@ -58,6 +62,10 @@ Rails.application.routes.draw do
|
|||||||
post :classificar # toggle individual (JSON)
|
post :classificar # toggle individual (JSON)
|
||||||
post :classificar_em_massa # ações em massa (JSON)
|
post :classificar_em_massa # ações em massa (JSON)
|
||||||
get :revisar # Passo 3 — revisão do motorista
|
get :revisar # Passo 3 — revisão do motorista
|
||||||
|
post :fechar_veiculo # consolida (fecha) um veículo do motorista
|
||||||
|
post :reabrir_veiculo # reabre um veículo já consolidado
|
||||||
|
post :arquivar_veiculo # tira um veículo do gate (reversível)
|
||||||
|
post :desarquivar_veiculo # restaura um veículo arquivado
|
||||||
get :buscar_nf # JSON — busca a NF na SimpleRoute (apontamento manual)
|
get :buscar_nf # JSON — busca a NF na SimpleRoute (apontamento manual)
|
||||||
post :apontar # cria apontamento manual a partir da NF
|
post :apontar # cria apontamento manual a partir da NF
|
||||||
post :apontar_manual # apontamento 100% manual (sem rastreio)
|
post :apontar_manual # apontamento 100% manual (sem rastreio)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Snapshot do veículo em cada pilar da consolidação. O veículo vive só na tabela
|
||||||
|
# externa de rastreio (db_reem_simplerout_2026), acessível via tracking_id. Guardar
|
||||||
|
# uma cópia aqui permite fechar/consolidar POR VEÍCULO sem N+1 e mantém o histórico
|
||||||
|
# estável mesmo que a base externa mude depois. Lançamentos sintéticos (apontamento
|
||||||
|
# 100% manual "MAN-*" e entrega de termo "TERMO-*") não têm veículo na rastreio e
|
||||||
|
# ficam NULL — o agrupamento trata NULL como "(sem veículo)".
|
||||||
|
class AddVehicleToConsolidacaoEntregas < ActiveRecord::Migration[7.1]
|
||||||
|
def up
|
||||||
|
unless column_exists?(:consolidacao_entregas, :vehicle)
|
||||||
|
add_column :consolidacao_entregas, :vehicle, :string
|
||||||
|
add_index :consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle],
|
||||||
|
name: 'idx_consolidacao_entrega_veiculo'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Backfill dos lançamentos já existentes a partir da rastreio. Só roda onde a
|
||||||
|
# tabela externa existe (dev/prod) — no banco de teste ela é isolada, então
|
||||||
|
# pulamos para não quebrar o db:migrate.
|
||||||
|
return unless connection.table_exists?('db_reem_simplerout_2026')
|
||||||
|
|
||||||
|
say_with_time 'backfill vehicle em consolidacao_entregas' do
|
||||||
|
execute(<<~SQL.squish)
|
||||||
|
UPDATE consolidacao_entregas ce
|
||||||
|
SET vehicle = e.vehicle
|
||||||
|
FROM db_reem_simplerout_2026 e
|
||||||
|
WHERE e.tracking_id = ce.tracking_id
|
||||||
|
AND ce.vehicle IS NULL
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
if index_exists?(:consolidacao_entregas, %i[consolidacao_id motorista_nome vehicle],
|
||||||
|
name: 'idx_consolidacao_entrega_veiculo')
|
||||||
|
remove_index :consolidacao_entregas, name: 'idx_consolidacao_entrega_veiculo'
|
||||||
|
end
|
||||||
|
remove_column :consolidacao_entregas, :vehicle if column_exists?(:consolidacao_entregas, :vehicle)
|
||||||
|
end
|
||||||
|
end
|
||||||
61
db/migrate/20260703000002_create_consolidacao_veiculos.rb
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha o padrão de
|
||||||
|
# consolidacao_motoristas (arquivamento reversível + valor_total), mas o que ela
|
||||||
|
# controla é o estado "fechado/consolidado" de cada carro — o PAGAMENTO continua no
|
||||||
|
# motorista (consolidacao_motoristas). Assim dá para consolidar um veículo pronto
|
||||||
|
# sem esperar os outros. O bucket sem veículo (termo/apontamento manual legado) usa
|
||||||
|
# o sentinel '(sem veículo)' para que o índice único funcione (NULL quebraria).
|
||||||
|
class CreateConsolidacaoVeiculos < ActiveRecord::Migration[7.1]
|
||||||
|
SEM_VEICULO = '(sem veículo)'
|
||||||
|
|
||||||
|
def up
|
||||||
|
unless table_exists?(:consolidacao_veiculos)
|
||||||
|
create_table :consolidacao_veiculos do |t|
|
||||||
|
t.references :consolidacao, null: false, foreign_key: { to_table: :consolidacoes }
|
||||||
|
t.string :motorista_nome, null: false
|
||||||
|
t.string :vehicle, null: false
|
||||||
|
t.decimal :valor_total, precision: 10, scale: 2, default: 0
|
||||||
|
|
||||||
|
t.datetime :fechado_em # NULL = aberto (ainda não consolidado)
|
||||||
|
t.integer :fechado_por
|
||||||
|
t.datetime :arquivado_em # NULL = ativo
|
||||||
|
t.integer :arquivado_por
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :consolidacao_veiculos, %i[consolidacao_id motorista_nome vehicle],
|
||||||
|
unique: true, name: 'idx_consolidacao_veiculo_unique'
|
||||||
|
add_index :consolidacao_veiculos, :arquivado_em
|
||||||
|
end
|
||||||
|
|
||||||
|
# Backfill: uma linha por (consolidação · motorista · veículo) já classificado,
|
||||||
|
# somando o valor (desconto = tipo 3 subtrai). Consolidações já finalizadas
|
||||||
|
# (status 1) nascem FECHADAS, herdando quem/quando finalizou — para não
|
||||||
|
# "reabrir" o que já foi pago.
|
||||||
|
say_with_time 'backfill consolidacao_veiculos a partir das entregas' do
|
||||||
|
execute(<<~SQL.squish)
|
||||||
|
INSERT INTO consolidacao_veiculos
|
||||||
|
(consolidacao_id, motorista_nome, vehicle, valor_total,
|
||||||
|
fechado_em, fechado_por, created_at, updated_at)
|
||||||
|
SELECT ce.consolidacao_id,
|
||||||
|
ce.motorista_nome,
|
||||||
|
COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}') AS vehicle,
|
||||||
|
COALESCE(SUM(CASE WHEN ce.tipo = 3
|
||||||
|
THEN -ce.valor_aplicado
|
||||||
|
ELSE ce.valor_aplicado END), 0) AS valor_total,
|
||||||
|
CASE WHEN c.status = 1 THEN c.finalizado_em END AS fechado_em,
|
||||||
|
CASE WHEN c.status = 1 THEN c.finalizado_por END AS fechado_por,
|
||||||
|
NOW(), NOW()
|
||||||
|
FROM consolidacao_entregas ce
|
||||||
|
JOIN consolidacoes c ON c.id = ce.consolidacao_id
|
||||||
|
GROUP BY ce.consolidacao_id, ce.motorista_nome,
|
||||||
|
COALESCE(NULLIF(ce.vehicle, ''), '#{SEM_VEICULO}'),
|
||||||
|
c.status, c.finalizado_em, c.finalizado_por
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :consolidacao_veiculos if table_exists?(:consolidacao_veiculos)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,6 +39,12 @@ FactoryBot.define do
|
|||||||
sequence(:motorista_nome) { |n| "Motorista #{n}" }
|
sequence(:motorista_nome) { |n| "Motorista #{n}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
factory :consolidacao_veiculo do
|
||||||
|
association :consolidacao
|
||||||
|
sequence(:motorista_nome) { |n| "Motorista #{n}" }
|
||||||
|
sequence(:vehicle) { |n| "GADE_#{format('%03d', n)}" }
|
||||||
|
end
|
||||||
|
|
||||||
factory :consolidacao_entrega do
|
factory :consolidacao_entrega do
|
||||||
association :consolidacao
|
association :consolidacao
|
||||||
sequence(:tracking_id) { |n| "TRK-#{n}" }
|
sequence(:tracking_id) { |n| "TRK-#{n}" }
|
||||||
|
|||||||
@@ -232,4 +232,108 @@ RSpec.describe Consolidacao, type: :model do
|
|||||||
expect(Consolidacao.arquivadas).to include(arquivada)
|
expect(Consolidacao.arquivadas).to include(arquivada)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#sincronizar_veiculos! (via recalcular_motorista!)' do
|
||||||
|
let(:consolidacao) { create(:consolidacao) }
|
||||||
|
|
||||||
|
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
|
||||||
|
|
||||||
|
it 'cria uma linha por veículo classificado, somando o valor do veículo' do
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15)
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', tipo: :bonus, valor_aplicado: 10)
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 20)
|
||||||
|
|
||||||
|
consolidacao.recalcular_motorista!('A')
|
||||||
|
|
||||||
|
veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A').order(:vehicle)
|
||||||
|
expect(veics.map(&:vehicle)).to eq(%w[GADE_1 GADE_2])
|
||||||
|
expect(veics.find_by(vehicle: 'GADE_1').valor_total).to eq(25)
|
||||||
|
expect(veics.find_by(vehicle: 'GADE_2').valor_total).to eq(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'remove o veículo aberto que ficou sem pilares, mas preserva o fechado' do
|
||||||
|
e1 = create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', valor_aplicado: 15)
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_2', valor_aplicado: 20)
|
||||||
|
consolidacao.recalcular_motorista!('A')
|
||||||
|
consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_2').fechar!(create(:gerente))
|
||||||
|
|
||||||
|
e1.destroy
|
||||||
|
consolidacao.recalcular_motorista!('A')
|
||||||
|
|
||||||
|
veics = consolidacao.consolidacao_veiculos.where(motorista_nome: 'A')
|
||||||
|
expect(veics.pluck(:vehicle)).to contain_exactly('GADE_2') # GADE_1 (aberto e vazio) removido
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'gate: veículo arquivado libera a finalização' do
|
||||||
|
let(:consolidacao) { create(:consolidacao) }
|
||||||
|
|
||||||
|
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
|
||||||
|
|
||||||
|
it 'desconta as elegíveis do veículo arquivado' do
|
||||||
|
allow(consolidacao).to receive(:entregas_elegiveis_count).with('A').and_return(3)
|
||||||
|
allow(consolidacao).to receive(:entregas_elegiveis_count_veiculo).with('A', 'GADE_2').and_return(1)
|
||||||
|
|
||||||
|
# 2 classificadas (GADE_1). Falta 1 (GADE_2) => incompleto.
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', tracking_id: 'T1')
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', tracking_id: 'T2', tipo: :bonus)
|
||||||
|
expect(consolidacao.motorista_completo?('A')).to be false
|
||||||
|
|
||||||
|
# Arquiva GADE_2 (o carro sem classificar): total cai p/ 2 => completo.
|
||||||
|
cv = consolidacao.consolidacao_veiculos.create!(motorista_nome: 'A', vehicle: 'GADE_2')
|
||||||
|
cv.arquivar!(create(:gerente))
|
||||||
|
expect(consolidacao.motorista_completo?('A')).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#arquivar_veiculos_pendentes!' do
|
||||||
|
let(:consolidacao) { create(:consolidacao) }
|
||||||
|
let(:gerente) { create(:gerente) }
|
||||||
|
|
||||||
|
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
|
||||||
|
|
||||||
|
it 'arquiva só os veículos pendentes (com elegíveis e < 100%)' do
|
||||||
|
allow(consolidacao).to receive(:veiculos_do_motorista).with('A').and_return([
|
||||||
|
{ vehicle: 'GADE_1', eligible: 5, classificadas: 5, completo: true, fechado: true, arquivado: false },
|
||||||
|
{ vehicle: 'GADE_2', eligible: 3, classificadas: 1, completo: false, fechado: false, arquivado: false },
|
||||||
|
{ vehicle: '(sem veículo)', eligible: 0, classificadas: 0, completo: false, fechado: false, arquivado: false }
|
||||||
|
])
|
||||||
|
|
||||||
|
arquivados = consolidacao.arquivar_veiculos_pendentes!(gerente)
|
||||||
|
|
||||||
|
expect(arquivados).to eq(['A · GADE_2'])
|
||||||
|
expect(consolidacao.consolidacao_veiculos.find_by(motorista_nome: 'A', vehicle: 'GADE_2')).to be_arquivado
|
||||||
|
# não mexe no consolidado (GADE_1) nem no bucket sem elegíveis
|
||||||
|
expect(consolidacao.consolidacao_veiculos.where(vehicle: 'GADE_1')).not_to exist
|
||||||
|
expect(consolidacao.consolidacao_veiculos.where(vehicle: '(sem veículo)')).not_to exist
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#finalizar! fecha os veículos ainda abertos' do
|
||||||
|
let(:consolidacao) { create(:consolidacao) }
|
||||||
|
let(:gerente) { create(:gerente) }
|
||||||
|
|
||||||
|
before { create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') }
|
||||||
|
|
||||||
|
it 'consolida os veículos abertos ao finalizar' do
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A',
|
||||||
|
vehicle: 'GADE_1', valor_aplicado: 15)
|
||||||
|
consolidacao.recalcular_motorista!('A')
|
||||||
|
cv = consolidacao.consolidacao_veiculos.find_by(vehicle: 'GADE_1')
|
||||||
|
expect(cv).not_to be_fechado
|
||||||
|
|
||||||
|
allow(consolidacao).to receive(:todas_entregas_classificadas?).and_return(true)
|
||||||
|
consolidacao.finalizar!(gerente)
|
||||||
|
|
||||||
|
expect(cv.reload).to be_fechado
|
||||||
|
expect(cv.fechado_por).to eq(gerente.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
74
spec/models/consolidacao_veiculo_spec.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ConsolidacaoVeiculo, type: :model do
|
||||||
|
let(:consolidacao) { create(:consolidacao) }
|
||||||
|
|
||||||
|
describe 'validações' do
|
||||||
|
it 'exige motorista_nome e vehicle' do
|
||||||
|
expect(build(:consolidacao_veiculo, motorista_nome: nil)).not_to be_valid
|
||||||
|
expect(build(:consolidacao_veiculo, vehicle: nil)).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'não permite o mesmo veículo duas vezes para o mesmo motorista' do
|
||||||
|
create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
|
||||||
|
dup = build(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
|
||||||
|
expect(dup).not_to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#recalcular_valor (before_save)' do
|
||||||
|
before do
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
|
||||||
|
vehicle: 'GADE_1', tipo: :entrega_normal, valor_aplicado: 15)
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
|
||||||
|
vehicle: 'GADE_1', tipo: :desconto, valor_aplicado: 5)
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
|
||||||
|
vehicle: 'GADE_2', tipo: :entrega_normal, valor_aplicado: 99)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'soma só os pilares do veículo e trata desconto como negativo' do
|
||||||
|
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao', vehicle: 'GADE_1')
|
||||||
|
expect(cv.valor_total).to eq(10) # 15 - 5 (GADE_2 fica de fora)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'o bucket "(sem veículo)" casa pilares com vehicle NULL/vazio' do
|
||||||
|
create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao',
|
||||||
|
vehicle: nil, tipo: :bonus, valor_aplicado: 7)
|
||||||
|
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'Joao',
|
||||||
|
vehicle: ConsolidacaoVeiculo::SEM_VEICULO)
|
||||||
|
expect(cv.valor_total).to eq(7)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'fechamento e arquivamento' do
|
||||||
|
let(:gerente) { create(:gerente) }
|
||||||
|
|
||||||
|
it 'fecha e reabre registrando quem/quando' do
|
||||||
|
cv = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
|
||||||
|
expect(cv).not_to be_fechado
|
||||||
|
|
||||||
|
cv.fechar!(gerente)
|
||||||
|
cv.reload
|
||||||
|
expect(cv).to be_fechado
|
||||||
|
expect(cv.fechado_por).to eq(gerente.id)
|
||||||
|
expect(cv.fechado_em).to be_present
|
||||||
|
|
||||||
|
cv.reabrir!(gerente)
|
||||||
|
expect(cv.reload).not_to be_fechado
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'arquiva e desarquiva, e os escopos separam os estados' do
|
||||||
|
aberto = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'A', vehicle: 'GADE_1')
|
||||||
|
fechado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'B', vehicle: 'GADE_2')
|
||||||
|
arquivado = create(:consolidacao_veiculo, consolidacao: consolidacao, motorista_nome: 'C', vehicle: 'GADE_3')
|
||||||
|
fechado.fechar!(gerente)
|
||||||
|
arquivado.arquivar!(gerente)
|
||||||
|
|
||||||
|
expect(ConsolidacaoVeiculo.fechados).to include(fechado)
|
||||||
|
expect(ConsolidacaoVeiculo.fechados).not_to include(aberto)
|
||||||
|
expect(ConsolidacaoVeiculo.abertos).to include(aberto)
|
||||||
|
expect(ConsolidacaoVeiculo.ativos).not_to include(arquivado)
|
||||||
|
expect(ConsolidacaoVeiculo.arquivados).to contain_exactly(arquivado)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
31
spec/models/operacao_spec.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# label e grupo_de são só parsing do nome da tabela (sem tocar o banco).
|
||||||
|
RSpec.describe Operacao do
|
||||||
|
describe '.label (caixa alta, sem parecer o nome do banco)' do
|
||||||
|
it 'tira o prefixo e deixa em CAIXA ALTA' do
|
||||||
|
expect(Operacao.label('gade_entregas_ubs_norte_maio_2026')).to eq('UBS NORTE MAIO 2026')
|
||||||
|
expect(Operacao.label('gade_entregas_emad_fev_2026')).to eq('EMAD FEV 2026')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.grupo_de (agrupamento por mês)' do
|
||||||
|
it 'reconhece o mês por EXTENSO (maio) e agrupa no mês certo, item em caixa alta' do
|
||||||
|
g = Operacao.send(:grupo_de, 'gade_entregas_ubs_norte_maio_2026')
|
||||||
|
expect(g[:titulo]).to eq('Maio 2026')
|
||||||
|
expect(g[:item]).to eq('UBS NORTE')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'continua reconhecendo a abreviação (fev)' do
|
||||||
|
g = Operacao.send(:grupo_de, 'gade_entregas_emad_fev_2026')
|
||||||
|
expect(g[:titulo]).to eq('Fevereiro 2026')
|
||||||
|
expect(g[:item]).to eq('EMAD')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cai em "Outras" quando não há mês reconhecível' do
|
||||||
|
g = Operacao.send(:grupo_de, 'gade_entregas_oeste_centro_remanescentes')
|
||||||
|
expect(g[:titulo]).to eq('Outras')
|
||||||
|
expect(g[:item]).to eq('OESTE CENTRO REMANESCENTES')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
26
spec/requests/operacoes_dashboard_spec.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Dashboard de Operações', type: :request do
|
||||||
|
it 'redireciona usuário não autenticado para o login' do
|
||||||
|
get operacoes_dashboard_path
|
||||||
|
expect(response).to redirect_to(new_user_session_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'bloqueia motorista (Pundit redireciona para a raiz)' do
|
||||||
|
sign_in create(:motorista)
|
||||||
|
get operacoes_dashboard_path
|
||||||
|
expect(response).to redirect_to(root_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'permite operador (renderiza a página)' do
|
||||||
|
sign_in create(:operador)
|
||||||
|
get operacoes_dashboard_path
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'permite gerente' do
|
||||||
|
sign_in create(:gerente)
|
||||||
|
get operacoes_dashboard_path(modo: 'global')
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
115
spec/services/analytics/operacao_metricas_spec.rb
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Analytics::OperacaoMetricas do
|
||||||
|
# Linhas simuladas (mesma forma do retorno de select_all) — evita depender das
|
||||||
|
# tabelas externas no teste. Stubamos #registros para focar nas agregações.
|
||||||
|
let(:rows) do
|
||||||
|
[
|
||||||
|
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 10:00:00', clat: '-23.5', clng: '-46.6'),
|
||||||
|
row(status: 'completed', driver: 'Carlos', sts: 'STS PERUS', gade: 'RECORRENTE', checkout: '2026-06-22 11:00:00', lat: '-23.6', lng: '-46.7'),
|
||||||
|
row(status: 'completed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'RECORRENTE', checkout: '2026-06-23 09:00:00', clat: '-23.4', clng: '-46.5'),
|
||||||
|
row(status: 'failed', driver: 'Pedro', sts: 'STS PIRITUBA', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-23 12:00:00'),
|
||||||
|
row(status: 'failed', driver: 'Carlos', sts: 'STS PERUS', gade: 'NOVO', obs: 'ÓBITO', checkout: '2026-06-22 13:00:00'),
|
||||||
|
row(status: 'pending', driver: 'Marcos', sts: 'STS PERUS', gade: 'NOVO', planned: '2026-06-24')
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
subject(:metricas) do
|
||||||
|
described_class.new(tabelas: [], inicio: Date.new(2026, 6, 1), fim: Date.new(2026, 6, 30))
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Isola das tabelas externas: não toca o catálogo nem db_reem_simplerout_2026.
|
||||||
|
allow(Operacao).to receive(:sanitizar).and_return([])
|
||||||
|
allow(metricas).to receive(:registros).and_return(rows)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'conta KPIs (total/sucesso/recusas/pendentes)' do
|
||||||
|
expect(metricas.total).to eq(6)
|
||||||
|
expect(metricas.sucesso).to eq(3)
|
||||||
|
expect(metricas.recusas).to eq(2)
|
||||||
|
expect(metricas.pendentes).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calcula insucessos %' do
|
||||||
|
ins = metricas.insucessos_pct
|
||||||
|
expect(ins[:completed]).to eq(3)
|
||||||
|
expect(ins[:failed]).to eq(2)
|
||||||
|
expect(ins[:pct_completed]).to eq(50.0)
|
||||||
|
expect(ins[:pct_failed]).to eq(33.33)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'agrupa índices de falha por observação' do
|
||||||
|
expect(metricas.indices_falha).to eq([{ observation: 'ÓBITO', total: 2, pct: 100.0 }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'agrupa por status_gade' do
|
||||||
|
expect(metricas.por_status_gade).to contain_exactly(
|
||||||
|
{ status: 'RECORRENTE', total: 3 },
|
||||||
|
{ status: 'NOVO', total: 3 }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'conta NFs por motorista (todas), desc' do
|
||||||
|
expect(metricas.por_motorista.first).to eq({ nome: 'Carlos', total: 3 })
|
||||||
|
expect(metricas.por_motorista.map { |h| h[:nome] }).to eq(%w[Carlos Pedro Marcos])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'conta entregas concluídas por STS (contact_name)' do
|
||||||
|
expect(metricas.por_sts).to eq([
|
||||||
|
{ nome: 'STS PERUS', total: 2 },
|
||||||
|
{ nome: 'STS PIRITUBA', total: 1 }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'agrupa por dia (completas x falhas)' do
|
||||||
|
pd = metricas.por_dia
|
||||||
|
expect(pd[:labels]).to eq(['22/06/2026', '23/06/2026'])
|
||||||
|
expect(pd[:completed]).to eq([2, 1])
|
||||||
|
expect(pd[:failed]).to eq([1, 1])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'monta pontos do mapa (prefere checkout, cai p/ checkin)' do
|
||||||
|
expect(metricas.pontos_mapa).to eq([[-23.5, -46.6], [-23.6, -46.7], [-23.4, -46.5]])
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'com cross-filter (filtros)' do
|
||||||
|
subject(:filtrada) { described_class.new(tabelas: [], filtros: { 'driver' => 'Carlos' }) }
|
||||||
|
|
||||||
|
before { allow(filtrada).to receive(:registros).and_return(rows) }
|
||||||
|
|
||||||
|
it 'restringe todas as agregações ao valor filtrado' do
|
||||||
|
expect(filtrada.total).to eq(3)
|
||||||
|
expect(filtrada.sucesso).to eq(2)
|
||||||
|
expect(filtrada.recusas).to eq(1)
|
||||||
|
expect(filtrada.por_motorista).to eq([{ nome: 'Carlos', total: 3 }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignora filtro com valor em branco' do
|
||||||
|
vazio = described_class.new(tabelas: [], filtros: { 'driver' => '' })
|
||||||
|
allow(vazio).to receive(:registros).and_return(rows)
|
||||||
|
expect(vazio.total).to eq(6)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'sem registros' do
|
||||||
|
before { allow(metricas).to receive(:registros).and_return([]) }
|
||||||
|
|
||||||
|
it 'não divide por zero' do
|
||||||
|
expect(metricas.total).to eq(0)
|
||||||
|
expect(metricas.insucessos_pct[:pct_failed]).to eq(0.0)
|
||||||
|
expect(metricas.indices_falha).to eq([])
|
||||||
|
expect(metricas.pontos_mapa).to eq([])
|
||||||
|
expect(metricas.centro_mapa).to eq(described_class::SP_CENTRO)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def row(status:, driver:, sts:, gade:, obs: nil, checkout: nil, planned: nil, lat: nil, lng: nil, clat: nil, clng: nil)
|
||||||
|
{
|
||||||
|
'status' => status, 'driver' => driver, 'contact_name' => sts, 'status_gade' => gade,
|
||||||
|
'observation' => obs, 'checkout' => checkout, 'planned_date' => planned,
|
||||||
|
'latitude' => lat, 'longitude' => lng,
|
||||||
|
'checkout_latitude' => clat, 'checkout_longitude' => clng
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||