diff --git a/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx new file mode 100644 index 0000000..9cb2e71 Binary files /dev/null and b/Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx differ diff --git a/Imagens para implantação /Imagem colada (2).png b/Imagens para implantação /Imagem colada (2).png new file mode 100644 index 0000000..db3345f Binary files /dev/null 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 new file mode 100644 index 0000000..6b20174 Binary files /dev/null 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 new file mode 100644 index 0000000..69627c3 Binary files /dev/null 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 new file mode 100644 index 0000000..1b3b70c Binary files /dev/null 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 new file mode 100644 index 0000000..0fdef96 Binary files /dev/null and b/Imagens para implantação /Imagem colada (6).png differ diff --git a/README.md b/README.md index 4c511e6..b84f4c8 100644 --- a/README.md +++ b/README.md @@ -1366,3 +1366,185 @@ docker compose up -d --build +--- + +
+🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026) + +Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo +do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base +técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV): +cada item vira uma fase própria quando for priorizado. + +### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha) +`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute" +criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha +(título `NF {nf} - {nome}`, janelas 08:00–18:00, lat/long do mês anterior, etc.). +> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.** +- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica). +- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga". + +### 2. 🧾 Relatório de comprovantes de entrega (POD) +`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada +entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`). +Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi"). + +### 3. ⚡ Webhooks — painel em tempo real +`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`, +`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza +**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura, +idempotência) e URL pública estável. + +### 4. 📱 Aviso ao paciente via WhatsApp +Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`): +"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE** +(motivo real da lista de observations). + +### 5. 📺 Monitor de rotas ao vivo +`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada +veículo, % concluído e atrasos (complementa o dashboard, que olha o passado). + +### 6. 👷 Sincronização de motoristas/veículos +`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema +(hoje o vínculo é o nome digitado — sujeito a divergência). + +### 7. 📊 Datamart e 🏷️ tags/skills +Export paginado de analytics para enriquecer dashboards; tags/skills para classificar +visitas por tipo de material. + +
+ +--- + +
+🆕 Atualização 13/07/2026 — Planilha da Operação (página + Excel do cliente), dashboard e correções + +### 📋 Planilha da Operação — página própria (`/dashboard/operacoes/planilha`) +Tabela **espelho da planilha da operação** (uma linha por NF) para pesquisar e acompanhar +ocorrências/status, acessada pelo botão **"📋 Planilha da operação"** no Dashboard de Operações +(o botão leva junto o contexto atual: operação/modo Global, período e cross-filters ativos). +- Colunas: Data, NF, Destinatário (endereço no tooltip), Unidade STS, Motorista, Veículo, + **Resultado** (badge Entregue/Falha/Pendente), **Ocorrência** (motivo do insucesso) e status + gade (RECORRENTE/NOVO). No modo Global aparece também a coluna Operação. +- **Busca textual server-side** (param `q`, qualquer campo) + **paginação server-side** + (param `pg`, 15/página, janela `« 1 … 7 8 9 … 42 »`) — aguenta as milhares de linhas do Global. +- Alternância 🏥 Operação / 🌐 Global, seletor de operação (auto-submit) e chips removíveis dos + filtros herdados. Reusa as linhas do `Analytics::OperacaoMetricas` (novo método `#buscar`) — + **nenhuma query nova**. +- Arquivos: rota `operacoes_planilha`, action `planilha` + `montar_tabela_espelho` + (`OperacoesDashboardController`), views `planilha.html.erb` + `_espelho.html.erb`. + +### ⬇️ Download do Excel do cliente — planilha Entregas preenchida +Botão **"⬇️ Baixar Excel preenchido"** na página da planilha: gera o `.xlsx` no formato do modelo +**"Entregas SUDESTE MM.AAAA_FINAL"** com as **3 abas, 1:1** — automatiza o preenchimento que era +feito manualmente para entregar ao cliente. +- **RESUMO** — mês, Total Previsto/Realizado/Performance e os quadros **EMAD** e **UBS** por + coordenadoria (CRS fixas do modelo) com **motivos de "Não Entregue"** (Óbito, Responsável + Ausente, Endereço não localizado, Recusa, Paciente não reside, Outros) classificados a partir + da `observation` do rastreio; linhas Total e Performance %. +- **ENTREGAS** — colunas A–V da própria tabela `gade_entregas_*` (ordem física de importação = + ordem da planilha original) + **W–Z preenchidas pelo rastreio**: STATUS, ENTREGA (Sim/Não), + DATA OCORRÊNCIA (data real do checkout) e OCORRÊNCIA — último status de cada NF (mesma CTE + `ROW_NUMBER` dos painéis). +- **SimpliRoute** — dump cru do rastreio com as **46 colunas exatas** do export original + (todas as visitas das NFs da operação, incluindo repetidas). +- **Estilo idêntico ao modelo** (cores extraídas do `styles.xml` do próprio arquivo): cabeçalho + ENTREGAS azul `1155CC` (A–T) + vermelho `C00000` (U–Z), quadros do RESUMO em + `002060`/`0070C0`/`C00000`/`A5A5A5`, status verde/laranja/azul, títulos 16pt, performance em + itálico %, cabeçalhos mesclados. Validado offline com caxlsx + LibreOffice. +- Arquivos: `Analytics::PlanilhaEntregas` (dados) + `Analytics::PlanilhaEntregasXlsx` (binário, + caxlsx), rota `operacoes_planilha_baixar`, action `baixar_planilha`. +- ⚠️ **Ressalvas:** nomes de coluna das tabelas `gade_entregas_*`/rastreio foram deduzidos com + fallback seguro (coluna inexistente sai **vazia**, sem quebrar o layout) — conferir o primeiro + download real e ajustar `COLUNAS_GADE`/`RASTREIO_COLUNAS` se algo sair em branco. Os 2 + **gráficos embutidos** do RESUMO não são replicados. "Conferência Documentos" é controle + manual (sai "A iniciar"). + +### 📊 Dashboard financeiro +- **Paginação nas tabelas de pagamentos** ("realizados" e "pendentes"): 10 linhas/página com + abas e contador ("1–10 de 47"), client-side (função genérica `paginarTabela`); some com ≤10 itens. +- **Novo gráfico "Pagamentos por dia"** abaixo da "Evolução do custo": barras verdes com o valor + pago por data de pagamento (`pago_em`) + linha tracejada do **acumulado** — o fluxo de caixa, + que não existia em nenhuma tela (`build_grafico_pagamentos`). Quando não há pagamento no + período mostra estado vazio (💸 + valor aguardando pagamento) em vez de gráfico zerado. + > Iterações descartadas no caminho: "Entregas por dia" (redundante — valor = qtd × preço fixo) + > e "Resultado por dia" (já existe na tela de Operações). +- O card dos gráficos virou `flex-col` e preenche toda a altura ao lado do ranking de motoristas + (sumiu o vazio embaixo da linha). + +### 🔴 Correções +- **"Rodapé quebrado" no celular:** era uma **barra de rolagem horizontal** (laranja) colada no + rodapé — a legenda dos gráficos não quebrava linha e estourava ~37px a largura em telas + <~430px. ✅ Cabeçalhos/legendas dos gráficos com `flex-wrap` + **`overflow-x-hidden` no + ``** (rede de segurança; tabelas largas continuam rolando nos próprios contêineres + `overflow-x-auto`). + +### 📌 Padrões (reforçados) +- Tabelas externas sempre read-only, nome de tabela via whitelist + `quote_table_name`, colunas + em whitelist fixa com fallback NULL (padrão `OperacaoMetricas`/`PlanilhaCarga`). +- Excel com `caxlsx` (mesmo padrão da planilha de carga SimpliRoute): serviço de linhas + + serviço de binário + `send_data` no controller. + +> **Sem migration.** Deploy normal; em produção reiniciar o Puma após o deploy. + +
+ +--- + +
+🆕 Atualização 15/07/2026 — Entrega de Termo Especial (novo card de preço + lançamento com dois tipos) + +O negócio passou a diferenciar **termo normal** e **termo especial**, cada um com seu preço. +O modal de lançamento agora permite informar **quantos termos de cada tipo** o motorista +entregou, num único envio. + +### 🆕 Funcionalidades +- **Novo card de preço "📋 Entrega de Termo Especial"** em **Admin → Configurações** + (chave `preco_termo_especial`, moeda). Criado com **R$ 0,00** — definir o valor real no + card antes de usar. O card aparece automático (a view itera todas as configs). +- **Novo pilar `tipo: termo_especial`** (enum 6, cor **ciano**, label "Entrega de Termo + Especial") — mesmo comportamento do termo normal: lote numa linha (`quantidade` × preço), + `manual: true`, `tracking_id` sintético `TERMO-`. +- **Modal "📄 Entrega de termo" com dois steppers** lado a lado — "📄 Termo Normal" + (inicia em 1) e "📋 Termo Especial" (inicia em 0), cada um exibindo seu preço unitário. + Um clique em "Adicionar termos ✓" lança os dois lotes de uma vez (exige ao menos 1 termo + no total; mínimo 0 em cada stepper). +- **Exibição em todas as telas**: linha "Termo Especial" no painel 📊 Resumo do Passo 2, + card próprio no Revisar, badge ciano "Entrega de Termo Especial ×N" nos apontamentos + manuais (show) e nos PDFs (extrato, relatório do motorista e financeiro — automático, + iteram `TIPO_CORES`). + +### ⚙️ Como funciona (técnico) +- **Migration** (rodar `db:migrate`): `20260715000001_add_preco_termo_especial_configuracao` + — cria a config `preco_termo_especial` (idempotente) + seed correspondente. +- `Configuracao`: chave nova em `CHAVES`/`CHAVES_MOEDA`/`LABELS`/`ICONES`/`mapa_de_precos` + + helper `preco_termo_especial`. +- `Consolidacao#adicionar_termos!` ganhou o parâmetro **`tipo:`** (default `'termo'`, + validado em `termo`/`termo_especial`); o preço vem de + `ConsolidacaoEntrega.valor_para(tipo)`. +- `ConsolidacaoEntregasController#apontar_termo` lê `quantidade` (normal) + + `quantidade_especial` e cria **um lote por tipo** dentro de uma transação, auditando as + duas quantidades. `contar_manuais` soma a `quantidade` dos **dois** tipos de termo; os + agregados `group(:tipo).sum(:quantidade)` já funcionavam sem mudança. +- Stimulus: `apontamento_termo_controller.js` com targets `quantidade`/`quantidadeEspecial` + e ações `maisEspecial`/`menosEspecial`; `validacao_controller.js` com target opcional + `qtdTermoEspecial`. + +### 📂 Arquivos +``` +db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb (nova) +app/models/configuracao.rb · consolidacao.rb · consolidacao_entrega.rb +app/controllers/consolidacao_entregas_controller.rb +app/javascript/controllers/apontamento_termo_controller.js · validacao_controller.js +app/views/consolidacao_entregas/validar.html.erb · revisar.html.erb +app/views/consolidacoes/show.html.erb +app/services/pdf/relatorio_motorista_pdf.rb +db/seeds.rb · spec/models/configuracao_spec.rb +``` + +```bash +docker-compose exec app bundle exec rails db:migrate +``` + +
+ diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index a0092d0..df04cc8 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -64,7 +64,8 @@ class ConsolidacaoEntregasController < ApplicationController bonus: Configuracao.preco_bonus, desconto: Configuracao.preco_desconto, extraordinaria: Configuracao.preco_extraordinaria, - termo: Configuracao.preco_termo + termo: Configuracao.preco_termo, + termo_especial: Configuracao.preco_termo_especial } end @@ -267,25 +268,38 @@ class ConsolidacaoEntregasController < ApplicationController end # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_termo - # ENTREGA DE TERMO — lança um lote de N termos para o motorista. Não há NF nem - # código de rastreio: só a quantidade, a um preço fixo configurável. + # ENTREGA DE TERMO — lança lotes de termos para o motorista. Não há NF nem + # código de rastreio: só as quantidades (normal e especial), cada tipo a um + # preço fixo configurável. def apontar_termo authorize @consolidacao, :update? - motorista = @consolidacao.adicionar_termos!( - motorista: params[:motorista].to_s.strip, - quantidade: params[:quantidade], - vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3) - user: current_user - ) + qtd_normal = params[:quantidade].to_i + qtd_especial = params[:quantidade_especial].to_i + raise ArgumentError, 'Informe ao menos um termo (normal ou especial).' if qtd_normal < 1 && qtd_especial < 1 + + motorista = nil + ActiveRecord::Base.transaction do + { 'termo' => qtd_normal, 'termo_especial' => qtd_especial }.each do |tipo, qtd| + next if qtd < 1 + + motorista = @consolidacao.adicionar_termos!( + motorista: params[:motorista].to_s.strip, + quantidade: qtd, + tipo: tipo, + vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3) + user: current_user + ) + end + end auditar!(:editar, @consolidacao, - dados_novos: { entrega_termo: params[:quantidade].to_i, motorista: motorista }) + dados_novos: { entrega_termo: qtd_normal, entrega_termo_especial: qtd_especial, motorista: motorista }) respond_to do |format| format.json { render json: resumo_json(motorista).merge(motorista: motorista) } format.html do redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), - notice: "#{params[:quantidade].to_i} entrega(s) de termo adicionada(s) para #{motorista}." + notice: "#{qtd_normal + qtd_especial} entrega(s) de termo adicionada(s) para #{motorista}." end end rescue ArgumentError => e @@ -534,8 +548,9 @@ class ConsolidacaoEntregasController < ApplicationController # Conta lançamentos manuais como ENTREGAS: cada termo vale `quantidade` (lote # numa só linha); os demais apontamentos manuais valem 1 por nota (tracking). def contar_manuais(rel_manual) - rel_manual.termo.sum(:quantidade) + - rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id) + tipos_termo = ConsolidacaoEntrega.tipos.values_at(:termo, :termo_especial) + rel_manual.where(tipo: tipos_termo).sum(:quantidade) + + rel_manual.where.not(tipo: tipos_termo).distinct.count(:tracking_id) end # `vehicles` (opcional, string ou array): quando presente, TODO o resumo diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 78bf621..e1f349c 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -165,6 +165,9 @@ class DashboardController < ApplicationController @pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc) @pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc) + + # Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico + @grafico_pagamentos_dia = build_grafico_pagamentos(pagos) end # Visão financeira (custo) das consolidações finalizadas do período, já com o @@ -241,6 +244,28 @@ class DashboardController < ApplicationController { labels: labels, valores: valores, qtds: qtds } end + # Valor pago por dia (pela data do pagamento) + acumulado no período, + # para o gráfico de fluxo de pagamentos. + def build_grafico_pagamentos(pagos) + dias = (@periodo_inicio..@periodo_fim).to_a + + if dias.size <= MAX_DIAS_GRAFICO + soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s) + labels = dias.map { |d| d.strftime('%d/%m') } + chaves = dias.map(&:to_s) + else + soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total) + meses = meses_no_periodo + labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') } + chaves = meses + end + + valores = chaves.map { |k| soma[k].to_f.round(2) } + acumulado = 0 + { labels: labels, valores: valores, + acumulado: valores.map { |v| (acumulado += v).round(2) } } + end + # Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive. def meses_no_periodo meses = [] diff --git a/app/controllers/operacoes_dashboard_controller.rb b/app/controllers/operacoes_dashboard_controller.rb index 930cb5c..6e79101 100644 --- a/app/controllers/operacoes_dashboard_controller.rb +++ b/app/controllers/operacoes_dashboard_controller.rb @@ -7,6 +7,11 @@ class OperacoesDashboardController < ApplicationController MODOS = %w[operacao global comparar].freeze + # Linhas por página da tabela espelho (planilha da operação) + ESPELHO_POR_PAGINA = 15 + + XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze + def index authorize :dashboard, :operacoes? @@ -34,8 +39,67 @@ class OperacoesDashboardController < ApplicationController end end + # Planilha da operação (tabela espelho): página própria com busca e paginação. + # Aceita os mesmos parâmetros do index (operacao/modo global/período/cross- + # filters), então o botão no dashboard preserva o contexto atual. + def planilha + authorize :dashboard, :operacoes? + + @periodo_inicio, @periodo_fim = periodo_selecionado + @modo = params[:modo] == 'global' ? 'global' : 'operacao' + @operacoes_agrupadas = Operacao.agrupadas_por_mes + @tabelas_validas = Operacao.nomes_validos + @filtros, @chips = cross_filtros + @data_filtro = parse_data(params[:f_data]) + + if @modo == 'global' + @metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro) + else + @operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first + @metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao + end + + montar_tabela_espelho if @metricas + end + + # GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_... + # Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado + # do rastreio — o modelo que é entregue ao cliente. + def baixar_planilha + authorize :dashboard, :operacoes? + + tabela = params[:operacao].to_s + unless Operacao.valida?(tabela) + return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.') + end + + planilha = Analytics::PlanilhaEntregas.new(tabela) + if planilha.linhas.empty? + return redirect_to(operacoes_planilha_path(operacao: tabela), + alert: "A operação #{planilha.label} não tem registros.") + end + + send_data Analytics::PlanilhaEntregasXlsx.new(planilha).gerar, + filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx", + type: XLSX_MIME, + disposition: 'attachment' + end + private + # Tabela espelho da planilha da operação: busca textual (q) + paginação (pg) + # sobre as linhas já cross-filtradas. Ordena pelas mais recentes (checkout; + # sem checkout vai para o fim). + def montar_tabela_espelho + @busca = params[:q].to_s.strip + linhas = @metricas.buscar(@busca).sort_by { |r| r['checkout'].to_s }.reverse + + @espelho_total = linhas.size + @espelho_paginas = [(@espelho_total / ESPELHO_POR_PAGINA.to_f).ceil, 1].max + @espelho_pagina = params[:pg].to_i.clamp(1, @espelho_paginas) + @espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || [] + end + def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil) Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data) end diff --git a/app/javascript/controllers/apontamento_termo_controller.js b/app/javascript/controllers/apontamento_termo_controller.js index 260bd55..5068d0e 100644 --- a/app/javascript/controllers/apontamento_termo_controller.js +++ b/app/javascript/controllers/apontamento_termo_controller.js @@ -1,17 +1,18 @@ // app/javascript/controllers/apontamento_termo_controller.js -// Stimulus — Entrega de termo. Lança um lote de N termos para o motorista da tela. -// Entrega sem NF nem código de rastreio: só a quantidade (stepper − N +), a um -// preço fixo configurável. +// Stimulus — Entrega de termo. Lança lotes de termos (normal e/ou especial) para +// o motorista da tela. Entrega sem NF nem código de rastreio: só as quantidades +// (um stepper − N + por tipo), cada tipo a um preço fixo configurável. import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["modal", "quantidade", "erro", "veiculo"] + static targets = ["modal", "quantidade", "quantidadeEspecial", "erro", "veiculo"] static values = { apontarTermoUrl: String, motorista: String } abrir() { this.erroTarget.classList.add("hidden") this.quantidadeTarget.value = "1" + this.quantidadeEspecialTarget.value = "0" this.modalTarget.classList.remove("hidden") } @@ -24,26 +25,39 @@ export default class extends Controller { if (event.target === this.modalTarget) this.fechar() } - // Stepper + // Steppers (mínimo 0 em ambos; o envio exige ao menos 1 no total) mais() { - this.quantidadeTarget.value = String(this.qtdAtual() + 1) + this.quantidadeTarget.value = String(this.qtdAtual(this.quantidadeTarget) + 1) } menos() { - this.quantidadeTarget.value = String(Math.max(1, this.qtdAtual() - 1)) + this.quantidadeTarget.value = String(Math.max(0, this.qtdAtual(this.quantidadeTarget) - 1)) } - qtdAtual() { - const n = parseInt(this.quantidadeTarget.value, 10) - return Number.isFinite(n) && n > 0 ? n : 1 + maisEspecial() { + this.quantidadeEspecialTarget.value = String(this.qtdAtual(this.quantidadeEspecialTarget) + 1) + } + + menosEspecial() { + this.quantidadeEspecialTarget.value = String(Math.max(0, this.qtdAtual(this.quantidadeEspecialTarget) - 1)) + } + + qtdAtual(alvo) { + const n = parseInt(alvo.value, 10) + return Number.isFinite(n) && n > 0 ? n : 0 } async adicionar(event) { event?.preventDefault() - const quantidade = this.qtdAtual() - const motorista = this.motoristaValue.trim() + const quantidade = this.qtdAtual(this.quantidadeTarget) + const quantidadeEspecial = this.qtdAtual(this.quantidadeEspecialTarget) + const motorista = this.motoristaValue.trim() if (!motorista) { this.mostrarErro("Motorista não identificado."); return } + if (quantidade + quantidadeEspecial < 1) { + this.mostrarErro("Informe ao menos um termo (normal ou especial).") + return + } const btn = event.currentTarget btn.disabled = true @@ -56,9 +70,10 @@ export default class extends Controller { "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify({ - motorista: motorista, - quantidade: quantidade, - vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : "" + motorista: motorista, + quantidade: quantidade, + quantidade_especial: quantidadeEspecial, + vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : "" }) }) const data = await resp.json() diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js index a8c3c79..e75c130 100644 --- a/app/javascript/controllers/validacao_controller.js +++ b/app/javascript/controllers/validacao_controller.js @@ -12,7 +12,7 @@ export default class extends Controller { "consolidarBtn", "consolidarHint", "contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo", "qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria", - "qtdTermo", "qtdManuais", "valorTotal" + "qtdTermo", "qtdTermoEspecial", "qtdManuais", "valorTotal" ] static values = { @@ -187,6 +187,8 @@ export default class extends Controller { this.qtdExtraordinariaTarget.textContent = porTipo["extraordinaria"] || 0 if (this.hasQtdTermoTarget) this.qtdTermoTarget.textContent = porTipo["termo"] || 0 + if (this.hasQtdTermoEspecialTarget) + this.qtdTermoEspecialTarget.textContent = porTipo["termo_especial"] || 0 const valor = dados.valor_total || 0 this.valorTotalTarget.textContent = diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index 56ae369..d42932a 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -10,6 +10,7 @@ class Configuracao < ApplicationRecord preco_desconto preco_extraordinaria preco_termo + preco_termo_especial notificacao_whatsapp notificacao_email empresa_nome @@ -23,6 +24,7 @@ class Configuracao < ApplicationRecord preco_desconto preco_extraordinaria preco_termo + preco_termo_especial ].freeze validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true @@ -58,6 +60,10 @@ class Configuracao < ApplicationRecord valor('preco_termo').to_f end + def self.preco_termo_especial + valor('preco_termo_especial').to_f + end + # Mapa usado pelo DashboardController (Fase 3) def self.mapa_de_precos { @@ -66,7 +72,8 @@ class Configuracao < ApplicationRecord bonus: preco_bonus, desconto: preco_desconto, extraordinaria: preco_extraordinaria, - termo: preco_termo + termo: preco_termo, + termo_especial: preco_termo_especial } end @@ -77,6 +84,7 @@ class Configuracao < ApplicationRecord 'preco_desconto' => 'Desconto', 'preco_extraordinaria' => 'Entrega Extraordinária', 'preco_termo' => 'Entrega de Termo', + 'preco_termo_especial' => 'Entrega de Termo Especial', 'notificacao_whatsapp' => 'Notificação WhatsApp', 'notificacao_email' => 'Notificação E-mail', 'empresa_nome' => 'Nome da Empresa' @@ -89,6 +97,7 @@ class Configuracao < ApplicationRecord 'preco_desconto' => '⚠️', 'preco_extraordinaria' => '✨', 'preco_termo' => '📄', + 'preco_termo_especial' => '📋', 'notificacao_whatsapp' => '💬', 'notificacao_email' => '✉️', 'empresa_nome' => '🏢' diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index 8bb25d9..7d14d9e 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -339,10 +339,15 @@ class Consolidacao < ApplicationRecord # 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 # o total do lote, então recalcular_motorista! e demais somas seguem inalteradas. - def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil) + # `tipo` distingue termo normal ('termo') de termo especial ('termo_especial'), + # cada um com seu preço configurável. + def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil, tipo: 'termo') nome = motorista.to_s.strip raise ArgumentError, 'Informe o motorista do termo.' if nome.blank? + tipo = tipo.to_s + raise ArgumentError, 'Tipo de termo inválido.' unless %w[termo termo_especial].include?(tipo) + qtd = quantidade.to_i raise ArgumentError, 'Informe uma quantidade válida de termos.' if qtd < 1 @@ -351,10 +356,10 @@ class Consolidacao < ApplicationRecord consolidacao_entregas.create!( tracking_id: "TERMO-#{SecureRandom.uuid}", # sintético: não há Entrega/NF - tipo: 'termo', + tipo: tipo, motorista_nome: nome, quantidade: qtd, - valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote + valor_aplicado: qtd * ConsolidacaoEntrega.valor_para(tipo), # total do lote manual: true, vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3) created_by: user&.id diff --git a/app/models/consolidacao_entrega.rb b/app/models/consolidacao_entrega.rb index df93357..2ce0894 100644 --- a/app/models/consolidacao_entrega.rb +++ b/app/models/consolidacao_entrega.rb @@ -15,7 +15,8 @@ class ConsolidacaoEntrega < ApplicationRecord bonus: 2, desconto: 3, extraordinaria: 4, - termo: 5 + termo: 5, + termo_especial: 6 } TIPO_CORES = { @@ -24,7 +25,8 @@ class ConsolidacaoEntrega < ApplicationRecord 'bonus' => { bg: 'bg-white border border-orange-500', text: 'text-black', label: 'Bônus' }, 'desconto' => { bg: 'bg-gray-900', text: 'text-white', label: 'Desconto' }, 'extraordinaria' => { bg: 'bg-purple-600', text: 'text-white', label: 'Entrega Extraordinária' }, - 'termo' => { bg: 'bg-blue-600', text: 'text-white', label: 'Entrega de Termo' } + 'termo' => { bg: 'bg-blue-600', text: 'text-white', label: 'Entrega de Termo' }, + 'termo_especial' => { bg: 'bg-cyan-600', text: 'text-white', label: 'Entrega de Termo Especial' } }.freeze # Uma entrega pode ter vários pilares (Normal + Bônus + Retirada…), @@ -54,6 +56,7 @@ class ConsolidacaoEntrega < ApplicationRecord when 'desconto' then Configuracao.preco_desconto when 'extraordinaria' then Configuracao.preco_extraordinaria when 'termo' then Configuracao.preco_termo + when 'termo_especial' then Configuracao.preco_termo_especial else 0 end end diff --git a/app/services/analytics/operacao_metricas.rb b/app/services/analytics/operacao_metricas.rb index 97c2834..e05bf31 100644 --- a/app/services/analytics/operacao_metricas.rb +++ b/app/services/analytics/operacao_metricas.rb @@ -45,6 +45,18 @@ module Analytics end end + # Campos pesquisáveis da tabela espelho (planilha da operação). + BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name + address nome_completo endereco_completo status_gade operacao].freeze + + # Busca textual da tabela espelho: filtra as linhas (já pós cross-filter) + # por qualquer campo exibido na planilha. + def buscar(texto) + termo = texto.to_s.strip.downcase + return linhas if termo.empty? + linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } } + end + # Taxas (úteis no comparativo). def taxa_sucesso pct(sucesso, total) diff --git a/app/services/analytics/planilha_entregas.rb b/app/services/analytics/planilha_entregas.rb new file mode 100644 index 0000000..45f25cc --- /dev/null +++ b/app/services/analytics/planilha_entregas.rb @@ -0,0 +1,242 @@ +# app/services/analytics/planilha_entregas.rb +# +# Monta os dados da "planilha Entregas" entregue ao cliente (modelo +# "Entregas SUDESTE MM.AAAA_FINAL.xlsx"), com as 3 abas do modelo: +# +# RESUMO — totais/performance e quadro por coordenadoria (EMAD e UBS), +# com os motivos de "Não Entregue" agregados do rastreio. +# ENTREGAS — planilha do cliente (A..V da tabela gade_entregas_*) com as +# colunas de resultado W..Z preenchidas pelo rastreio (o +# preenchimento que hoje é feito manualmente). +# SimpliRoute — dump cru do rastreio (todas as visitas das NFs da operação). +# +# SEGURANÇA: tabela passa pela whitelist (Operacao.sanitizar) + quote_table_name; +# colunas são whitelist fixa e as ausentes viram NULL (mesmo padrão do +# OperacaoMetricas). Bases SOMENTE LEITURA. +module Analytics + class PlanilhaEntregas + # Colunas da tabela gade (snake_case) -> cabeçalho exato do modelo (A..V). + # Coluna que não existir em alguma operação sai vazia, preservando o layout. + COLUNAS_GADE = { + 'ordem' => 'Ordem', + 'operacao' => 'Operação', + 'coordenadoria' => 'Coordenadoria', + 'supervisao' => 'Supervisão', + 'unidade' => 'Unidade', + 'data_cadastro' => 'Data Cadastro', + 'cartao_sus' => 'Cartão SUS', + 'nome_completo' => 'Nome Completo', + 'endereco_completo' => 'Endereço Completo', + 'nome_responsavel' => 'Nome Responsável', + 'telefones' => 'Telefones', + 'area_de_risco' => 'Área de Risco', + 'numero_ativo' => 'Numero Ativo', + 'num_serie_base' => 'Num Serie Base', + 'num_serie_pulverizador' => 'Num Serie Pulverizador', + 'observacoes' => 'Observações', + 'endereco_sem_complemento' => 'Endereço Sem Complemento', + 'complemento' => 'Complemento', + 'latitude' => 'Latitude', + 'longitude' => 'Longitude', + 'status' => 'STATUS', + 'nota_fiscal' => 'NOTA FISCAL' + }.freeze + + # Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio). + CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze + + # Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela + # de rastreio (a primeira que existir é usada; nenhuma existente = vazia). + RASTREIO_COLUNAS = { + 'Tracking ID' => %w[tracking_id], + 'Referência ID' => %w[reference_id], + 'Data prevista' => %w[planned_date], + 'Motorista' => %w[driver], + 'Co-pilotos' => %w[copilots co_pilotos copilotos], + 'Veículo' => %w[vehicle], + 'Título' => %w[title titulo], + 'Endereço' => %w[address], + 'ETA' => %w[eta], + 'ETD' => %w[etd], + 'Checkin' => %w[checkin], + 'Checkout' => %w[checkout], + 'Responsável' => %w[receiver responsavel responsible], + 'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration], + 'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real], + 'Antecipação' => %w[early antecipacao anticipation], + 'Atraso' => %w[delay atraso], + 'Latitude' => %w[latitude], + 'Longitude' => %w[longitude], + 'Checkout latitude' => %w[checkout_latitude], + 'Checkout longitude' => %w[checkout_longitude], + 'Load' => %w[load load_1 carga], + 'Load 2' => %w[load_2], + 'Load 3' => %w[load_3], + 'Load 4' => %w[load_4], + 'Estado' => %w[status], + 'Comentários' => %w[checkout_comment comments comentarios comment], + 'Observações' => %w[observation observacoes], + 'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1], + 'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1], + 'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2], + 'Janela de horário Final 2' => %w[window_end_2 janela_final_2], + 'Habilidades necessárias' => %w[skills_required habilidades_necessarias], + 'Habilidades adicionais' => %w[skills_optional habilidades_adicionais], + 'Notas' => %w[notes notas], + 'Nome de contato' => %w[contact_name], + 'Telefone de contato' => %w[contact_phone telefone_de_contato], + 'Correio eletrônico de contato' => %w[contact_email correio_eletronico_de_contato], + 'ID da rota' => %w[route_id id_da_rota], + 'ID da conta' => %w[account_id], + 'Nome da conta' => %w[account_name nome_da_conta], + 'Nota Fiscal' => %w[nota_fiscal], + 'Termo de Recebimento' => %w[termo_de_recebimento], + 'Foto da Fachada' => %w[foto_da_fachada], + 'Relatório de visita' => %w[relatorio_de_visita visit_report], + 'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base] + }.freeze + + # Quadro do RESUMO — linhas fixas do modelo. + COORDENADORIAS = ['CRS Sudeste', 'CRS Sul', 'CRS Leste', 'CRS Norte', 'CRS Oeste', 'CRS Centro'].freeze + MOTIVOS = ['Óbito', 'Responsável Ausente', 'Endereço não localizado', 'Recusa', + 'Paciente não reside no local', 'Outros'].freeze + GRUPOS = %w[EMAD UBS].freeze + + MESES_PT = %w[JANEIRO FEVEREIRO MARÇO ABRIL MAIO JUNHO JULHO AGOSTO SETEMBRO OUTUBRO NOVEMBRO DEZEMBRO].freeze + + attr_reader :tabela + + def initialize(tabela) + @tabela = Operacao.sanitizar([tabela]).first + raise ArgumentError, "Operação inválida: #{tabela}" unless @tabela + end + + def label + Operacao.label(@tabela) + end + + # ── Aba ENTREGAS ───────────────────────────────────────────── + # Array de hashes: chaves de COLUNAS_GADE + rastreio_status/checkout/observation. + # ORDER BY g.ctid preserva a ordem física de importação — a mesma ordem da + # planilha original do cliente (tabelas gade são read-only após o import). + def linhas + @linhas ||= begin + gade = conn.quote_table_name(@tabela) + sql = <<~SQL + WITH ultimo AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn + FROM #{conn.quote_table_name(Entrega.table_name)} + WHERE reference_id IS NOT NULL + ) + SELECT #{selects_gade}, + r.status AS rastreio_status, + r.checkout AS rastreio_checkout, + r.observation AS rastreio_observation + FROM #{gade} g + LEFT JOIN ultimo r ON r.rn = 1 AND r.reference_id::text = g.nota_fiscal + ORDER BY g.ctid + SQL + conn.select_all(sql).to_a + end + end + + # ── Aba SimpliRoute ────────────────────────────────────────── + # Todas as visitas do rastreio para as NFs da operação (inclui repetidas, + # como no dump original do SimpliRoute). + def linhas_rastreio + gade = conn.quote_table_name(@tabela) + sql = <<~SQL + SELECT r.* + FROM #{conn.quote_table_name(Entrega.table_name)} r + WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL) + ORDER BY r.reference_id, r.checkout NULLS LAST + SQL + conn.select_all(sql).to_a + end + + # { cabeçalho do modelo => coluna real do rastreio (ou nil) }. + def colunas_rastreio + existentes = conn.columns(Entrega.table_name).map(&:name) + RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } } + end + + # ── Aba RESUMO ─────────────────────────────────────────────── + # Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria: + # previsão/entregue/não entregue/pendente + motivos de "Não Entregue". + def resumo + grupos = GRUPOS.index_with { Hash.new { |h, k| h[k] = novo_bloco } } + + linhas.each do |l| + bloco = grupos[grupo_de(l)][coordenadoria_de(l)] + bloco[:previsao] += 1 + status = l['rastreio_status'].to_s + if status == 'completed' + bloco[:entregue] += 1 + elsif Entrega::STATUS_FALHA.include?(status) + bloco[:nao_entregue] += 1 + bloco[:motivos][motivo_de(l['rastreio_observation'])] += 1 + else + bloco[:pendente] += 1 + end + end + + total_prev = linhas.size + total_real = grupos.values.sum { |coords| coords.values.sum { |b| b[:entregue] } } + { + mes: "#{MESES_PT[Date.current.month - 1]}/#{Date.current.year}", + total_previsto: total_prev, + total_realizado: total_real, + performance: (total_prev.zero? ? nil : total_real.to_f / total_prev), + grupos: grupos + } + end + + # Bloco zerado do quadro RESUMO (também usado pelo gerador xlsx). + def novo_bloco + { previsao: 0, entregue: 0, nao_entregue: 0, pendente: 0, motivos: Hash.new(0) } + end + + private + + def conn + ActiveRecord::Base.connection + end + + # SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias. + def selects_gade + existentes = conn.columns(@tabela).map(&:name) + COLUNAS_GADE.keys.map do |c| + existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}" + end.join(', ') + end + + # Grupo do quadro (EMAD/UBS): coluna "operacao" da linha; sem ela, deduz do + # nome/label da operação. Default UBS (grupo mais comum no modelo). + def grupo_de(linha) + texto = I18n.transliterate("#{linha['operacao']} #{@tabela} #{label}").upcase + texto.include?('EMAD') ? 'EMAD' : 'UBS' + end + + # Casa a coordenadoria da linha com as fixas do modelo (sem acento/caixa); + # valores fora da lista entram como estão (viram linha extra no quadro). + def coordenadoria_de(linha) + bruto = linha['coordenadoria'].to_s.strip + return COORDENADORIAS.first if bruto.empty? # sem coluna: agrega na 1ª (Sudeste) + + chave = I18n.transliterate(bruto).upcase + COORDENADORIAS.find { |c| I18n.transliterate(c).upcase == chave } || bruto + end + + # Classifica a observação do insucesso nos motivos fixos do RESUMO. + def motivo_de(observacao) + o = I18n.transliterate(observacao.to_s).downcase + return 'Óbito' if o.include?('obito') + return 'Responsável Ausente' if o.include?('ausente') + return 'Endereço não localizado' if o.include?('localizado') || o.include?('endereco') + return 'Recusa' if o.include?('recus') + return 'Paciente não reside no local' if o.include?('reside') + 'Outros' + end + end +end diff --git a/app/services/analytics/planilha_entregas_xlsx.rb b/app/services/analytics/planilha_entregas_xlsx.rb new file mode 100644 index 0000000..dba9da2 --- /dev/null +++ b/app/services/analytics/planilha_entregas_xlsx.rb @@ -0,0 +1,211 @@ +# app/services/analytics/planilha_entregas_xlsx.rb +# +# Gera o binário .xlsx da planilha Entregas do cliente com as 3 abas do modelo +# "Entregas SUDESTE MM.AAAA_FINAL.xlsx": RESUMO, ENTREGAS e SimpliRoute. +# Os dados vêm de Analytics::PlanilhaEntregas. +# +# As cores/estilos foram extraídos do próprio arquivo modelo (styles.xml): +# ENTREGAS A..T azul 1155CC e U..Z vermelho C00000 (texto branco, negrito); +# RESUMO com quadros em 002060/0070C0/C00000/A5A5A5; SimpliRoute sem estilo. +# (Os gráficos embutidos do modelo não são replicados.) +require 'caxlsx' + +module Analytics + class PlanilhaEntregasXlsx + def initialize(planilha) + @planilha = planilha + end + + # String binária do .xlsx. + def gerar + pkg = Axlsx::Package.new + wb = pkg.workbook + definir_estilos(wb) + + aba_resumo(wb) + aba_entregas(wb) + aba_simpliroute(wb) + + pkg.to_stream.read + end + + private + + def definir_estilos(wb) + st = wb.styles + borda = { style: :thin, color: 'FF808080', edges: %i[left right top bottom] } + centro = { horizontal: :center, vertical: :center } + + # Cabeçalhos/civis do RESUMO + @titulo_mes = st.add_style(b: true) + @titulo_sec = st.add_style(b: true, sz: 16, u: true) + @rotulo_kpi = st.add_style(b: true, sz: 10) + @valor_kpi = st.add_style(b: true, sz: 10, border: { style: :thin, color: 'FF000000', edges: [:bottom] }) + @valor_pct = st.add_style(b: true, sz: 10, format_code: '0.0%', + border: { style: :thin, color: 'FF000000', edges: [:bottom] }) + + @h_navy = st.add_style(bg_color: '002060', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro) + @h_azul = st.add_style(bg_color: '0070C0', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro) + @h_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro) + @h_cinza = st.add_style(bg_color: 'A5A5A5', fg_color: 'FFFFFF', sz: 10, border: borda, + alignment: centro.merge(wrap_text: true)) + @h_motivo = st.add_style(b: true, sz: 10, border: borda, alignment: centro.merge(wrap_text: true)) + + @celula = st.add_style(sz: 11, border: borda) + @cel_num = st.add_style(sz: 11, border: borda, alignment: centro) + + # Status das Entregas / Conferência Documentos (verde "bom", laranja "em + # andamento", azul "a iniciar" — como no modelo) + @st_verde = st.add_style(bg_color: 'C6EFCE', fg_color: '006100', b: true, sz: 10, border: borda, alignment: centro) + @st_laranja = st.add_style(bg_color: 'FCE4D6', fg_color: 'ED7D31', sz: 10, border: borda, alignment: centro) + @st_iniciar = st.add_style(fg_color: '0070C0', sz: 10, border: borda, alignment: centro) + + @perf_rotulo = st.add_style(i: true) + @perf_pct = st.add_style(i: true, format_code: '0.0%') + + # Cabeçalho da ENTREGAS + @cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true) + @cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true) + end + + # ── RESUMO ─────────────────────────────────────────────────── + # Mesma disposição do modelo: mês, totais/performance e um quadro por grupo + # (EMAD e UBS) com as coordenadorias fixas + motivos de "Não Entregue". + # ("Cordenadoria" sem o primeiro "o" é verbatim do modelo.) + def aba_resumo(wb) + resumo = @planilha.resumo + wb.add_worksheet(name: 'RESUMO') do |s| + s.add_row [] + s.add_row ['', resumo[:mes]], style: [nil, @titulo_mes] + s.add_row [] + s.add_row ['', 'Total Previsto:', resumo[:total_previsto]], style: [nil, @rotulo_kpi, @valor_kpi] + s.add_row ['', 'Total Realizado:', resumo[:total_realizado]], style: [nil, @rotulo_kpi, @valor_kpi] + s.add_row ['', 'Performance:', resumo[:performance] || ''], + style: [nil, @rotulo_kpi, (resumo[:performance] ? @valor_pct : @valor_kpi)] + + PlanilhaEntregas::GRUPOS.each { |grupo| quadro_grupo(s, grupo, resumo[:grupos][grupo]) } + + s.column_widths 3, 18, 10, 10, 12, 14, 14, 3, 10, 12, 14, 10, 14, 10 + end + end + + def quadro_grupo(s, grupo, coords) + s.add_row [] + s.add_row ['', grupo], style: [nil, @titulo_sec] + + # "Informações" (F:G) e 'Motivos de "Não Entregue"' (I:N) mescladas + s.add_row ['', '', '', '', '', 'Informações', '', '', 'Motivos de "Não Entregue"', '', '', '', '', ''], + style: [nil, nil, nil, nil, nil, @h_motivo, @h_motivo, nil, *Array.new(6, @h_motivo)] + r = s.rows.size + s.merge_cells("F#{r}:G#{r}") + s.merge_cells("I#{r}:N#{r}") + + s.add_row ['', 'Cordenadoria', 'Previsão', 'Entregue', 'Não Entregue', + 'Status das Entregas', 'Conferência Documentos', '', + *PlanilhaEntregas::MOTIVOS], + style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, @h_cinza, @h_cinza, nil, + *Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)] + + # Coordenadorias fixas do modelo + eventuais extras vindas dos dados. + nomes = PlanilhaEntregas::COORDENADORIAS + (coords.keys - PlanilhaEntregas::COORDENADORIAS) + total = @planilha.novo_bloco + + nomes.each do |nome| + b = coords[nome] || @planilha.novo_bloco + total[:previsao] += b[:previsao] + total[:entregue] += b[:entregue] + total[:nao_entregue] += b[:nao_entregue] + total[:pendente] += b[:pendente] + b[:motivos].each { |m, q| total[:motivos][m] += q } + + s.add_row ['', nome, b[:previsao], b[:entregue], b[:nao_entregue], + (b[:pendente].zero? ? 'Concluído' : 'Em andamento'), 'A iniciar', '', + *PlanilhaEntregas::MOTIVOS.map { |m| b[:motivos][m] }], + style: [nil, @celula, @cel_num, @cel_num, @cel_num, + (b[:pendente].zero? ? @st_verde : @st_laranja), @st_iniciar, nil, + *Array.new(PlanilhaEntregas::MOTIVOS.size, @cel_num)] + end + + s.add_row ['', 'Total', total[:previsao], total[:entregue], total[:nao_entregue], '', '', '', + *PlanilhaEntregas::MOTIVOS.map { |m| total[:motivos][m] }], + style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, nil, nil, nil, + *Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)] + + if total[:previsao].positive? + s.add_row ['', 'Performance %', 1, + total[:entregue].to_f / total[:previsao], + total[:nao_entregue].to_f / total[:previsao]], + style: [nil, @perf_rotulo, @perf_pct, @perf_pct, @perf_pct] + else + s.add_row ['', 'Performance %', '', '', ''], style: [nil, @perf_rotulo] + end + end + + # ── ENTREGAS ───────────────────────────────────────────────── + # A..V direto da tabela gade (texto); W..Z do rastreio: + # W STATUS (completed/failed...), X ENTREGA (Sim/Não), Y DATA OCORRÊNCIA + # (data real do checkout), Z OCORRÊNCIA (motivo do insucesso). + # Cabeçalho como no modelo: A..T azul, U..Z (resultado) vermelho. + def aba_entregas(wb) + linhas = @planilha.linhas + azuis = PlanilhaEntregas::COLUNAS_GADE.size - 2 # A..T + wb.add_worksheet(name: 'ENTREGAS') do |sheet| + sheet.add_row(PlanilhaEntregas::CABECALHO, + style: Array.new(azuis, @cab_azul) + + Array.new(PlanilhaEntregas::CABECALHO.size - azuis, @cab_verm)) + linhas.each do |l| + valores, tipos = linha_entrega(l) + sheet.add_row(valores, types: tipos) + end + sheet.auto_filter = "A1:Z#{linhas.size + 1}" + end + end + + def linha_entrega(l) + status_r = l['rastreio_status'].to_s + entregue = if status_r == 'completed' + 'Sim' + elsif Entrega::STATUS_FALHA.include?(status_r) + 'Não' + else + '' + end + data_ocorrencia = data_de(l['rastreio_checkout']) + + valores = PlanilhaEntregas::COLUNAS_GADE.keys.map { |c| l[c].to_s } + + [status_r, entregue, data_ocorrencia || '', l['rastreio_observation'].to_s] + tipos = Array.new(PlanilhaEntregas::COLUNAS_GADE.size + 2, :string) + + [(data_ocorrencia ? :date : :string), :string] + [valores, tipos] + end + + # ── SimpliRoute ────────────────────────────────────────────── + # Dump cru do rastreio (todas as visitas das NFs da operação), com o + # cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem + # coluna correspondente sai vazio. + def aba_simpliroute(wb) + colunas = @planilha.colunas_rastreio + linhas = @planilha.linhas_rastreio + wb.add_worksheet(name: 'SimpliRoute') do |sheet| + sheet.add_row(colunas.keys) + linhas.each do |r| + sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' }, + types: Array.new(colunas.size, :string)) + end + sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}" + end + end + + def texto(valor) + valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s + end + + def data_de(valor) + return nil if valor.nil? + return valor.to_date if valor.respond_to?(:to_date) + Date.parse(valor.to_s) + rescue ArgumentError, TypeError + nil + end + end +end diff --git a/app/services/pdf/relatorio_motorista_pdf.rb b/app/services/pdf/relatorio_motorista_pdf.rb index afd5b71..bb1f08c 100644 --- a/app/services/pdf/relatorio_motorista_pdf.rb +++ b/app/services/pdf/relatorio_motorista_pdf.rb @@ -37,7 +37,7 @@ module Pdf original&.vehicle.presence || (e.manual? ? 'manual' : '—'), original ? "NF #{original.numero_nf}" : (e.nf_manual.present? ? "NF #{e.nf_manual}" : '—'), original ? original.address.to_s : (e.obs_manual.presence || '—'), - e.termo? && e.quantidade > 1 ? "#{e.tipo_cor[:label]} ×#{e.quantidade}" : e.tipo_cor[:label], + (e.termo? || e.termo_especial?) && e.quantidade > 1 ? "#{e.tipo_cor[:label]} ×#{e.quantidade}" : e.tipo_cor[:label], (e.desconto? ? '-' : '') + moeda(e.valor_aplicado) ] end diff --git a/app/services/simpli_route/planilha_carga.rb b/app/services/simpli_route/planilha_carga.rb index cfa3e16..579e60e 100644 --- a/app/services/simpli_route/planilha_carga.rb +++ b/app/services/simpli_route/planilha_carga.rb @@ -2,8 +2,8 @@ # # Monta as linhas da planilha de carga do SimpliRoute a partir de UMA operação # vigente (tabela gade_entregas_*). Preenche Latitude/Longitude reaproveitando a -# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço + -# supervisão e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo. +# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço +# (normalizado) e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo. # # Tabelas de operação e rastreio são SOMENTE LEITURA (apenas SELECT). module SimpliRoute @@ -14,8 +14,10 @@ module SimpliRoute nota_fiscal nome_completo endereco_sem_complemento complemento supervisao telefones ].freeze - # Colunas que formam a chave de match com o histórico. - COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento supervisao].freeze + # Colunas que formam a chave de match com o histórico. Supervisão fica de + # fora: o cliente renomeia/mescla supervisões entre meses (ex.: "STS SANTA + # CECILIA" + "STS SE" → "STS SE_SANTA CECILIA") e a geo é do endereço. + COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento].freeze # Mês (número) -> grafias aceitas nos nomes de tabela (abreviação e extenso). MESES_GRAFIAS = { @@ -122,18 +124,31 @@ module SimpliRoute end end - # Chave de match: nome | endereço | supervisão, normalizados. + # Chave de match: nome | endereço, normalizados. def chave(row) - COLUNAS_CHAVE.map { |c| normaliza(row[c]) }.join('|') + [normaliza(row['nome_completo']), + normaliza_endereco(row['endereco_sem_complemento'])].join('|') end def normaliza(valor) valor.to_s.strip.gsub(/\s+/, ' ').upcase end - # Coordenada como Float (com PONTO). O rastreio pode trazer texto com vírgula - # decimal ("-23,62"); coordenadas não usam separador de milhar, então trocar - # a vírgula por ponto é seguro e garante a saída numérica (ponto) no .xlsx. + # Rua/número/bairro (3 primeiros segmentos) + dígitos do CEP: imune às + # variações de sufixo que o cliente alterna entre meses + # ("São Paulo, São Paulo, 01511-010, Brazil" vs "São Paulo, SP, CEP: 01511-010"). + # O CEP entra só pelos dígitos finais (7-8) para tolerar hífen fora do + # lugar (ex.: "028140-30"). + def normaliza_endereco(valor) + segs = valor.to_s.split(',').map(&:strip) + base = normaliza(segs.first(3).join(', ')) + cep = segs.drop(3).join.gsub(/\D/, '')[/\d{7,8}\z/] + [base, cep].compact.join('|') + end + + # Coordenada como Float. O rastreio pode trazer texto com vírgula decimal + # ("-23,62"); coordenadas não usam separador de milhar, então trocar a + # vírgula por ponto é seguro — o Float#to_s do xlsx sai sempre com PONTO. def to_coord(valor) Float(valor.to_s.strip.tr(',', '.')) rescue ArgumentError, TypeError diff --git a/app/services/simpli_route/planilha_carga_xlsx.rb b/app/services/simpli_route/planilha_carga_xlsx.rb index 79ead25..0c74d5d 100644 --- a/app/services/simpli_route/planilha_carga_xlsx.rb +++ b/app/services/simpli_route/planilha_carga_xlsx.rb @@ -59,8 +59,10 @@ module SimpliRoute private - # Monta [valores, tipos] de uma linha (A..Z). Lat/long numéricos quando há - # match; D/E como texto ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo). + # Monta [valores, tipos] de uma linha (A..Z). Lat/long como TEXTO com ponto + # decimal — se fossem numéricas, Excel/LibreOffice em pt-BR exibiria vírgula + # (locale) e o cliente subiria "-23,57" pro SimpliRoute; D/E como texto + # ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo). def linha(l) lat = l[:latitude] lng = l[:longitude] @@ -72,8 +74,8 @@ module SimpliRoute l[:janela_fim], # E l[:tempo], # F l[:anotacoes].to_s, # G - (lat.nil? ? '' : lat), # H - (lng.nil? ? '' : lng), # I + lat.to_s, # H + lng.to_s, # I l[:referencia].to_s, # J '', '', # K, L l[:contato].to_s, # M @@ -82,8 +84,8 @@ module SimpliRoute ] tipos = [ :string, :string, :integer, :string, :string, :integer, :string, - (lat.nil? ? :string : :float), # H - (lng.nil? ? :string : :float), # I + :string, # H + :string, # I :string, :string, :string, :string, :string, *Array.new(VAZIAS_FINAIS.size, :string) ] diff --git a/app/views/admin/planilhas_simpli_route/show.html.erb b/app/views/admin/planilhas_simpli_route/show.html.erb index cd8492c..70a8eb3 100644 --- a/app/views/admin/planilhas_simpli_route/show.html.erb +++ b/app/views/admin/planilhas_simpli_route/show.html.erb @@ -14,7 +14,7 @@
ℹ️

