diff --git a/Imagens para correção /Imagem colada (10).png b/Imagens para correção /BKP/Imagem colada (10).png similarity index 100% rename from Imagens para correção /Imagem colada (10).png rename to Imagens para correção /BKP/Imagem colada (10).png diff --git a/Imagens para correção /Imagem colada (11).png b/Imagens para correção /BKP/Imagem colada (11).png similarity index 100% rename from Imagens para correção /Imagem colada (11).png rename to Imagens para correção /BKP/Imagem colada (11).png diff --git a/Imagens para correção /Imagem colada (12).png b/Imagens para correção /BKP/Imagem colada (12).png similarity index 100% rename from Imagens para correção /Imagem colada (12).png rename to Imagens para correção /BKP/Imagem colada (12).png diff --git a/Imagens para correção /Imagem colada (13).png b/Imagens para correção /BKP/Imagem colada (13).png similarity index 100% rename from Imagens para correção /Imagem colada (13).png rename to Imagens para correção /BKP/Imagem colada (13).png diff --git a/Imagens para correção /Imagem colada (14).png b/Imagens para correção /BKP/Imagem colada (14).png similarity index 100% rename from Imagens para correção /Imagem colada (14).png rename to Imagens para correção /BKP/Imagem colada (14).png diff --git a/Imagens para correção /Imagem colada (15).png b/Imagens para correção /BKP/Imagem colada (15).png similarity index 100% rename from Imagens para correção /Imagem colada (15).png rename to Imagens para correção /BKP/Imagem colada (15).png diff --git a/Imagens para correção /Imagem colada (16).png b/Imagens para correção /BKP/Imagem colada (16).png similarity index 100% rename from Imagens para correção /Imagem colada (16).png rename to Imagens para correção /BKP/Imagem colada (16).png diff --git a/Imagens para correção /Imagem colada (17).png b/Imagens para correção /BKP/Imagem colada (17).png similarity index 100% rename from Imagens para correção /Imagem colada (17).png rename to Imagens para correção /BKP/Imagem colada (17).png diff --git a/Imagens para correção /Imagem colada (2).png b/Imagens para correção /BKP/Imagem colada (2).png similarity index 100% rename from Imagens para correção /Imagem colada (2).png rename to Imagens para correção /BKP/Imagem colada (2).png diff --git a/Imagens para correção /Imagem colada (3).png b/Imagens para correção /BKP/Imagem colada (3).png similarity index 100% rename from Imagens para correção /Imagem colada (3).png rename to Imagens para correção /BKP/Imagem colada (3).png diff --git a/Imagens para correção /Imagem colada (4).png b/Imagens para correção /BKP/Imagem colada (4).png similarity index 100% rename from Imagens para correção /Imagem colada (4).png rename to Imagens para correção /BKP/Imagem colada (4).png diff --git a/Imagens para correção /Imagem colada (5).png b/Imagens para correção /BKP/Imagem colada (5).png similarity index 100% rename from Imagens para correção /Imagem colada (5).png rename to Imagens para correção /BKP/Imagem colada (5).png diff --git a/Imagens para correção /Imagem colada (6).png b/Imagens para correção /BKP/Imagem colada (6).png similarity index 100% rename from Imagens para correção /Imagem colada (6).png rename to Imagens para correção /BKP/Imagem colada (6).png diff --git a/Imagens para correção /Imagem colada (7).png b/Imagens para correção /BKP/Imagem colada (7).png similarity index 100% rename from Imagens para correção /Imagem colada (7).png rename to Imagens para correção /BKP/Imagem colada (7).png diff --git a/Imagens para correção /Imagem colada (8).png b/Imagens para correção /BKP/Imagem colada (8).png similarity index 100% rename from Imagens para correção /Imagem colada (8).png rename to Imagens para correção /BKP/Imagem colada (8).png diff --git a/Imagens para correção /Imagem colada (9).png b/Imagens para correção /BKP/Imagem colada (9).png similarity index 100% rename from Imagens para correção /Imagem colada (9).png rename to Imagens para correção /BKP/Imagem colada (9).png diff --git a/Imagens para correção /BKP/Imagem colada.png b/Imagens para correção /BKP/Imagem colada.png new file mode 100644 index 0000000..bafc59f Binary files /dev/null and b/Imagens para correção /BKP/Imagem colada.png differ diff --git a/Imagens para correção /Imagem colada.png b/Imagens para correção /Imagem colada.png index bafc59f..0134e75 100644 Binary files a/Imagens para correção /Imagem colada.png and b/Imagens para correção /Imagem colada.png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (10).png b/Imagens para implantação /Bkp Imagens/Imagem colada (10).png new file mode 100644 index 0000000..b98db24 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (10).png differ diff --git a/Imagens para implantação /Imagem colada (11).png b/Imagens para implantação /Bkp Imagens/Imagem colada (11).png similarity index 100% rename from Imagens para implantação /Imagem colada (11).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (11).png diff --git a/Imagens para implantação /Imagem colada (12).png b/Imagens para implantação /Bkp Imagens/Imagem colada (12).png similarity index 100% rename from Imagens para implantação /Imagem colada (12).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (12).png diff --git a/Imagens para implantação /Imagem colada (13).png b/Imagens para implantação /Bkp Imagens/Imagem colada (13).png similarity index 100% rename from Imagens para implantação /Imagem colada (13).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (13).png diff --git a/Imagens para implantação /Imagem colada (14).png b/Imagens para implantação /Bkp Imagens/Imagem colada (14).png similarity index 100% rename from Imagens para implantação /Imagem colada (14).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (14).png diff --git a/Imagens para implantação /Imagem colada (15).png b/Imagens para implantação /Bkp Imagens/Imagem colada (15).png similarity index 100% rename from Imagens para implantação /Imagem colada (15).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (15).png diff --git a/Imagens para implantação /Imagem colada (16).png b/Imagens para implantação /Bkp Imagens/Imagem colada (16).png similarity index 100% rename from Imagens para implantação /Imagem colada (16).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (16).png diff --git a/Imagens para implantação /Imagem colada (17).png b/Imagens para implantação /Bkp Imagens/Imagem colada (17).png similarity index 100% rename from Imagens para implantação /Imagem colada (17).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (17).png diff --git a/Imagens para implantação /Imagem colada (18).png b/Imagens para implantação /Bkp Imagens/Imagem colada (18).png similarity index 100% rename from Imagens para implantação /Imagem colada (18).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (18).png diff --git a/Imagens para implantação /Imagem colada (19).png b/Imagens para implantação /Bkp Imagens/Imagem colada (19).png similarity index 100% rename from Imagens para implantação /Imagem colada (19).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (19).png diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (2).png b/Imagens para implantação /Bkp Imagens/Imagem colada (2).png new file mode 100644 index 0000000..1608c25 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (2).png differ diff --git a/Imagens para implantação /Imagem colada (20).png b/Imagens para implantação /Bkp Imagens/Imagem colada (20).png similarity index 100% rename from Imagens para implantação /Imagem colada (20).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (20).png diff --git a/Imagens para implantação /Imagem colada (21).png b/Imagens para implantação /Bkp Imagens/Imagem colada (21).png similarity index 100% rename from Imagens para implantação /Imagem colada (21).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (21).png diff --git a/Imagens para implantação /Imagem colada (22).png b/Imagens para implantação /Bkp Imagens/Imagem colada (22).png similarity index 100% rename from Imagens para implantação /Imagem colada (22).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (22).png diff --git a/Imagens para implantação /Imagem colada (23).png b/Imagens para implantação /Bkp Imagens/Imagem colada (23).png similarity index 100% rename from Imagens para implantação /Imagem colada (23).png rename to Imagens para implantação /Bkp Imagens/Imagem colada (23).png diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (3).png b/Imagens para implantação /Bkp Imagens/Imagem colada (3).png new file mode 100644 index 0000000..d3fc022 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (3).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (4).png b/Imagens para implantação /Bkp Imagens/Imagem colada (4).png new file mode 100644 index 0000000..9dcfb24 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (4).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (5).png b/Imagens para implantação /Bkp Imagens/Imagem colada (5).png new file mode 100644 index 0000000..729beb3 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (5).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (6).png b/Imagens para implantação /Bkp Imagens/Imagem colada (6).png new file mode 100644 index 0000000..50c8ee9 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (6).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (7).png b/Imagens para implantação /Bkp Imagens/Imagem colada (7).png new file mode 100644 index 0000000..24a51d8 Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (7).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (8).png b/Imagens para implantação /Bkp Imagens/Imagem colada (8).png new file mode 100644 index 0000000..462b0da Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (8).png differ diff --git a/Imagens para implantação /Bkp Imagens/Imagem colada (9).png b/Imagens para implantação /Bkp Imagens/Imagem colada (9).png new file mode 100644 index 0000000..7b6604d Binary files /dev/null and b/Imagens para implantação /Bkp Imagens/Imagem colada (9).png differ diff --git a/Imagens para implantação /Imagem colada (10).png b/Imagens para implantação /Imagem colada (10).png index b98db24..41fbcd7 100644 Binary files a/Imagens para implantação /Imagem colada (10).png and b/Imagens para implantação /Imagem colada (10).png differ diff --git a/Imagens para implantação /Imagem colada (2).png b/Imagens para implantação /Imagem colada (2).png index 1608c25..3278741 100644 Binary files a/Imagens para implantação /Imagem colada (2).png and b/Imagens para implantação /Imagem colada (2).png differ diff --git a/Imagens para implantação /Imagem colada (3).png b/Imagens para implantação /Imagem colada (3).png index d3fc022..e4c9134 100644 Binary files a/Imagens para implantação /Imagem colada (3).png and b/Imagens para implantação /Imagem colada (3).png differ diff --git a/Imagens para implantação /Imagem colada (4).png b/Imagens para implantação /Imagem colada (4).png index 9dcfb24..b9365d6 100644 Binary files a/Imagens para implantação /Imagem colada (4).png and b/Imagens para implantação /Imagem colada (4).png differ diff --git a/Imagens para implantação /Imagem colada (5).png b/Imagens para implantação /Imagem colada (5).png index 729beb3..2a3e57b 100644 Binary files a/Imagens para implantação /Imagem colada (5).png and b/Imagens para implantação /Imagem colada (5).png differ diff --git a/Imagens para implantação /Imagem colada (6).png b/Imagens para implantação /Imagem colada (6).png index 50c8ee9..7f85bb8 100644 Binary files a/Imagens para implantação /Imagem colada (6).png and b/Imagens para implantação /Imagem colada (6).png differ diff --git a/Imagens para implantação /Imagem colada (7).png b/Imagens para implantação /Imagem colada (7).png index 24a51d8..636c66d 100644 Binary files a/Imagens para implantação /Imagem colada (7).png and b/Imagens para implantação /Imagem colada (7).png differ diff --git a/Imagens para implantação /Imagem colada (8).png b/Imagens para implantação /Imagem colada (8).png index 462b0da..ef2166a 100644 Binary files a/Imagens para implantação /Imagem colada (8).png and b/Imagens para implantação /Imagem colada (8).png differ diff --git a/Imagens para implantação /Imagem colada (9).png b/Imagens para implantação /Imagem colada (9).png index 7b6604d..d2e7b49 100644 Binary files a/Imagens para implantação /Imagem colada (9).png and b/Imagens para implantação /Imagem colada (9).png differ diff --git a/README.md b/README.md index 3933456..b5c0c9c 100644 --- a/README.md +++ b/README.md @@ -1032,7 +1032,7 @@ docker-compose exec app bundle exec rails db:migrate -
+
🔧 Atualização 29/06/2026 — Responsividade da tela de Validar (telas menores) 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
+
+🆕 Atualização 29/06/2026 — Dashboard de Operações (análise de entregas + mapa) + +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 ``, 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. + +
+ +
+🆕 Atualização 30/06/2026 — Mapa, dashboard clicável, validação por veículo e UX da sidebar + +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 ``, 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). + +
+ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index d4bb843..c0c4742 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -13,7 +13,7 @@ class ConsolidacaoEntregasController < ApplicationController def validar authorize @consolidacao, :show? @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 + # insucesso, pois o motorista foi ao local e a entrega precisa constar. @@ -24,7 +24,7 @@ class ConsolidacaoEntregasController < ApplicationController .do_motorista(@motorista) @entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.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) # 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: # quando um carro está selecionado, a barra reflete só aquele carro. @veiculos_status = veiculos_status(@motorista) - if @veiculo - atual = @veiculos_status.find { |v| v[:vehicle] == @veiculo } - @total_entregas = atual ? atual[:eligible] : 0 - @classificadas = atual ? atual[:classificadas] : 0 + if @veiculos_sel.any? + sel = @veiculos_status.select { |v| @veiculos_sel.include?(v[:vehicle]) } + @total_entregas = sel.sum { |v| v[:eligible] } + @classificadas = sel.sum { |v| v[:classificadas] } else @total_entregas = @consolidacao.entregas_elegiveis_count(@motorista) @classificadas = @consolidacao.classificadas_count(@motorista) @@ -97,7 +97,7 @@ class ConsolidacaoEntregasController < ApplicationController respond_to do |format| 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] ) end @@ -116,8 +116,8 @@ class ConsolidacaoEntregasController < ApplicationController return render_nao_elegivel unless motorista_na_consolidacao?(motorista) - # Recorte por veículo (fechar um carro de cada vez). Sem veículo = todos. - elegiveis = tracking_ids_elegiveis(motorista, params[:vehicle].presence) + # Recorte por veículo(s) selecionado(s). Sem veículo = todos. + elegiveis = tracking_ids_elegiveis(motorista, params[:vehicle]) # 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. tracking_ids = tracking_ids.empty? ? elegiveis : (tracking_ids & elegiveis) @@ -148,10 +148,10 @@ class ConsolidacaoEntregasController < ApplicationController qtd: tracking_ids.size, motorista: motorista }) 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 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}." end end @@ -347,9 +347,20 @@ class ConsolidacaoEntregasController < ApplicationController rel.pluck(:tracking_id) end - # Recorte por um único veículo (trata o grupo "sem veículo"). - def filtrar_por_veiculo(rel, veiculo) - veiculo == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.da_veiculo(veiculo) + # Normaliza o parâmetro de veículo (string única OU array vindo de vehicle[]) + # numa lista limpa de selecionados. + 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 # Status "fechado" por veículo do motorista: lista de @@ -447,21 +458,28 @@ class ConsolidacaoEntregasController < ApplicationController rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id) end - # `vehicle` (opcional): quando presente, `classificadas_escopo` reflete só aquele - # carro (para a barra de progresso por veículo). `veiculos_status` acompanha o - # payload para o front atualizar os chips sem recarregar. - def resumo_json(motorista, vehicle: nil) + # `vehicles` (opcional, string ou array): quando presente, TODO o resumo + # (barra de progresso, contagem por pilar e valor total) reflete apenas o(s) + # veículo(s) selecionado(s). `veiculos_status` acompanha o payload para o front + # atualizar os chips sem recarregar. + def resumo_json(motorista, vehicles: nil) + lista = veiculos_param(vehicles) classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista) status = veiculos_status(motorista) - classificadas = classificacoes.distinct.count(:tracking_id) - escopo = if vehicle.present? - (status.find { |v| v[:vehicle] == vehicle } || {})[:classificadas] || 0 - else - classificadas - end + classificadas_motorista = classificacoes.distinct.count(:tracking_id) + + if lista.any? + # Recorta as classificações às NFs dos veículos selecionados. + ids = tracking_ids_elegiveis(motorista, lista) + 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, - classificadas: classificadas, + classificadas: classificadas_motorista, classificadas_escopo: escopo, manuais: contar_manuais(classificacoes.where(manual: true)), por_tipo: classificacoes.group(:tipo).sum(:quantidade), diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb new file mode 100644 index 0000000..930cb5c --- /dev/null +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -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 diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js index 1fce06e..aab3bc9 100644 --- a/app/javascript/controllers/validacao_controller.js +++ b/app/javascript/controllers/validacao_controller.js @@ -6,7 +6,8 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ - "linha", "checkbox", "selecionarTodos", + "linha", "checkbox", "selecionarTodos", "busca", "buscaVazia", + "resumo", "listaCol", "btnResumoLabel", "toggles", "barra", "barraManuais", "percentual", "chipVeiculo", "contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo", "qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria", @@ -18,7 +19,7 @@ export default class extends Controller { massaUrl: String, // POST classificar_em_massa motorista: String, 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 } @@ -51,7 +52,7 @@ export default class extends Controller { tracking_id: tracking, tipo: tipo, motorista: this.motoristaValue, - vehicle: this.veiculoValue, + vehicle: this.veiculosValue, valor: valor }) // resp.ativo indica se o pilar ficou ligado ou desligado (toggle) @@ -64,10 +65,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) { 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? @@ -101,7 +130,7 @@ export default class extends Controller { const resp = await this.post(this.massaUrlValue, { tipo: tipo, motorista: this.motoristaValue, - vehicle: this.veiculoValue, + vehicle: this.veiculosValue, tracking_ids: ids, acao: remover ? "remover" : "adicionar", valor: valor @@ -212,12 +241,13 @@ export default class extends Controller { if (total) total.textContent = dados.eligible if (check) check.textContent = dados.fechado ? "✓" : "" - // Estado de cor determinístico: verde (fechado) > laranja (selecionado) > neutro. + // Estado de cor determinístico: laranja (selecionado) > verde (fechado) > neutro. + // Seleção tem prioridade para o usuário sempre enxergar quais carros somam. chip.classList.remove(...VERDE, ...LARANJA, ...NEUTRO) - if (dados.fechado) { - chip.classList.add(...VERDE) - } else if (chip.dataset.vehicle === this.veiculoValue) { + if (this.veiculosValue.includes(chip.dataset.vehicle)) { chip.classList.add(...LARANJA) + } else if (dados.fechado) { + chip.classList.add(...VERDE) } else { chip.classList.add(...NEUTRO) } diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb index 76a74ec..d3591de 100644 --- a/app/policies/dashboard_policy.rb +++ b/app/policies/dashboard_policy.rb @@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard) def index? user.admin? || user.gerente? end + + # Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML). + def operacoes? + !user.motorista? + end end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb new file mode 100644 index 0000000..97c2834 --- /dev/null +++ b/app/services/analytics/operacao_metricas.rb @@ -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 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 diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index a0eb4d7..8aa148a 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -6,14 +6,14 @@ data-validacao-massa-url-value="<%= classificar_em_massa_consolidacao_consolidacao_entregas_path(@consolidacao) %>" data-validacao-motorista-value="<%= @motorista %>" 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] %>"> <%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
<%# Header %> -
+
<%= 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]' %>
@@ -28,10 +28,10 @@
<%# Barra de progresso (reflete o veículo selecionado, se houver) %> -
+
- <% if @veiculo.present? %>🚚 <%= @veiculo %> — <% end %> + <% if @veiculos_sel.any? %>🚚 <%= @veiculos_sel.join(', ') %> — <% end %> <%= @classificadas %> de <%= @total_entregas %> entregas classificadas · @@ -49,25 +49,27 @@ <%# Fechar por veículo — filtra a tela por carro e mostra quais já estão fechados. Só aparece quando o motorista usou mais de um veículo no período. %> <% if @veiculos_status.size > 1 %> -
+
🚗 Fechar por veículo: - clique para filtrar e classificar um carro de cada vez + clique para filtrar/somar um ou vários carros (clique de novo p/ tirar)
- <%# Chip "Todos" %> + <%# Chip "Todos" — limpa a seleção (mostra todos os carros) %> <%= 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 - #{@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 <% end %> <% @veiculos_status.each do |v| %> - <% selecionado = (@veiculo == v[:vehicle]) %> - <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: v[:vehicle]), + <% selecionado = @veiculos_sel.include?(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]]) %> + <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: nova_sel.presence), 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 - #{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 %> + #{selecionado ? 'border-orange-500 text-orange-400 bg-orange-500/10' : (v[:fechado] ? 'border-green-500 text-green-400 bg-green-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500')}" do %> <%= v[:vehicle] %> <%= v[:classificadas] %>/<%= v[:eligible] %> @@ -80,7 +82,7 @@ <% end %> <%# Ações em massa + filtro %> -
+
-
+
+ <%# Oculta/mostra o resumo lateral (mais largura para a lista) %> + <%= 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'), class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
@@ -114,7 +121,7 @@ entrega e entrega de termo) num único botão com menu suspenso. Os modais e controllers existentes são reaproveitados; só o gatilho mudou. %> <% unless @consolidacao.finalizada? && current_user.operador? %> -
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %> -
+
<%# ── Lista de entregas (3 colunas) — área rolável ──── %> -
+
+ <%# 🔍 Busca na listagem — filtra os cards por NF, local, endereço ou veículo %> +
+
+ 🔍 + +
+ +
<% if @entregas.any? %> <% @entregas.each do |e| %> <% tipos_ativos = (@classificacoes[e.tracking_id] || []).map(&:tipo) %>
<%# Checkbox seleção em massa %> @@ -318,7 +336,7 @@
<%# ── Resumo lateral (1 coluna) — rola junto se precisar ─────── %> -
+