A Latitude/Longitude é reaproveitada do mês anterior da mesma - operação (casando nome + endereço + supervisão). Pacientes novos ou sem + operação (casando nome + endereço). Pacientes novos ou sem correspondência saem sem lat/long — o SimpliRoute geocodifica na importação. Se a operação não tiver mês anterior, a planilha sai inteira sem lat/long.

diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb index fd3161a..c5dc270 100644 --- a/app/views/consolidacao_entregas/revisar.html.erb +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -12,14 +12,15 @@ <%# Resumo por tipo %> -
+
<% [ ['entrega_normal', 'Normal', 'bg-orange-500 text-black'], ['retirada', 'Retirada', 'bg-orange-800 text-white'], ['bonus', 'Bônus', 'bg-white text-black'], ['desconto', 'Desconto', 'bg-black text-white border border-gray-700'], ['extraordinaria', 'Extraordinária', 'bg-purple-600 text-white'], - ['termo', 'Termo', 'bg-blue-600 text-white'] + ['termo', 'Termo', 'bg-blue-600 text-white'], + ['termo_especial', 'Termo Especial', 'bg-cyan-600 text-white'] ].each do |tipo, label, cores| %>

<%= @resumo[tipo] || 0 %>

@@ -67,7 +68,7 @@ <%= c.entrega_original&.address.presence || endereco_manual.presence || '—' %> - <%= c.tipo_cor[:label] %><% if c.termo? && c.quantidade > 1 %> ×<%= c.quantidade %><% end %> + <%= c.tipo_cor[:label] %><% if (c.termo? || c.termo_especial?) && c.quantidade > 1 %> ×<%= c.quantidade %><% end %> <% if c.manual? %> ➕ Apontamento diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb index 1ad2c76..1bff40e 100644 --- a/app/views/consolidacao_entregas/validar.html.erb +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -288,20 +288,14 @@
-

Entrega sem nota fiscal nem código de rastreio. Informe quantos termos foram entregues.

+

Entrega sem nota fiscal nem código de rastreio. Informe quantos termos de cada tipo foram entregues.

- <%# Motorista (contexto da tela) + preço unitário %> -
-
-

Motorista

-

🚚 <%= @motorista %>

-
-
-

Preço por termo

-

<%= moeda(Configuracao.preco_termo) %>

-
+ <%# Motorista (contexto da tela) %> +
+

Motorista

+

🚚 <%= @motorista %>

<%# Veículo (Q3) — a qual carro estes termos pertencem %> @@ -314,15 +308,32 @@ <% end %> - <%# Stepper de quantidade %> - -
- - - + <%# Steppers de quantidade — termo normal e termo especial %> +
+
+

📄 Termo Normal

+

<%= moeda(@precos[:termo]) %> cada

+
+ + + +
+
+
+

📋 Termo Especial

+

<%= moeda(@precos[:termo_especial]) %> cada

+
+ + + +
+
+
+ Termo Especial + 0 +
@@ -456,6 +471,7 @@

⚠️ Desconto: -<%= moeda(@precos[:desconto]) %>

✨ Extraordinária: <%= moeda(@precos[:extraordinaria]) %> (valor por entrega)