📊 Resumo

diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb index e365a80..f6c2027 100644 --- a/app/views/layouts/_navbar.html.erb +++ b/app/views/layouts/_navbar.html.erb @@ -37,6 +37,7 @@ <%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %> <% unless current_user.motorista? %> <%= nav_link_to '📊 Dashboard', dashboard_path %> + <%= nav_link_to '📈 Operações', operacoes_dashboard_path %> <% end %> <%# Consolidações — só quem pode consolidar (exclui o externo) %> @@ -70,6 +71,21 @@
+<%# 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. %> + + <%# Topbar mobile %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5d423b9..fe4fb27 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -55,10 +55,29 @@ animation: spin 0.8s linear infinite; } @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 . */ + @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; } + } <%= javascript_importmap_tags %> <%= yield :head %> + + <%# Aplica o estado recolhido da sidebar antes de pintar (evita "piscar") %> + diff --git a/app/views/operacoes_dashboard/_comparativo.html.erb b/app/views/operacoes_dashboard/_comparativo.html.erb new file mode 100644 index 0000000..73f523e --- /dev/null +++ b/app/views/operacoes_dashboard/_comparativo.html.erb @@ -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: '%' } + ] +%> +
+
+

Comparativo

+
+ ● A · <%= label_a %> (<%= a.total %>) + ● B · <%= label_b %> (<%= b.total %>) +
+
+ + <%# Tabela comparativa (verde = melhor desempenho na linha) %> +
+ + + + + + + + + + + <% 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] %> + + + + + + + <% end %> + +
MétricaABΔ (A−B)
<%= l[:nome] %><%= l[:a] %><%= l[:suf] %><%= l[:b] %><%= l[:suf] %><%= delta > 0 ? "+#{delta}" : delta %><%= l[:suf] %>
+
+ + <%# Barras agrupadas A x B %> +
+ +
+
diff --git a/app/views/operacoes_dashboard/_mapa.html.erb b/app/views/operacoes_dashboard/_mapa.html.erb new file mode 100644 index 0000000..301f472 --- /dev/null +++ b/app/views/operacoes_dashboard/_mapa.html.erb @@ -0,0 +1,36 @@ +<%# locals: metricas (Analytics::OperacaoMetricas) %> +<% heat = metricas.pontos_mapa %> +<% pontos = metricas.pontos_detalhados %> +
+
+