📄 Termo: <%= moeda(@precos[:termo]) %>

+

📋 Termo Especial: <%= moeda(@precos[:termo_especial]) %>

diff --git a/app/views/consolidacoes/show.html.erb b/app/views/consolidacoes/show.html.erb index bcf131d..27cdb67 100644 --- a/app/views/consolidacoes/show.html.erb +++ b/app/views/consolidacoes/show.html.erb @@ -171,14 +171,20 @@ <% @apontamentos.each do |(motorista, _tracking), pilares| %> - <% eh_termo = pilares.first.termo? %> + <% eh_termo = pilares.first.termo? || pilares.first.termo_especial? %> <% entrega = eh_termo ? nil : pilares.first.entrega_original %> <% nf_exibida = entrega&.numero_nf || pilares.first.nf_manual %> <% obs_manual = pilares.first.obs_manual %> <% total = pilares.sum { |p| p.desconto? ? -p.valor_aplicado : p.valor_aplicado } %> -

<%= eh_termo ? '📄 Entrega de termo' : "NF #{nf_exibida.presence || '—'}" %>

+

<%= if pilares.first.termo? + '📄 Entrega de termo' + elsif pilares.first.termo_especial? + '📋 Entrega de termo especial' + else + "NF #{nf_exibida.presence || '—'}" + end %>