Mapa de Entregas

+
+
+ + +
+
+ + +
+ +
+
+ + <% if heat.empty? && pontos.empty? %> +

Sem coordenadas para exibir no período.

+ <% else %> +
+

+ 🔥 Calor mostra a densidade · 📍 Pontos mostra cada entrega (clique no marcador para detalhes; coordenada de check-out). +

+ <% end %> +
diff --git a/app/views/operacoes_dashboard/_painel.html.erb b/app/views/operacoes_dashboard/_painel.html.erb new file mode 100644 index 0000000..c24ca9c --- /dev/null +++ b/app/views/operacoes_dashboard/_painel.html.erb @@ -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 %> + +
+ <% if titulo.present? %> +
+

<%= titulo %>

+ + <%= metricas.total %> entregas<% if metricas.data_inicio %> · <%= metricas.data_inicio.strftime('%d/%m/%Y') %>–<%= metricas.data_fim.strftime('%d/%m/%Y') %><% end %> + +
+ <% end %> + + <%# ── KPIs ─────────────────────────────────────────────── %> + <% pct_total = ->(parte) { metricas.total.zero? ? 0 : (parte * 100.0 / metricas.total).round(1) } %> +
+
+

📦 Total de Entregas

+

<%= metricas.total %>

+ +
+
+