<%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %> <% if entrega.nil? && obs_manual.present? %> · <%= obs_manual %><% end %> @@ -187,7 +193,7 @@

<% pilares.each do |p| %> - <%= p.tipo_cor[:label] %><% if p.termo? && p.quantidade > 1 %> ×<%= p.quantidade %><% end %> + <%= p.tipo_cor[:label] %><% if (p.termo? || p.termo_especial?) && p.quantidade > 1 %> ×<%= p.quantidade %><% end %> <% end %>
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index 5b91321..cede46d 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -204,9 +204,9 @@ <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
- <%# Gráfico de linha diário (Chart.js) %> -
-
+ <%# Gráficos diários (Chart.js) — flex-col p/ preencher a altura do card de motoristas %> +
+

Evolução do custo

Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %>

@@ -215,9 +215,39 @@ Valor (R$)
-
+
+ + <%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato %> +
+
+

Pagamentos por dia

+

Valor pago aos motoristas por data de pagamento

+
+ <% if @pag_pago_qtd.positive? %> +
+ Pago no dia + Acumulado +
+ <% end %> +
+ <% if @pag_pago_qtd.positive? %> +
+ +
+ <% else %> +
+
💸
+

Nenhum pagamento realizado neste período

+ <% if @pag_pend_valor.positive? %> +

+ <%= moeda(@pag_pend_valor) %> + aguardando pagamento (<%= @pag_pend_qtd %> motorista(s)) +

+ <% end %> +
+ <% end %>
<%# Top motoristas %> @@ -348,9 +378,9 @@ Valor - + <% @pagamentos_feitos.each do |cm| %> - + <%= l cm.pago_em.to_date, format: :short %> <%= cm.motorista_nome %> @@ -364,6 +394,10 @@
+ <% else %>

Nenhum pagamento realizado neste período.

<% end %> @@ -382,9 +416,9 @@ Valor - + <% @pagamentos_pendentes.each do |cm| %> - + <%= cm.motorista_nome %> <%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao), @@ -396,6 +430,10 @@
+ <% else %>

Nenhuma pendência no período. 🎉

<% end %> @@ -473,6 +511,60 @@ })(); +<%# Paginação client-side das tabelas de pagamentos (10 por página) %> + + <%# Filtro de operação — submete o form ao marcar/desmarcar %> +<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %> + + <%# Rosca de pagamentos (pago x a pagar, por valor) %> - +<%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na + página (aparecia como uma barra laranja "quebrando" o rodapé no celular); + tabelas largas já rolam nos próprios contêineres overflow-x-auto. %> + <%# ── Animação de abertura (uma vez, logo após o login) ── %> <% if @mostrar_splash %> diff --git a/app/views/operacoes_dashboard/_espelho.html.erb b/app/views/operacoes_dashboard/_espelho.html.erb new file mode 100644 index 0000000..d092db6 --- /dev/null +++ b/app/views/operacoes_dashboard/_espelho.html.erb @@ -0,0 +1,139 @@ +<%# app/views/operacoes_dashboard/_espelho.html.erb %> +<%# Tabela espelho da planilha da operação: uma linha por NF com resultado, + ocorrência, motorista etc. Busca textual (param q) e paginação (param pg) + são server-side; o restante da URL (modo/operação/período/cross-filters) + é preservado nos links e no form. %> + +<% pg_path = ->(n) { operacoes_planilha_path(request.query_parameters.merge('pg' => n)) } %> +<% resultado_badge = ->(status) { + if status == 'completed' + ['Entregue', 'bg-green-500/10 text-green-400 border-green-500/30'] + elsif Entrega::STATUS_FALHA.include?(status) + ['Falha', 'bg-red-500/10 text-red-400 border-red-500/30'] + else + ['Pendente', 'bg-yellow-500/10 text-yellow-400 border-yellow-500/30'] + end + } %> + +
+
+
+