✅ Total de Sucesso

+

<%= metricas.sucesso %>

+ +
+
+

❌ Total de Recusas

+

<%= metricas.recusas %>

+ +
+
+

⏳ Entregas Pendentes

+

<%= metricas.pendentes %>

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

Insucessos %

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

Sem dados no período.

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

Índices de Falha

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

Por Status

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

Notas fiscais por Motorista

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

Entregas por STS (Unidade)

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

Entregas por Dia

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

Sem dados no período.

+ <% else %> +
+ +
+ <% end %> +
+
diff --git a/app/views/operacoes_dashboard/_tabela_simples.html.erb b/app/views/operacoes_dashboard/_tabela_simples.html.erb new file mode 100644 index 0000000..985e542 --- /dev/null +++ b/app/views/operacoes_dashboard/_tabela_simples.html.erb @@ -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? %> +

<%= vazio %>

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

Dashboard de Operações

+

+ <% 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') %> +

+
+ + <%# 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' %> +
+
+ 📅 + +
+
+ <% 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 %> +
+
+ <% end %> +
+ + <%# ── Abas de modo ──────────────────────────────────────── %> + <% modos = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global', 'comparar' => '⚖️ Comparar' } %> +
+ <% modos.each do |m, label| %> + <% ativo = @modo == m %> + <%= link_to label, + operacoes_dashboard_path(ctx_ops.merge(modo: m, inicio: ini_iso, fim: fim_iso)), + data: { turbo: false }, + class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %> + <% end %> +
+ + <% if @tabelas_validas.blank? %> +
+ Nenhuma operação encontrada no banco (tabelas gade_entregas_*). +
+ <% else %> + + <%# ── Filtros ativos (cross-filter por clique nas tabelas) ── %> + <% if @chips.any? && %w[operacao global].include?(@modo) %> +
+ Filtros ativos: + <% @chips.each do |chip| %> + + <%= chip[:rotulo] %>: <%= chip[:display] || chip[:valor] %> + <%= link_to '✕', operacoes_dashboard_path(request.query_parameters.except(chip[:param])), + data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %> + + <% 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' %> +
+ <% end %> + + <%# ── Seletor de operação por modo ───────────────────── %> + <% if @modo == 'operacao' %> +
+ + + + + +
+ + <% if @metricas %> + <%= render 'painel', metricas: @metricas, filtravel: true %> + <%= render 'mapa', metricas: @metricas %> + <% end %> + + <% elsif @modo == 'global' %> +
+ 🌐 Visão Global — todas as + <%= @tabelas_validas.size %> operações agregadas no período. +
+ <% if @metricas %> + <%= render 'painel', metricas: @metricas, filtravel: true %> + <%= render 'mapa', metricas: @metricas %> + <% end %> + + <% else # comparar %> +
+ + + +
+ + +
+
+ + +
+
+ + <%# 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) %> +
+ + 🔎 Ver detalhe completo de cada operação + +
+
+ <% if @metricas_a %> + <%= render 'painel', metricas: @metricas_a, titulo: "A · #{@metricas_a.operacoes_label}" %> + <% else %> +