📋 Planilha da operação

+

Espelho por NF — pesquise por nota, motorista, unidade, ocorrência, veículo...

+
+ +
+ <% request.query_parameters.except('q', 'pg').each do |k, v| %> + + <% end %> +
+ 🔎 + +
+ + <% if @busca.present? %> + <%= link_to '✕ Limpar', operacoes_planilha_path(request.query_parameters.except('q', 'pg')), + data: { turbo: false }, class: 'text-xs text-orange-400 hover:text-orange-300 whitespace-nowrap' %> + <% end %> +
+
+ + <% if @espelho_linhas.blank? %> +

+ <%= @busca.present? ? %(Nenhuma entrega encontrada para "#{@busca}".) : 'Nenhuma entrega para exibir.' %> +

+ <% else %> +
+ + + + + + <% if @modo == 'global' %> + + <% end %> + + + + + + + + + + + <% @espelho_linhas.each do |r| %> + <% rotulo, badge_css = resultado_badge.(r['status']) %> + <% data = r['checkout'] || r['planned_date'] %> + <% endereco = r['endereco_completo'].presence || r['address'].presence %> + + + + <% if @modo == 'global' %> + + <% end %> + + + + + + + + + <% end %> + +
DataNFOperaçãoDestinatárioUnidadeMotoristaVeículoResultadoOcorrênciaStatus
+ <%= data.respond_to?(:strftime) ? data.strftime('%d/%m/%y %H:%M') : data.to_s.first(16) %> + <%= r['reference_id'] %><%= r['operacao'] %> + <%= (r['nome_completo'].presence || '—').truncate(28) %> + <%= r['contact_name'].to_s.truncate(24).presence || '—' %><%= r['driver'].presence || '—' %><%= r['vehicle'].presence || '—' %> + + <%= rotulo %> + + + <%= r['observation'].to_s.truncate(32).presence || '—' %> + <%= r['status_gade'].presence || '—' %>
+
+ + <%# Paginação: « 1 … janela em torno da atual … última » %> +
+

+ <%= (@espelho_pagina - 1) * OperacoesDashboardController::ESPELHO_POR_PAGINA + 1 %>–<%= + [(@espelho_pagina) * OperacoesDashboardController::ESPELHO_POR_PAGINA, @espelho_total].min %> + de <%= @espelho_total %> entregas +