Selecione a operação A.

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

Selecione a operação B.

+ <% end %> +
+
+
+ <% end %> + <% end %> +
+ +<%# ── Scripts: flatpickr, Chart.js, Leaflet ───────────────── %> + + + + + + + diff --git a/config/routes.rb b/config/routes.rb index f617ebf..7bc0127 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,9 @@ Rails.application.routes.draw do get '/dashboard', to: 'dashboard#index', as: :dashboard get '/dashboard/relatorio_financeiro', to: 'dashboard#relatorio_financeiro', as: :dashboard_relatorio_financeiro + # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) + get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard + # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard diff --git a/spec/requests/operacoes_dashboard_spec.rb b/spec/requests/operacoes_dashboard_spec.rb new file mode 100644 index 0000000..8b4d931 --- /dev/null +++ b/spec/requests/operacoes_dashboard_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +RSpec.describe 'Dashboard de Operações', type: :request do + it 'redireciona usuário não autenticado para o login' do + get operacoes_dashboard_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'bloqueia motorista (Pundit redireciona para a raiz)' do + sign_in create(:motorista) + get operacoes_dashboard_path + expect(response).to redirect_to(root_path) + end + + it 'permite operador (renderiza a página)' do + sign_in create(:operador) + get operacoes_dashboard_path + expect(response).to have_http_status(:ok) + end + + it 'permite gerente' do + sign_in create(:gerente) + get operacoes_dashboard_path(modo: 'global') + expect(response).to have_http_status(:ok) + end +end diff --git a/spec/services/analytics/operacao_metricas_spec.rb b/spec/services/analytics/operacao_metricas_spec.rb new file mode 100644 index 0000000..ae1c4a7 --- /dev/null +++ b/spec/services/analytics/operacao_metricas_spec.rb @@ -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