+ <% if @espelho_paginas > 1 %> +
+ <% if @espelho_pagina > 1 %> + <%= link_to '‹', pg_path.(@espelho_pagina - 1), data: { turbo: false }, + class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %> + <% end %> + <% janela = ([1, @espelho_pagina - 2].max..[@espelho_paginas, @espelho_pagina + 2].min).to_a %> + <% paginas = ([1] + janela + [@espelho_paginas]).uniq %> + <% anterior = 0 %> + <% paginas.each do |p| %> + <% if p > anterior + 1 %> + + <% end %> + <% anterior = p %> + <% if p == @espelho_pagina %> + <%= p %> + <% else %> + <%= link_to p, pg_path.(p), data: { turbo: false }, + class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %> + <% end %> + <% end %> + <% if @espelho_pagina < @espelho_paginas %> + <%= link_to '›', pg_path.(@espelho_pagina + 1), data: { turbo: false }, + class: 'px-3 py-1.5 rounded-lg text-xs font-semibold bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white' %> + <% end %> +
+ <% end %> +
+ <% end %> +
diff --git a/app/views/operacoes_dashboard/index.html.erb b/app/views/operacoes_dashboard/index.html.erb index 46f2f56..2a0ff56 100644 --- a/app/views/operacoes_dashboard/index.html.erb +++ b/app/views/operacoes_dashboard/index.html.erb @@ -88,16 +88,28 @@ <% end %>
- <%# ── Abas de modo ──────────────────────────────────────── %> + <%# ── Abas de modo + atalho para a planilha ─────────────── %> <% 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 %> +
+
+ <% 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 %> +
+ + <%# Planilha da operação (tabela espelho) — leva o contexto atual junto %> + <% planilha_ctx = request.query_parameters + .slice('f_driver', 'f_sts', 'f_status', 'f_obs', 'f_resultado', 'f_data') + .merge(modo: (@modo == 'global' ? 'global' : 'operacao'), inicio: ini_iso, fim: fim_iso) %> + <% planilha_ctx[:operacao] = @operacao if @operacao %> + <%= link_to '📋 Planilha da operação', operacoes_planilha_path(planilha_ctx), + data: { turbo: false }, + class: 'px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap bg-[#1a1a1a] + text-orange-400 border border-orange-500/40 hover:bg-orange-500 hover:text-black' %>
<% if @tabelas_validas.blank? %> diff --git a/app/views/operacoes_dashboard/planilha.html.erb b/app/views/operacoes_dashboard/planilha.html.erb new file mode 100644 index 0000000..d1bddcb --- /dev/null +++ b/app/views/operacoes_dashboard/planilha.html.erb @@ -0,0 +1,108 @@ +<%# app/views/operacoes_dashboard/planilha.html.erb %> +<%# Página da Planilha da Operação: tabela espelho (uma linha por NF) com busca + e paginação. Chega-se aqui pelo botão no Dashboard de Operações, que passa + o contexto (operação/modo global/período/cross-filters) na URL. %> + +<% content_for :head do %> + +<% end %> + +<% ini_iso = @periodo_inicio.strftime('%Y-%m-%d') %> +<% fim_iso = @periodo_fim.strftime('%Y-%m-%d') %> + +
+ + <%# ── Cabeçalho ─────────────────────────────────────────── %> +
+
+

📋 Planilha da Operação

+

+ <% if @modo == 'global' %> + 🌐 Global · <%= @periodo_inicio.strftime('%d/%m/%Y') %> – <%= @periodo_fim.strftime('%d/%m/%Y') %> + <% elsif @metricas&.data_inicio %> + <%= @metricas.operacoes_label %> · <%= @metricas.data_inicio.strftime('%d/%m/%Y') %> – <%= @metricas.data_fim.strftime('%d/%m/%Y') %> + <% elsif @operacao %> + <%= Operacao.label(@operacao) %> + <% end %> +

+
+ + <%= link_to '← Dashboard de Operações', + operacoes_dashboard_path({ modo: @modo, operacao: @operacao, inicio: ini_iso, fim: fim_iso }.compact), + data: { turbo: false }, + class: 'px-4 py-2.5 rounded-xl text-sm whitespace-nowrap bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500' %> +
+ + <%# ── Seleção de escopo: operação única ou global ───────── %> +
+ <% pills = { 'operacao' => '🏥 Operação', 'global' => '🌐 Global' } %> + <% pills.each do |m, label| %> + <% ativo = @modo == m %> + <%= link_to label, + operacoes_planilha_path({ modo: m, operacao: @operacao, inicio: ini_iso, fim: fim_iso, q: @busca.presence }.compact), + data: { turbo: false }, + class: "px-4 py-2 rounded-xl text-sm font-semibold whitespace-nowrap #{ativo ? 'bg-orange-500 text-black' : 'bg-[#1a1a1a] text-gray-300 border border-white/10 hover:border-orange-500'}" %> + <% end %> + + <% if @modo == 'operacao' %> +
+ + + + <% if @busca.present? %><% end %> + +
+ + <%# Planilha Entregas preenchida (modelo entregue ao cliente) %> + <% if @operacao %> + <%= link_to '⬇️ Baixar Excel preenchido', operacoes_planilha_baixar_path(operacao: @operacao), + data: { turbo: false }, + title: 'Planilha Entregas da operação com status/ocorrências preenchidos pelo rastreio', + class: 'px-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %> + <% end %> + <% end %> +
+ + <%# ── Filtros ativos herdados do dashboard (chips removíveis) ── %> + <% if @chips.any? %> +
+ Filtros ativos: + <% @chips.each do |chip| %> + + <%= chip[:rotulo] %>: <%= chip[:display] || chip[:valor] %> + <%= link_to '✕', operacoes_planilha_path(request.query_parameters.except(chip[:param], 'pg')), + data: { turbo: false }, class: 'hover:text-white font-bold', title: 'Remover filtro' %> + + <% end %> + <%= link_to 'Limpar tudo', + operacoes_planilha_path(request.query_parameters.except('pg', *@chips.map { |c| c[:param] })), + data: { turbo: false }, class: 'text-xs text-gray-400 hover:text-orange-400 underline' %> +
+ <% end %> + + <%# ── Tabela espelho ─────────────────────────────────────── %> + <% if @metricas %> + <%= render 'espelho' %> + <% else %> +
+ Nenhuma operação encontrada no banco (tabelas gade_entregas_*). +
+ <% end %> +
+ +<%# Troca de operação recarrega a planilha %> + diff --git a/config/routes.rb b/config/routes.rb index 9f867cc..1042fb1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,6 +18,10 @@ Rails.application.routes.draw do # Dashboard de análise de entregas por operação (UBS Norte, EMAD, ...) get '/dashboard/operacoes', to: 'operacoes_dashboard#index', as: :operacoes_dashboard + # Planilha da operação (tabela espelho pesquisável, página própria) + get '/dashboard/operacoes/planilha', to: 'operacoes_dashboard#planilha', as: :operacoes_planilha + # Download .xlsx da planilha Entregas preenchida (modelo entregue ao cliente) + get '/dashboard/operacoes/planilha/baixar', to: 'operacoes_dashboard#baixar_planilha', as: :operacoes_planilha_baixar # Painel motorista get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard diff --git a/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb b/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb new file mode 100644 index 0000000..3112576 --- /dev/null +++ b/db/migrate/20260715000001_add_preco_termo_especial_configuracao.rb @@ -0,0 +1,18 @@ +# Cria a configuração de preço "Entrega de Termo Especial" em bancos já +# existentes, para que apareça na tela de Configurações. Idempotente — não +# sobrescreve se o admin já tiver definido um valor. +class AddPrecoTermoEspecialConfiguracao < ActiveRecord::Migration[7.1] + def up + return if Configuracao.exists?(chave: 'preco_termo_especial') + + Configuracao.create!( + chave: 'preco_termo_especial', + valor: '0.00', + descricao: 'Valor pago por entrega de termo especial (R$)' + ) + end + + def down + Configuracao.where(chave: 'preco_termo_especial').delete_all + end +end diff --git a/db/seeds.rb b/db/seeds.rb index d0d1e9a..0466e38 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -30,6 +30,7 @@ configs = [ { chave: 'preco_desconto', valor: '15.00', descricao: 'Valor descontado por problema (R$)' }, { chave: 'preco_extraordinaria', valor: '25.00', descricao: 'Valor coringa para entregas fora do planejamento (R$)' }, { chave: 'preco_termo', valor: '15.00', descricao: 'Valor pago por entrega de termo (R$)' }, + { chave: 'preco_termo_especial', valor: '0.00', descricao: 'Valor pago por entrega de termo especial (R$)' }, { chave: 'notificacao_whatsapp', valor: 'false', descricao: 'Notificações via WhatsApp (true/false)' }, { chave: 'notificacao_email', valor: 'false', descricao: 'Notificações via e-mail (true/false)' }, { chave: 'empresa_nome', valor: 'Reem Transporte', descricao: 'Nome da empresa exibido no sistema' }, diff --git a/spec/models/configuracao_spec.rb b/spec/models/configuracao_spec.rb index 2938f86..d6f874e 100644 --- a/spec/models/configuracao_spec.rb +++ b/spec/models/configuracao_spec.rb @@ -30,6 +30,11 @@ RSpec.describe Configuracao, type: :model do it 'retorna 0.0 quando a chave não existe' do expect(Configuracao.preco_bonus).to eq(0.0) end + + it 'aceita a chave preco_termo_especial e converte em float' do + create(:configuracao, chave: 'preco_termo_especial', valor: '25.00') + expect(Configuracao.preco_termo_especial).to eq(25.0) + end end describe '#valor_formatado' do