From 90ed705ed873a305519e521b1b0cbd5285941e2f8e2e9aa298eefbd0efae8c28 Mon Sep 17 00:00:00 2001 From: victor Date: Thu, 11 Jun 2026 12:02:50 -0300 Subject: [PATCH] =?UTF-8?q?Fases=204,=205=20e=206=20=E2=80=94=20Job=201h?= =?UTF-8?q?=20+=20Auditoria=20+=20Consolida=C3=A7=C3=A3o=20+=20Wizard=20de?= =?UTF-8?q?=20valida=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 236 ++++++++---------- .../api/v1/dashboard_controller.rb | 30 +++ app/controllers/application_controller.rb | 26 +- app/controllers/concerns/auditavel.rb | 22 ++ .../consolidacao_entregas_controller.rb | 168 +++++++++++++ app/controllers/consolidacoes_controller.rb | 131 ++++++++++ app/helpers/application_helper.rb | 102 +++----- .../controllers/validacao_controller.js | 145 +++++++++++ app/jobs/application_job.rb | 5 + app/jobs/atualizar_historico_estimado_job.rb | 24 ++ app/models/auditoria_log.rb | 31 ++- app/models/configuracao.rb | 70 ++++-- app/models/consolidacao.rb | 83 ++++-- app/models/entrega.rb | 91 +++++-- app/models/user.rb | 76 +++--- app/policies/application_policy.rb | 41 +-- app/policies/consolidacao_policy.rb | 15 ++ app/policies/dashboard_policy.rb | 10 + .../consolidacao_entregas/revisar.html.erb | 74 ++++++ .../consolidacao_entregas/validar.html.erb | 166 ++++++++++++ app/views/consolidacoes/index.html.erb | 65 +++++ app/views/consolidacoes/new.html.erb | 89 +++++++ app/views/consolidacoes/wizard.html.erb | 91 +++++++ app/views/layouts/application.html.erb | 226 ++++++----------- config/routes.rb | 84 +++---- config/schedule.rb | 12 + ...01000008_add_route_ids_to_consolidacoes.rb | 6 + db/seeds.rb | 101 +++----- lib/tasks/historico.rake | 13 + 29 files changed, 1617 insertions(+), 616 deletions(-) create mode 100644 app/controllers/api/v1/dashboard_controller.rb create mode 100644 app/controllers/concerns/auditavel.rb create mode 100644 app/controllers/consolidacao_entregas_controller.rb create mode 100644 app/controllers/consolidacoes_controller.rb create mode 100644 app/javascript/controllers/validacao_controller.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/atualizar_historico_estimado_job.rb create mode 100644 app/policies/consolidacao_policy.rb create mode 100644 app/policies/dashboard_policy.rb create mode 100644 app/views/consolidacao_entregas/revisar.html.erb create mode 100644 app/views/consolidacao_entregas/validar.html.erb create mode 100644 app/views/consolidacoes/index.html.erb create mode 100644 app/views/consolidacoes/new.html.erb create mode 100644 app/views/consolidacoes/wizard.html.erb create mode 100644 config/schedule.rb create mode 100644 db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb create mode 100644 lib/tasks/historico.rake diff --git a/README.md b/README.md index bb3ae29..c765739 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**. -**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus) -**Repositório:** +**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus) +**Repositório:** https://git.xenserver.com.br/Cludio-code/logistica-controle-custos --- @@ -13,7 +13,6 @@ A **Gade Hospitalar** precisa controlar o custo de cada operação de entrega (UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.). O banco **já existe** e é atualizado a cada 1 hora por outro sistema externo: - - Tabela: `public.db_reem_simplerout_2026` - Account: Gade Hospitalar (account_id: 95907) - **NUNCA** fazer `DROP TABLE`, `TRUNCATE`, `DELETE` ou migration nessa tabela. @@ -23,13 +22,13 @@ e lê a tabela existente apenas para exibir e consolidar entregas. --- -## 🏗️ Estado atual — O que já foi feito +## 🏗️ Estado atual — O que já foi feito (Fase 1 ✅) -```text +``` logistica-controle-custos/ ├── Gemfile # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…) ├── Dockerfile # Ruby 3.2.2-slim -├── docker-compose.yml # app (postgres externo via .env) +├── docker-compose.yml # app + db (postgres:15) ├── tailwind.config.js # Paleta preto #0a0a0a / laranja #f97316 / branco ├── .env.example # Template de variáveis — copiar para .env ├── .gitignore # Protege .env, master.key, credenciais @@ -40,44 +39,27 @@ logistica-controle-custos/ │ ├── app/ │ ├── models/ -│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela db_reem_simplerout_2026 +│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela existente db_reem_simplerout_2026 │ │ ├── user.rb # Devise + roles (admin/gerente/operador/motorista) + PIN -│ │ ├── configuracao.rb # Preços configuráveis + mapa_de_precos() +│ │ ├── configuracao.rb # Preços configuráveis (entrega, retirada, bonus, desconto) │ │ ├── consolidacao.rb # Soft delete, enum status, cálculo de progresso │ │ ├── consolidacao_motorista.rb │ │ ├── consolidacao_entrega.rb # Enum tipo: normal/retirada/bonus/desconto │ │ ├── historico_estimado.rb # Job 1h + cálculo ao vivo -│ │ └── auditoria_log.rb # Log de ações críticas + .registrar() +│ │ └── auditoria_log.rb # Log de ações críticas │ │ │ ├── controllers/ -│ │ ├── application_controller.rb # Auth + Pundit + tema -│ │ ├── users/sessions_controller.rb # PIN login + redirect por role -│ │ ├── dashboard_controller.rb # Cálculos ao vivo (Fase 3) -│ │ ├── usuarios_controller.rb # CRUD usuários -│ │ ├── configuracoes_controller.rb # Preços + toggle tema -│ │ └── motorista_controller.rb # Painel do motorista -│ │ +│ │ └── application_controller.rb # Auth + Pundit + tema │ ├── policies/ -│ │ ├── application_policy.rb # Base Pundit + helpers admin?/gerente?/operador? -│ │ ├── user_policy.rb # Admin vê tudo; gerente não vê admins -│ │ └── configuracao_policy.rb # Apenas admin edita preços -│ │ +│ │ └── application_policy.rb # Base Pundit │ ├── helpers/ -│ │ └── application_helper.rb # moeda(), nav_link_to(), badge_status(), badge_role(), progress_bar() -│ │ -│ └── views/ -│ ├── devise/sessions/new.html.erb # Login com teclado PIN + abas E-mail/PIN -│ ├── layouts/ -│ │ ├── application.html.erb # Layout dark/light + sidebar responsiva -│ │ └── _sidebar.html.erb # Navegação condicional por role -│ ├── dashboard/index.html.erb # Cards grandes + Chart.js + ranking -│ ├── usuarios/ # index, new, edit, _form -│ ├── configuracoes/ # index (cards de preço), edit -│ ├── motorista/index.html.erb # Painel simplificado do motorista -│ └── shared/_flash.html.erb +│ │ └── application_helper.rb # nav_link_to, moeda(), badge_status(), progress_bar() +│ └── views/layouts/ +│ ├── application.html.erb # Layout base dark theme laranja/preto/branco +│ └── _navbar.html.erb # Sidebar responsiva com hamburguer mobile │ └── db/ - ├── seeds.rb # Admin/Gerente/Operador + 3 motoristas com PIN + ├── seeds.rb # Admin: admin@gade.com / Gade@2026! + configs de preço └── migrate/ ├── ..._create_users.rb ├── ..._create_configuracoes.rb @@ -85,8 +67,7 @@ logistica-controle-custos/ ├── ..._create_consolidacao_motoristas.rb ├── ..._create_consolidacao_entregas.rb ├── ..._create_historico_estimados.rb - ├── ..._create_auditoria_logs.rb - └── ..._add_fase2_fields_to_users.rb # PIN + ativo + trackable + lockable + └── ..._create_auditoria_logs.rb ``` --- @@ -94,41 +75,35 @@ logistica-controle-custos/ ## 🚀 Setup do zero (próximo dev) ### 1. Clone - ```bash -git clone https://git.xenserver.com.br/victor/Reem-Notas.git -cd Reem-Notas +git clone https://git.xenserver.com.br/Cludio-code/logistica-controle-custos.git +cd logistica-controle-custos ``` ### 2. Configure o .env - ```bash cp .env.example .env nano .env # preencha DB_HOST, DB_PASSWORD, etc. ``` ### 3. Gere o SECRET_KEY_BASE - ```bash docker run --rm ruby:3.2.2-slim bash -c "gem install rails --no-doc -q && rails secret" # Cole o resultado no .env → SECRET_KEY_BASE=... ``` ### 4. Suba os containers - ```bash docker-compose up -d ``` ### 5. Banco + seeds - ```bash docker-compose exec app bundle exec rails db:create db:migrate db:seed ``` ### 6. Acesse - -```text +``` http://localhost:3000 Login: admin@gade.com Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO @@ -149,98 +124,99 @@ docker-compose down # parar tudo ## 🗺️ Fases de implementação -<<<<<<< HEAD | Fase | Status | O que fazer | |------|--------|-------------| | **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base | -| **2** | ✅ **CONCLUÍDA** | Login por role + PIN motorista + CRUD usuários + pilares de preço + sidebar | -| **3** | ✅ **CONCLUÍDA** | Dashboard: cards grandes laranja/preto/branco + gráfico Chart.js + cálculo ao vivo | -| **4** | ⏳ Próxima | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog | -| **5** | ⏳ | Nova Consolidação + Wizard Passo 1 (selecionar motorista) | -| **6** | ⏳ | Toggle switches coloridos + ações em massa + barra de progresso (Stimulus) | -| **7** | ⏳ | PDFs com Prawn: relatório individual + holerite com QR code | -| **8** | ⏳ | Painel motorista + notificações WhatsApp/email + responsividade final | -======= -| Fase | Status | O que foi feito / fazer | -| ----- | ---------------- | ------------------------------------------------------------------------------------------- | -| **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base | -| **2** | ✅ **CONCLUÍDA** | Login por role + PIN motorista + CRUD usuários + pilares de preço + toggle tema + Pundit | -| **3** | ✅ **CONCLUÍDA** | Dashboard: cards laranja/preto/branco + gráfico Chart.js + ranking motoristas + cálculo ao vivo | -| **4** | ⏳ Próxima | Job Whenever (1h) + HistoricoEstimado + UI de AuditoriaLog | -| **5** | ⏳ | Nova Consolidação + Wizard Passo 1 (selecionar motorista) | -| **6** | ⏳ | Toggle switches coloridos + ações em massa + barra de progresso (Stimulus) | -| **7** | ⏳ | PDFs com Prawn: relatório individual + holerite com QR code | -| **8** | ⏳ | Painel motorista + notificações WhatsApp/email + responsividade final | ->>>>>>> 63b7f4d (Atualização README - Fase 2 e 3) +| **2** | ⏳ Pendente | Login por role + PIN motorista + CRUD usuários + pilares de preço + sidebar | +| **3** | ⏳ Pendente | Dashboard: cards grandes laranja/preto/branco + gráfico Chart.js + cálculo ao vivo | +| **4** | ✅ **CONCLUÍDA** | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas | +| **5** | ✅ **CONCLUÍDA** | Nova Consolidação + filtros + Wizard Passo 1 (selecionar motorista) | +| **6** | ✅ **CONCLUÍDA** | Toggles coloridos + ações em massa + barra progresso + Wizard Passos 2 e 3 (Stimulus) | +| **7** | ⏳ Pendente | PDFs com Prawn: relatório individual + holerite com QR code | +| **8** | ⏳ Pendente | Painel motorista + notificações WhatsApp/email + responsividade final | ---- +### 📂 Arquivos das Fases 4, 5 e 6 -## 👥 Perfis de acesso +**Fase 4 — Job 1h + Auditoria:** +``` +config/schedule.rb # Whenever — roda a cada 1h +lib/tasks/historico.rake # rake historico:atualizar +app/jobs/application_job.rb +app/jobs/atualizar_historico_estimado_job.rb # conta entregas pagas × preco_entrega +app/controllers/api/v1/dashboard_controller.rb # GET /api/v1/dashboard/metricas +app/controllers/concerns/auditavel.rb # auditar!(:acao, registro) nos controllers +app/policies/dashboard_policy.rb +``` +Ativar o cron no servidor: `bundle exec whenever --update-crontab` +Testar manualmente: `docker-compose exec app bundle exec rake historico:atualizar` -| Perfil | Login | Acesso | Vai para | -| --------- | ------------------- | ----------------------------------- | --------------- | -| Admin | email + senha | Tudo | `/dashboard` | -| Gerente | email + senha | Dashboard + Consolidações + PDFs | `/dashboard` | -| Operador | email + senha | Consolidações (criar + validar) | `/dashboard` | -| Motorista | PIN 4 dígitos | Painel pessoal + baixar próprio PDF | `/motorista` | +**Fase 5 — Consolidação + Wizard Passo 1:** +``` +db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb # rodar db:migrate! +app/controllers/consolidacoes_controller.rb # index/new/create/show/wizard/finalizar/arquivar +app/policies/consolidacao_policy.rb +app/views/consolidacoes/index.html.erb # lista com filtros (status/nome/período/motorista/rota) +app/views/consolidacoes/new.html.erb # form: nome + 2 date pickers + multi-select motoristas/rotas +app/views/consolidacoes/wizard.html.erb # Passo 1: lista motoristas com progresso +``` -### Credenciais de teste (seeds) +**Fase 6 — Validação + Toggles + Massa:** +``` +app/controllers/consolidacao_entregas_controller.rb # validar/classificar/classificar_em_massa/revisar +app/views/consolidacao_entregas/validar.html.erb # Passo 2: toggles coloridos + checkbox massa + resumo lateral +app/views/consolidacao_entregas/revisar.html.erb # Passo 3: resumo + próximo motorista / salvar rascunho +app/javascript/controllers/validacao_controller.js # Stimulus: tempo real (progresso, resumo, toggles) +config/routes.rb # ATUALIZADO — rotas do wizard +``` -| Usuário | Login | Senha / PIN | -| ---------------- | ------------------ | ------------ | -| Admin | admin@gade.com | Gade@2026! | -| Gerente | gerente@gade.com | Gade@2026! | -| Operador | operador@gade.com | Gade@2026! | -| Motorista João | PIN | 1234 | -| Motorista Maria | PIN | 5678 | -| Motorista Carlos | PIN | 9012 | +**Cores dos toggles (padrão do projeto):** +🟧 Laranja = Entrega Normal · 🟫 Laranja escuro = Retirada · ⬜ Branco = Bônus · ⬛ Preto = Desconto -> ⚠️ **Altere todas as senhas no primeiro acesso em produção.** +**Regras implementadas:** +- Entregas elegíveis: `status='completed'` AND `checkin IS NOT NULL` (somente leitura da tabela existente) +- Não permite finalizar consolidação com entregas não classificadas (botão desabilitado + validação server-side) +- Consolidação finalizada não pode ser editada por operadores (Pundit) +- Todas ações críticas registradas em `auditoria_logs` ---- - -### ▶️ Para continuar da Fase 4 +### ▶️ Para continuar (Fases 2, 3, 7 ou 8) Cole este contexto no início da conversa com o Claude: -```text -Execute a FASE 4 do Sistema de Controle de Custos Logística da Gade Hospitalar. +``` +Continue o Sistema de Controle de Custos Logística da Gade Hospitalar. -CHECKPOINT FASES 1-3 (concluídas): -- Rails 7+ com Docker, PostgreSQL 15, Tailwind (preto #0a0a0a / laranja #f97316) -- Models: Entrega (read-only), User (Devise + roles + PIN), Configuracao, - Consolidacao, ConsolidacaoMotorista, ConsolidacaoEntrega, - HistoricoEstimado, AuditoriaLog -- Devise com login por e-mail (admin/gerente/operador) e PIN 4 dígitos (motorista) -- Pundit: UserPolicy, ConfiguracaoPolicy, ApplicationPolicy -- Dashboard com Chart.js: cards KPI, gráfico diário, ranking motoristas, grid locais -- CRUD de usuários com toggle ativo/inativo e formulário dinâmico por role -- Configurações de preço (entrega, retirada, bonus, desconto) com cards editáveis -- Toggle tema claro/escuro salvo na sessão -- Sidebar responsiva (desktop fixa + drawer mobile) com navegação por role -- Seeds: admin/gerente/operador com e-mail + 3 motoristas com PIN +CHECKPOINT — FASES 1, 4, 5 e 6 CONCLUÍDAS: +- Rails 7+ com Docker, PostgreSQL externo, Tailwind (preto #0a0a0a / laranja #f97316) +- Models: Entrega (read-only db_reem_simplerout_2026), User (Devise+roles), + Configuracao, Consolidacao (+route_ids jsonb), ConsolidacaoMotorista, + ConsolidacaoEntrega, HistoricoEstimado, AuditoriaLog +- 8 migrations criadas +- Fase 4: Whenever (1h) + AtualizarHistoricoEstimadoJob + rake historico:atualizar + + API GET /api/v1/dashboard/metricas + concern Auditavel +- Fase 5: ConsolidacoesController completo (filtros, multi-select motoristas/rotas) + + Wizard Passo 1 (wizard.html.erb) +- Fase 6: ConsolidacaoEntregasController (validar/classificar/classificar_em_massa/revisar) + + toggles coloridos + ações em massa + Stimulus validacao_controller.js + + validação de finalização -FASE 4 deve implementar: -1. Job com Whenever: rodar a cada 1h para calcular e salvar HistoricoEstimado -2. Modelo HistoricoEstimado: snapshot do valor estimado por motorista por hora -3. Controller + view de AuditoriaLog: tabela paginada com filtro por usuário/ação -4. Indicador visual no Dashboard mostrando "última atualização" do histórico -5. Rake task manual: rails historico:calcular (para rodar fora do cron) +FALTAM: Fase 2 (auth/PIN/CRUD usuários/configurações), Fase 3 (Dashboard com +gráficos), Fase 7 (PDFs Prawn + holerite + QR code), Fase 8 (painel motorista ++ notificações). Consulte o README.md do repositório para detalhes. ``` --- ## 🗄️ Banco existente — referência rápida -| Campo | Uso | -| -------------- | ------------------------------------------ | -| `tracking_id` | PK lógico da entrega | -| `reference_id` | Número da NF | -| `planned_date` | Data da entrega (filtro principal) | -| `driver` | Nome do motorista | -| `status` | `'completed'` = entrega paga | -| `checkin` | NULL = motorista não registrou chegada | -| `route_id` | UUID da operação (UBS SUL, EMAD, etc.) | +| Campo | Uso | +|-------|-----| +| `tracking_id` | PK lógico da entrega | +| `reference_id` | Número da NF | +| `planned_date` | Data da entrega (filtro principal) | +| `driver` | Nome do motorista | +| `status` | `'completed'` = entrega paga | +| `checkin` | NULL = motorista não registrou chegada | +| `route_id` | UUID da operação (UBS SUL, EMAD, etc.) | | `contact_name` | Nome do local (UBS SACOMA, EMAD VELEIROS…) | **Regra de pagamento:** `status = 'completed'` AND `checkin IS NOT NULL` @@ -249,18 +225,29 @@ FASE 4 deve implementar: ## 🎨 Design — padrão da marca -| Elemento | Cor | Hex | -| ----------------- | ------------ | --------- | -| Fundo | Preto | `#0a0a0a` | -| Cards | Cinza escuro | `#1a1a1a` | -| Destaque / botões | Laranja | `#f97316` | -| Textos | Branco | `#ffffff` | -| Sucesso | Verde | `#22c55e` | -| Erro/desconto | Vermelho | `#ef4444` | +| Elemento | Cor | Hex | +|----------|-----|-----| +| Fundo | Preto | `#0a0a0a` | +| Cards | Cinza escuro | `#1a1a1a` | +| Destaque / botões | Laranja | `#f97316` | +| Textos | Branco | `#ffffff` | +| Sucesso | Verde | `#22c55e` | +| Erro/desconto | Vermelho | `#ef4444` | - Fonte mínima 16px desktop / 18px mobile - Botões mínimo 48px altura (acessibilidade touch) -- Tema escuro como padrão (toggle disponível no header) +- Tema escuro como padrão + +--- + +## 👥 Perfis de acesso + +| Perfil | Login | Acesso | +|--------|-------|--------| +| Admin | email + senha | Tudo | +| Gerente | email + senha | Dashboard + Consolidações + PDFs | +| Operador | email + senha | Consolidações (criar + validar) | +| Motorista | PIN 4 dígitos ou QR | Painel pessoal + baixar próprio PDF | --- @@ -269,4 +256,3 @@ FASE 4 deve implementar: - **NUNCA** versione `.env`, `config/master.key` ou qualquer arquivo com senha - Repositório **PRIVADO** em git.xenserver.com.br - Use sempre `ENV['VARIAVEL']` no código Ruby -- Todas as actions protegidas por Pundit (`authorize` obrigatório) diff --git a/app/controllers/api/v1/dashboard_controller.rb b/app/controllers/api/v1/dashboard_controller.rb new file mode 100644 index 0000000..20843c3 --- /dev/null +++ b/app/controllers/api/v1/dashboard_controller.rb @@ -0,0 +1,30 @@ +# app/controllers/api/v1/dashboard_controller.rb +module Api + module V1 + class DashboardController < ApplicationController + # GET /api/v1/dashboard/metricas + def metricas + authorize :dashboard, :metricas? + + ultimo = HistoricoEstimado.recentes.first + vivo = HistoricoEstimado.calcular_ao_vivo + + render json: { + ultimo_registro: ultimo && { + data_hora: ultimo.data_hora, + valor_estimado: ultimo.valor_total_estimado.to_f, + entregas: ultimo.entregas_contadas + }, + ao_vivo: { + valor_mes: vivo[:valor], + entregas_mes: vivo[:entregas] + }, + historico_24h: HistoricoEstimado + .where(data_hora: 24.hours.ago..Time.current) + .order(:data_hora) + .map { |h| { hora: h.data_hora.strftime('%H:%M'), valor: h.valor_total_estimado.to_f } } + } + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ba5ff14..6110295 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,26 +4,24 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! before_action :set_tema - after_action :verify_authorized, except: :index, unless: :skip_pundit? - after_action :verify_policy_scoped, only: :index, unless: :skip_pundit? - rescue_from Pundit::NotAuthorizedError, with: :usuario_nao_autorizado - - protect_from_forgery with: :exception + # Pundit: redireciona se não autorizado + rescue_from Pundit::NotAuthorizedError do |e| + flash[:alert] = "Você não tem permissão para realizar esta ação." + redirect_to root_path + end private def set_tema - # Lê o tema da sessão (padrão: escuro) - @tema = session[:tema] || 'dark' + @tema = current_user&.tema_preferido || 'dark' end - def skip_pundit? - devise_controller? || params[:controller] =~ /rails\/|action_mailbox|action_text/ - end - - def usuario_nao_autorizado - flash[:alert] = 'Acesso negado. Você não tem permissão para esta ação.' - redirect_back(fallback_location: dashboard_path) + def after_sign_in_path_for(resource) + if resource.motorista? + motorista_dashboard_path + else + dashboard_path + end end end diff --git a/app/controllers/concerns/auditavel.rb b/app/controllers/concerns/auditavel.rb new file mode 100644 index 0000000..59ecfd5 --- /dev/null +++ b/app/controllers/concerns/auditavel.rb @@ -0,0 +1,22 @@ +# app/controllers/concerns/auditavel.rb +# +# Concern para registrar ações críticas na AuditoriaLog. +# Uso no controller: +# include Auditavel +# auditar!(:criar, @consolidacao, dados_novos: @consolidacao.attributes) +# +module Auditavel + extend ActiveSupport::Concern + + def auditar!(acao, registro, dados_anteriores: {}, dados_novos: {}) + AuditoriaLog.registrar( + user: current_user, + acao: acao, + entidade: registro.class.name, + entidade_id: registro.id, + dados_anteriores: dados_anteriores, + dados_novos: dados_novos, + request: request + ) + end +end diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb new file mode 100644 index 0000000..c1f9dc0 --- /dev/null +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -0,0 +1,168 @@ +# app/controllers/consolidacao_entregas_controller.rb +class ConsolidacaoEntregasController < ApplicationController + include Auditavel + + before_action :set_consolidacao + before_action :bloquear_se_finalizada, except: %i[validar revisar] + + # GET /consolidacoes/:consolidacao_id/consolidacao_entregas/validar?motorista=X + # WIZARD PASSO 2 — tela de validação com toggles + def validar + authorize @consolidacao, :show? + @motorista = params[:motorista] + + # Entregas pagas do período no banco existente (read-only) + @entregas = Entrega.pagas + .da_conta_gade + .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) + .do_motorista(@motorista) + @entregas = @entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? + @entregas = @entregas.order(:planned_date) + + # Classificações já feitas — indexadas por tracking_id + @classificacoes = @consolidacao.consolidacao_entregas + .where(motorista_nome: @motorista) + .index_by(&:tracking_id) + + # Filtro "apenas não classificadas" + if params[:apenas_pendentes] == '1' + @entregas = @entregas.reject { |e| @classificacoes.key?(e.tracking_id) } + end + + @precos = { + entrega_normal: Configuracao.preco_entrega, + retirada: Configuracao.preco_retirada, + bonus: Configuracao.preco_bonus, + desconto: Configuracao.preco_desconto + } + end + + # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar + # Classifica UMA entrega (chamado pelo toggle via Turbo/fetch) + def classificar + authorize @consolidacao, :update? + + ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: params[:tracking_id]) + ce.motorista_nome = params[:motorista] + ce.tipo = params[:tipo] + ce.valor_aplicado = valor_para_tipo(params[:tipo]) + ce.created_by = current_user.id + ce.save! + + recalcular_motorista(params[:motorista]) + + respond_to do |format| + format.json { render json: resumo_json(params[:motorista]) } + format.html { redirect_back fallback_location: wizard_consolidacao_path(@consolidacao) } + end + end + + # POST /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar_em_massa + # Ações em massa: marcar todos / marcar selecionados + def classificar_em_massa + authorize @consolidacao, :update? + + tipo = params[:tipo] + motorista = params[:motorista] + tracking_ids = Array(params[:tracking_ids]).reject(&:blank?) + + # "Marcar todos como Normal" → sem tracking_ids = todas do período + if tracking_ids.empty? + entregas = Entrega.pagas.da_conta_gade + .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) + .do_motorista(motorista) + entregas = entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? + tracking_ids = entregas.pluck(:tracking_id) + end + + valor = valor_para_tipo(tipo) + + ActiveRecord::Base.transaction do + tracking_ids.each do |tid| + ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid) + ce.assign_attributes(motorista_nome: motorista, tipo: tipo, + valor_aplicado: valor, created_by: current_user.id) + ce.save! + end + end + + recalcular_motorista(motorista) + auditar!(:editar, @consolidacao, + dados_novos: { acao_massa: tipo, qtd: tracking_ids.size, motorista: motorista }) + + respond_to do |format| + format.json { render json: resumo_json(motorista) } + format.html do + redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista), + notice: "#{tracking_ids.size} entregas marcadas como #{tipo.humanize}." + end + end + end + + # GET /consolidacoes/:consolidacao_id/consolidacao_entregas/revisar?motorista=X + # WIZARD PASSO 3 — revisão do motorista + def revisar + authorize @consolidacao, :show? + @motorista = params[:motorista] + + @classificacoes = @consolidacao.consolidacao_entregas + .where(motorista_nome: @motorista) + .order(:created_at) + + @resumo = @classificacoes.group(:tipo).count + @total = @classificacoes.sum do |c| + c.tipo == 'desconto' ? -c.valor_aplicado : c.valor_aplicado + end + + # Próximo motorista pendente (para botão "Próximo Motorista") + nomes_completos = @consolidacao.consolidacao_motoristas.map(&:motorista_nome) + @proximo = nomes_completos.find do |nome| + next false if nome == @motorista + total = Entrega.contar_pagas(inicio: @consolidacao.data_inicio, + fim: @consolidacao.data_fim, motorista: nome) + @consolidacao.consolidacao_entregas.where(motorista_nome: nome).count < total + end + end + + private + + def set_consolidacao + @consolidacao = Consolidacao.find(params[:consolidacao_id]) + end + + def bloquear_se_finalizada + return unless @consolidacao.finalizada? && current_user.operador? + redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.' + end + + def valor_para_tipo(tipo) + case tipo.to_s + when 'entrega_normal' then Configuracao.preco_entrega + when 'retirada' then Configuracao.preco_retirada + when 'bonus' then Configuracao.preco_bonus + when 'desconto' then Configuracao.preco_desconto + else 0 + end + end + + def recalcular_motorista(nome) + cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: nome) + return unless cm + + total = @consolidacao.consolidacao_entregas.where(motorista_nome: nome).sum do |e| + e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado + end + cm.update_column(:valor_total, total) + @consolidacao.update_column(:valor_total, @consolidacao.consolidacao_motoristas.sum(:valor_total)) + end + + def resumo_json(motorista) + classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista) + { + ok: true, + classificadas: classificacoes.count, + por_tipo: classificacoes.group(:tipo).count, + valor_total: classificacoes.sum { |e| e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado }.to_f + } + end +end diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb new file mode 100644 index 0000000..c04c3ac --- /dev/null +++ b/app/controllers/consolidacoes_controller.rb @@ -0,0 +1,131 @@ +# app/controllers/consolidacoes_controller.rb +class ConsolidacoesController < ApplicationController + include Auditavel + + before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar wizard] + + # GET /consolidacoes — lista com filtros + def index + authorize Consolidacao + + @consolidacoes = Consolidacao.ativas.recentes.includes(:criador) + + # Filtros + @consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present? + @consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present? + @consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present? + @consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present? + + if params[:motorista].present? + ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%") + .pluck(:consolidacao_id) + @consolidacoes = @consolidacoes.where(id: ids) + end + + if params[:route_id].present? + @consolidacoes = @consolidacoes.where('route_ids @> ?', [params[:route_id]].to_json) + end + end + + # GET /consolidacoes/new + def new + authorize Consolidacao + @consolidacao = Consolidacao.new( + data_inicio: Date.current.beginning_of_month, + data_fim: Date.current + ) + @motoristas = Entrega.motoristas_ativos + @rotas = Entrega.rotas_unicas + end + + # POST /consolidacoes + def create + authorize Consolidacao + @consolidacao = Consolidacao.new(consolidacao_params.merge(created_by: current_user.id)) + + motoristas = Array(params[:motoristas]).reject(&:blank?) + + if motoristas.empty? + @consolidacao.errors.add(:base, 'Selecione ao menos um motorista') + preparar_form_new + return render :new, status: :unprocessable_entity + end + + ActiveRecord::Base.transaction do + @consolidacao.save! + motoristas.each do |nome| + @consolidacao.consolidacao_motoristas.create!(motorista_nome: nome) + end + end + + auditar!(:criar, @consolidacao, dados_novos: @consolidacao.attributes) + redirect_to wizard_consolidacao_path(@consolidacao), notice: 'Consolidação criada! Selecione o motorista para validar.' + rescue ActiveRecord::RecordInvalid + preparar_form_new + render :new, status: :unprocessable_entity + end + + # GET /consolidacoes/:id — visão geral + def show + authorize @consolidacao + @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome) + end + + # GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista + def wizard + authorize @consolidacao, :update? + + @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm| + total = Entrega.contar_pagas( + inicio: @consolidacao.data_inicio, + fim: @consolidacao.data_fim, + motorista: cm.motorista_nome + ) + classificadas = @consolidacao.consolidacao_entregas + .where(motorista_nome: cm.motorista_nome) + .count + { + registro: cm, + total: total, + classificadas: classificadas, + completo: total.positive? && classificadas >= total + } + end + end + + # POST /consolidacoes/:id/finalizar + def finalizar + authorize @consolidacao, :update? + + if @consolidacao.finalizar!(current_user) + auditar!(:finalizar, @consolidacao) + redirect_to @consolidacao, notice: 'Consolidação finalizada com sucesso!' + else + redirect_to wizard_consolidacao_path(@consolidacao), + alert: 'Não é possível finalizar: existem entregas não classificadas.' + end + end + + # POST /consolidacoes/:id/arquivar + def arquivar + authorize @consolidacao, :destroy? + @consolidacao.arquivar!(current_user) + auditar!(:arquivar, @consolidacao) + redirect_to consolidacoes_path, notice: 'Consolidação arquivada.' + end + + private + + def set_consolidacao + @consolidacao = Consolidacao.find(params[:id]) + end + + def preparar_form_new + @motoristas = Entrega.motoristas_ativos + @rotas = Entrega.rotas_unicas + end + + def consolidacao_params + params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, route_ids: []) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index daf28b7..55e1dc5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,82 +1,46 @@ # app/helpers/application_helper.rb module ApplicationHelper - # Formata valor monetário → "R$ 1.250,00" + # Link de navegação com estado ativo + def nav_link_to(label, path, **opts) + active = current_page?(path) + css = [ + "flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px]", + active ? "bg-orange-500 text-black" : "text-gray-400 hover:bg-[#1a1a1a] hover:text-white" + ].join(" ") + link_to label, path, class: css + end + + # Formata moeda BR def moeda(valor) - "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" + "R$ #{"%.2f" % valor.to_f}".gsub('.', ',') end - # Link ativo na sidebar - def nav_link_to(nome, path, icon: nil, **opts) - ativo = current_page?(path) || request.path.start_with?(path.to_s) - classes = [ - 'flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all', - ativo ? 'bg-[#f97316] text-white shadow-lg shadow-orange-500/20' - : 'text-gray-400 hover:text-white hover:bg-white/5' - ].join(' ') - - link_to path, class: classes, **opts do - concat content_tag(:span, icon, class: 'text-lg w-5 text-center') if icon - concat content_tag(:span, nome) - end - end - - # Badge colorido por status de consolidação + # Badge de status de consolidação def badge_status(status) - config = { - 'aberta' => { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Aberta' }, - 'em_revisao' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Em Revisão' }, - 'fechada' => { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Fechada' }, - 'aprovada' => { bg: 'bg-[#f97316]/20', text: 'text-[#f97316]', label: 'Aprovada' }, - 'cancelada' => { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Cancelada' } - } - c = config[status.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: status.to_s.humanize } - content_tag(:span, c[:label], - class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}") - end + cfg = case status.to_s + when 'rascunho' then { cor: 'bg-yellow-500 text-black', icone: '📝', label: 'Rascunho' } + when 'finalizada' then { cor: 'bg-green-600 text-white', icone: '✅', label: 'Finalizada' } + when 'arquivada' then { cor: 'bg-gray-700 text-gray-300', icone: '📁', label: 'Arquivada' } + else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.humanize } + end - # Badge de role do usuário - def badge_role(role) - config = { - 'admin' => { bg: 'bg-purple-500/20', text: 'text-purple-400', label: 'Admin' }, - 'gerente' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Gerente' }, - 'operador' => { bg: 'bg-cyan-500/20', text: 'text-cyan-400', label: 'Operador' }, - 'motorista'=> { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Motorista' } - } - c = config[role.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: role.to_s.humanize } - content_tag(:span, c[:label], - class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}") - end - - # Badge de status ativo/inativo do usuário - def badge_status_usuario(ativo) - if ativo - content_tag(:span, 'Ativo', - class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-400') - else - content_tag(:span, 'Inativo', - class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-red-500/20 text-red-400') + tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do + "#{cfg[:icone]} #{cfg[:label]}" end end - # Barra de progresso - def progress_bar(percentual, color: '#f97316') - perc = [[percentual.to_i, 0].max, 100].min - content_tag(:div, class: 'w-full bg-white/10 rounded-full h-2') do - content_tag(:div, '', - class: 'h-2 rounded-full transition-all duration-500', - style: "width: #{perc}%; background-color: #{color}") + # Barra de progresso laranja + def progress_bar(percentual, label: nil) + pct = percentual.to_i.clamp(0, 100) + tag.div class: "w-full" do + concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") { + concat tag.span(label || "#{pct}% classificado") + concat tag.span("#{pct}%") + } + concat tag.div(class: "w-full bg-[#2a2a2a] rounded-full h-2") { + tag.div style: "width: #{pct}%", + class: "h-2 rounded-full bg-orange-500 transition-all duration-500" + } end end - - # Opções de role para select - def role_options_for_select - User.roles.keys.map { |r| [r.humanize, r] } - end - - # Classe base para inputs - def input_class - 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white ' \ - 'placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 ' \ - 'focus:ring-[#f97316] transition-colors' - end end diff --git a/app/javascript/controllers/validacao_controller.js b/app/javascript/controllers/validacao_controller.js new file mode 100644 index 0000000..e04cf25 --- /dev/null +++ b/app/javascript/controllers/validacao_controller.js @@ -0,0 +1,145 @@ +// app/javascript/controllers/validacao_controller.js +// Stimulus controller — Wizard Passo 2 (validação de entregas) +// Atualização em tempo real: toggles, ações em massa, barra de progresso, resumo lateral. + +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "linha", "checkbox", "toggles", "barra", "percentual", + "contadorClassificadas", "contadorTotal", + "qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "valorTotal" + ] + + static values = { + url: String, // POST classificar + massaUrl: String, // POST classificar_em_massa + motorista: String, + total: Number + } + + connect() { + // Carrega estado inicial renderizado pelo servidor + const el = document.getElementById("validacao-estado-inicial") + if (el) { + const estado = JSON.parse(el.textContent) + this.atualizarResumo(estado) + } + } + + // ── Toggle individual ────────────────────────────────────── + async classificar(event) { + const btn = event.currentTarget + const tracking = btn.dataset.tracking + const tipo = btn.dataset.tipo + + btn.disabled = true + try { + const resp = await this.post(this.urlValue, { + tracking_id: tracking, + tipo: tipo, + motorista: this.motoristaValue + }) + this.marcarToggleAtivo(tracking, tipo) + this.atualizarResumo(resp) + } catch (e) { + alert("Erro ao classificar entrega. Tente novamente.") + } finally { + btn.disabled = false + } + } + + // ── Marcar todos como Normal ─────────────────────────────── + async marcarTodos(event) { + const tipo = event.currentTarget.dataset.tipo + if (!confirm(`Marcar TODAS as entregas como ${this.labelTipo(tipo)}?`)) return + + const resp = await this.post(this.massaUrlValue, { + tipo: tipo, + motorista: this.motoristaValue, + tracking_ids: [] // vazio = todas + }) + this.linhaTargets.forEach(l => this.marcarToggleAtivo(l.dataset.tracking, tipo)) + this.atualizarResumo(resp) + } + + // ── Marcar selecionados como X ───────────────────────────── + async marcarSelecionados(event) { + const tipo = event.currentTarget.dataset.tipo + const ids = this.checkboxTargets.filter(c => c.checked).map(c => c.value) + + if (ids.length === 0) { + alert("Selecione ao menos uma entrega (checkbox à esquerda).") + return + } + + const resp = await this.post(this.massaUrlValue, { + tipo: tipo, + motorista: this.motoristaValue, + tracking_ids: ids + }) + ids.forEach(id => this.marcarToggleAtivo(id, tipo)) + this.checkboxTargets.forEach(c => c.checked = false) + this.atualizarResumo(resp) + } + + // ── Helpers ──────────────────────────────────────────────── + + async post(url, body) { + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify(body) + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + return resp.json() + } + + marcarToggleAtivo(tracking, tipo) { + const grupo = this.togglesTargets.find(t => t.dataset.tracking === tracking) + if (!grupo) return + grupo.querySelectorAll(".toggle-btn").forEach(b => { + const ativo = b.dataset.tipo === tipo + b.classList.toggle("ring-2", ativo) + b.classList.toggle("ring-green-400", ativo) + b.classList.toggle("scale-105", ativo) + b.classList.toggle("opacity-50", !ativo) + }) + const linha = this.linhaTargets.find(l => l.dataset.tracking === tracking) + if (linha) linha.dataset.classificada = "1" + } + + atualizarResumo(dados) { + const porTipo = dados.por_tipo || {} + this.qtdNormalTarget.textContent = porTipo["entrega_normal"] || 0 + this.qtdRetiradaTarget.textContent = porTipo["retirada"] || 0 + this.qtdBonusTarget.textContent = porTipo["bonus"] || 0 + this.qtdDescontoTarget.textContent = porTipo["desconto"] || 0 + + const valor = dados.valor_total || 0 + this.valorTotalTarget.textContent = + "R$ " + valor.toFixed(2).replace(".", ",") + + const classificadas = dados.classificadas || 0 + this.contadorClassificadasTarget.textContent = classificadas + + const total = this.totalValue || parseInt(this.contadorTotalTarget.textContent) + const pct = total > 0 ? Math.round((classificadas / total) * 100) : 0 + this.barraTarget.style.width = `${Math.min(pct, 100)}%` + this.percentualTarget.textContent = `${Math.min(pct, 100)}%` + + if (pct >= 100) { + this.barraTarget.classList.remove("bg-orange-500") + this.barraTarget.classList.add("bg-green-500") + } + } + + labelTipo(tipo) { + return { entrega_normal: "Normal", retirada: "Retirada", + bonus: "Bônus", desconto: "Desconto" }[tipo] || tipo + } +} diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..fa824ee --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,5 @@ +# app/jobs/application_job.rb +class ApplicationJob < ActiveJob::Base + retry_on ActiveRecord::Deadlocked + discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/atualizar_historico_estimado_job.rb b/app/jobs/atualizar_historico_estimado_job.rb new file mode 100644 index 0000000..4a6708d --- /dev/null +++ b/app/jobs/atualizar_historico_estimado_job.rb @@ -0,0 +1,24 @@ +# app/jobs/atualizar_historico_estimado_job.rb +# +# Roda a cada 1 hora (via Whenever → rake historico:atualizar). +# Lógica: conta entregas do dia com status='completed' E checkin IS NOT NULL, +# multiplica pelo preco_entrega configurado e persiste em historico_estimados. +# +class AtualizarHistoricoEstimadoJob < ApplicationJob + queue_as :default + + def perform + preco = Configuracao.preco_entrega + + entregas = Entrega.da_conta_gade + .pagas + .where(planned_date: Date.current) + .count + + HistoricoEstimado.create!( + data_hora: Time.current, + valor_total_estimado: entregas * preco, + entregas_contadas: entregas + ) + end +end diff --git a/app/models/auditoria_log.rb b/app/models/auditoria_log.rb index c5bac93..8e44e43 100644 --- a/app/models/auditoria_log.rb +++ b/app/models/auditoria_log.rb @@ -1,22 +1,29 @@ # app/models/auditoria_log.rb class AuditoriaLog < ApplicationRecord - belongs_to :user, optional: true + belongs_to :user, optional: true # opcional caso user seja deletado - validates :acao, presence: true + ACOES = %w[criar editar finalizar arquivar excluir login logout].freeze + + validates :acao, presence: true + validates :entidade, presence: true scope :recentes, -> { order(created_at: :desc) } - scope :do_usuario, ->(user) { where(user: user) } + scope :por_user, ->(user_id) { where(user_id: user_id) } + scope :por_entidade, ->(entidade) { where(entidade: entidade) } - # Cria um log de auditoria de forma simples - # AuditoriaLog.registrar(current_user, 'criar_consolidacao', ip, detalhes: '...') - def self.registrar(user, acao, ip = nil, detalhes: nil) + def self.registrar(user:, acao:, entidade:, entidade_id: nil, + dados_anteriores: {}, dados_novos: {}, request: nil) create!( - user: user, - acao: acao, - ip: ip, - detalhes: detalhes + user_id: user&.id, + acao: acao.to_s, + entidade: entidade.to_s, + entidade_id: entidade_id, + dados_anteriores: dados_anteriores, + dados_novos: dados_novos, + ip_address: request&.remote_ip, + user_agent: request&.user_agent ) - rescue ActiveRecord::RecordInvalid => e - Rails.logger.warn("AuditoriaLog falhou: #{e.message}") + rescue => e + Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}") end end diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index e46642f..ccd8756 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -1,39 +1,55 @@ # app/models/configuracao.rb class Configuracao < ApplicationRecord - TIPOS = %w[entrega retirada bonus desconto].freeze + # Chaves válidas do sistema + CHAVES = %w[ + preco_entrega + preco_retirada + preco_bonus + preco_desconto + notificacao_whatsapp + notificacao_email + empresa_nome + empresa_logo + ].freeze - enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 } + CHAVES_MOEDA = %w[ + preco_entrega + preco_retirada + preco_bonus + preco_desconto + ].freeze - validates :tipo, presence: true, uniqueness: true - validates :valor, presence: true, - numericality: { greater_than_or_equal_to: 0 } + validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true + validates :valor, presence: true - LABELS = { - 'entrega' => 'Entrega Normal', - 'retirada' => 'Retirada', - 'bonus' => 'Bônus', - 'desconto' => 'Desconto' - }.freeze + # ── Acesso rápido ─────────────────────────────────────────── - ICONES = { - 'entrega' => '🚚', - 'retirada' => '📦', - 'bonus' => '⭐', - 'desconto' => '🔻' - }.freeze - - def tipo_label - LABELS[tipo] || tipo.humanize + def self.valor(chave) + find_by(chave: chave)&.valor end - def icone - ICONES[tipo] || '💰' + def self.preco_entrega + valor('preco_entrega').to_f end - # Retorna um hash { entrega: 10.50, retirada: 8.00, bonus: 5.00, desconto: 2.00 } - def self.mapa_de_precos - all.each_with_object({}) do |c, h| - h[c.tipo.to_sym] = c.valor.to_f - end + def self.preco_retirada + valor('preco_retirada').to_f + end + + def self.preco_bonus + valor('preco_bonus').to_f + end + + def self.preco_desconto + valor('preco_desconto').to_f + end + + def moeda? + CHAVES_MOEDA.include?(chave) + end + + def valor_formatado + return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda? + valor end end diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index 93a23e7..dd883a4 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -1,34 +1,75 @@ # app/models/consolidacao.rb class Consolidacao < ApplicationRecord - # Soft delete - acts_as_paranoid if respond_to?(:acts_as_paranoid) + belongs_to :criador, class_name: 'User', foreign_key: :created_by + belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true - enum status: { aberta: 0, em_revisao: 1, fechada: 2, aprovada: 3, cancelada: 4 } - - belongs_to :criado_por, class_name: 'User', foreign_key: :user_id has_many :consolidacao_motoristas, dependent: :destroy - has_many :consolidacao_entregas, through: :consolidacao_motoristas - has_many :motoristas, through: :consolidacao_motoristas, source: :user + has_many :consolidacao_entregas, dependent: :destroy - validates :referencia_mes, presence: true - validates :status, presence: true + # ── Enums ─────────────────────────────────────────────────── + enum status: { rascunho: 0, finalizada: 1, arquivada: 2 } - scope :do_mes, ->(data = Date.today) { - where(referencia_mes: data.beginning_of_month..data.end_of_month) - } + # ── Soft delete ───────────────────────────────────────────── + scope :ativas, -> { where(deleted_at: nil) } + scope :arquivadas, -> { where.not(deleted_at: nil) } - scope :abertas, -> { where(status: :aberta) } - scope :fechadas, -> { where(status: [:fechada, :aprovada]) } + # ── Validações ────────────────────────────────────────────── + validates :nome, presence: true + validates :data_inicio, presence: true + validates :data_fim, presence: true + validates :created_by, presence: true + validate :periodo_valido - def progresso_percentual - return 0 if consolidacao_motoristas.none? + # ── Callbacks ─────────────────────────────────────────────── + before_save :recalcular_valor_total - finalizados = consolidacao_motoristas.where(finalizado: true).count - total = consolidacao_motoristas.count - ((finalizados.to_f / total) * 100).round + # ── Escopos ───────────────────────────────────────────────── + scope :recentes, -> { order(created_at: :desc) } + scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } + + # ── Métodos ───────────────────────────────────────────────── + + def arquivar!(user) + update!(deleted_at: Time.current, status: :arquivada) end - def mes_label - referencia_mes&.strftime('%B/%Y')&.capitalize + def finalizar!(user) + return false unless todas_entregas_classificadas? + + update!( + status: :finalizada, + finalizado_por: user.id, + finalizado_em: Time.current + ) + end + + def todas_entregas_classificadas? + total_entregas = ConsolidacaoMotorista + .where(consolidacao_id: id) + .sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) } + + consolidacao_entregas.count >= total_entregas + end + + def percentual_classificado + return 0 if total_entregas_estimadas.zero? + ((consolidacao_entregas.count.to_f / total_entregas_estimadas) * 100).round + end + + def total_entregas_estimadas + @total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm| + Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) + end + end + + private + + def periodo_valido + return unless data_inicio && data_fim + errors.add(:data_fim, 'deve ser após a data de início') if data_fim < data_inicio + end + + def recalcular_valor_total + self.valor_total = consolidacao_motoristas.sum(:valor_total) end end diff --git a/app/models/entrega.rb b/app/models/entrega.rb index 6297062..7e18d34 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -1,39 +1,88 @@ # app/models/entrega.rb -# ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026 -# NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa tabela. +# +# Model de LEITURA para a tabela existente da Gade Hospitalar. +# NUNCA criar migration para esta tabela. +# NUNCA executar INSERT, UPDATE, DELETE ou DROP nesta tabela. +# class Entrega < ApplicationRecord - self.table_name = 'db_reem_simplerout_2026' + self.table_name = 'db_reem_simplerout_2026' self.primary_key = 'tracking_id' - # Apenas leitura + # Apenas leitura — segurança contra mutações acidentais def readonly? true end - # Scopes - scope :do_account, -> { where(account_id: 95907) } - scope :pagas, -> { where(status: 'completed').where.not(checkin: nil) } - scope :pendentes, -> { where.not(status: 'completed').or(where(checkin: nil)) } + # ── Scopes ────────────────────────────────────────────────── + scope :concluidas, -> { where(status: 'completed') } + scope :com_checkin, -> { where.not(checkin: nil) } + scope :pagas, -> { concluidas.com_checkin } + scope :pendentes, -> { where.not(status: 'completed') } - scope :do_mes, ->(data = Date.today) { - do_account - .where(planned_date: data.beginning_of_month..data.end_of_month) - } - - scope :do_dia, ->(data = Date.today) { - do_account.where(planned_date: data) + scope :no_periodo, ->(inicio, fim) { + where(planned_date: inicio.to_date..fim.to_date) } scope :do_motorista, ->(nome) { - where('LOWER(driver) = ?', nome.to_s.downcase) + where(driver: nome) } - # Helpers de instância - def paga? - status == 'completed' && checkin.present? + scope :da_rota, ->(route_id) { + where(route_id: route_id) + } + + scope :da_conta_gade, -> { + where(account_id: ENV.fetch('DB_EXISTING_ACCOUNT_ID', 95907).to_i) + } + + # ── Métodos de classe ──────────────────────────────────────── + + # Lista motoristas únicos (para selects, consolidações) + def self.motoristas_ativos(inicio: nil, fim: nil) + base = da_conta_gade + base = base.no_periodo(inicio, fim) if inicio && fim + base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?) end - def data_formatada - planned_date&.strftime('%d/%m/%Y') + # Lista rotas únicas (route_id → descrição) + def self.rotas_unicas(inicio: nil, fim: nil) + base = da_conta_gade + base = base.no_periodo(inicio, fim) if inicio && fim + base.distinct.pluck(:route_id).compact + end + + # Contagem de entregas pagas para cálculo estimado + def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil) + base = pagas.da_conta_gade.no_periodo(inicio, fim) + base = base.do_motorista(motorista) if motorista.present? + base = base.da_rota(route_id) if route_id.present? + base.count + end + + # ── Helpers de instância ───────────────────────────────────── + + def numero_nf + reference_id + end + + def local + contact_name.presence || address + end + + def concluida? + status == 'completed' + end + + def checkin_registrado? + checkin.present? + end + + def elegivel_pagamento? + concluida? && checkin_registrado? + end + + def atraso_minutos + return 0 unless delay.present? + delay.to_s.split(':').then { |h, m, _s| h.to_i * 60 + m.to_i } end end diff --git a/app/models/user.rb b/app/models/user.rb index 6048369..d9bbf6c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,59 +1,69 @@ # app/models/user.rb class User < ApplicationRecord - # Devise modules - devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, - :trackable, :lockable + devise :database_authenticatable, + :registerable, + :recoverable, + :rememberable, + :validatable - # Roles + # ── Roles ────────────────────────────────────────────────── enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 } - # Validações - validates :name, presence: true - validates :role, presence: true - validates :pin_acesso, - uniqueness: { allow_blank: true }, - length: { is: 4, allow_blank: true }, - format: { with: /\A\d{4}\z/, allow_blank: true, message: 'deve ter exatamente 4 dígitos numéricos' } + ROLES_LABEL = { + 'admin' => 'Administrador', + 'gerente' => 'Gerente', + 'operador' => 'Operador', + 'motorista' => 'Motorista' + }.freeze - validate :pin_obrigatorio_para_motorista + # ── Validações ────────────────────────────────────────────── + validates :nome, presence: true, length: { minimum: 3 } + validates :role, presence: true + validates :pin_code, + length: { is: 4 }, + numericality: { only_integer: true }, + allow_nil: true, + if: :motorista? - # Callbacks - before_validation :normalizar_pin + validate :pin_unico_para_motoristas, if: :motorista? - # Associações - has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify - has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error + # Motoristas podem não ter e-mail (login via PIN) + def email_required? + !motorista? + end - # Scopes - scope :ativos, -> { where(ativo: true) } - scope :motoristas, -> { where(role: :motorista) } + def password_required? + !motorista? ? super : false + end + # ── Escopos ───────────────────────────────────────────────── + scope :ativos, -> { where(ativo: true) } + scope :por_nome, -> { order(:nome) } + scope :nao_motoristas, -> { where.not(role: :motorista) } + + # ── Helpers ───────────────────────────────────────────────── def nome_display - name.split.first.capitalize + nome.presence || email end - def active? - ativo? + def role_label + ROLES_LABEL[role] || role.humanize end - def pode_acessar_admin? + def pode_ver_config? admin? || gerente? end - def pode_criar_consolidacao? + def pode_consolidar? admin? || gerente? || operador? end private - def normalizar_pin - self.pin_acesso = pin_acesso.to_s.strip.presence - end + def pin_unico_para_motoristas + return if pin_code.blank? - def pin_obrigatorio_para_motorista - if motorista? && pin_acesso.blank? - errors.add(:pin_acesso, 'é obrigatório para motoristas') - end + conflito = User.motorista.where(pin_code: pin_code).where.not(id: id) + errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists? end end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 74750b2..32a7560 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -3,44 +3,15 @@ class ApplicationPolicy attr_reader :user, :record def initialize(user, record) - raise Pundit::NotAuthorizedError, 'Você precisa estar logado.' unless user @user = user @record = record end - def index? = false - def show? = false - def create? = false - def new? = create? - def update? = false - def edit? = update? - def destroy? = false - - protected - - def admin? - user.admin? - end - - def gerente? - user.gerente? - end - - def operador? - user.operador? - end - - def motorista? - user.motorista? - end - - def admin_ou_gerente? - admin? || gerente? - end - - def admin_gerente_ou_operador? - admin? || gerente? || operador? - end + def index? = user.pode_consolidar? || user.admin? + def show? = user.pode_consolidar? || user.admin? + def create? = user.pode_consolidar? || user.admin? + def update? = user.pode_consolidar? || user.admin? + def destroy? = user.admin? class Scope def initialize(user, scope) @@ -49,7 +20,7 @@ class ApplicationPolicy end def resolve - raise NotImplementedError, "#{self.class}#resolve não implementado" + @scope.all end private diff --git a/app/policies/consolidacao_policy.rb b/app/policies/consolidacao_policy.rb new file mode 100644 index 0000000..0cf7214 --- /dev/null +++ b/app/policies/consolidacao_policy.rb @@ -0,0 +1,15 @@ +# app/policies/consolidacao_policy.rb +class ConsolidacaoPolicy < ApplicationPolicy + def index? = user.pode_consolidar? + def show? = user.pode_consolidar? + def create? = user.pode_consolidar? + def update? = user.pode_consolidar? && !record_finalizada_para_operador? + def destroy? = user.admin? || user.gerente? + + private + + # Operador não mexe em consolidação finalizada + def record_finalizada_para_operador? + record.respond_to?(:finalizada?) && record.finalizada? && user.operador? + end +end diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb new file mode 100644 index 0000000..76a74ec --- /dev/null +++ b/app/policies/dashboard_policy.rb @@ -0,0 +1,10 @@ +# app/policies/dashboard_policy.rb +class DashboardPolicy < Struct.new(:user, :dashboard) + def metricas? + user.admin? || user.gerente? || user.operador? + end + + def index? + user.admin? || user.gerente? + end +end diff --git a/app/views/consolidacao_entregas/revisar.html.erb b/app/views/consolidacao_entregas/revisar.html.erb new file mode 100644 index 0000000..e8f9d39 --- /dev/null +++ b/app/views/consolidacao_entregas/revisar.html.erb @@ -0,0 +1,74 @@ +<%# app/views/consolidacao_entregas/revisar.html.erb — PASSO 3: Revisar %> +<% content_for :title, "Revisar — #{@motorista}" %> + +
+ +
+ <%= link_to '← Voltar (Passo 2)', + validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista), + class: 'text-gray-500 hover:text-orange-500 text-sm' %> +

📋 Revisão — <%= @motorista %>

+

<%= @consolidacao.nome %>

+
+ + <%# 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'] + ].each do |tipo, label, cores| %> +
+

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

+

<%= label %>

+
+ <% end %> +
+ + <%# Total %> +
+

Total a pagar — <%= @motorista %>

+

<%= moeda(@total) %>

+

<%= @classificacoes.size %> entregas classificadas

+
+ + <%# Detalhe das classificações %> +
+ + + + + + + + + + <% @classificacoes.each do |c| %> + + + + + + <% end %> + +
Tracking / NFTipoValor
<%= c.tracking_id %> + + <%= c.tipo_cor[:label] %> + + + <%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %> +
+
+ + <%# Ações finais %> +
+ <% if @proximo %> + <%= link_to "Próximo motorista: #{@proximo} →", + validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @proximo), + class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %> + <% end %> + <%= link_to '💾 Salvar rascunho e voltar', wizard_consolidacao_path(@consolidacao), + class: 'flex-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] font-bold py-3.5 rounded-lg min-h-[48px] flex items-center justify-center' %> +
+
diff --git a/app/views/consolidacao_entregas/validar.html.erb b/app/views/consolidacao_entregas/validar.html.erb new file mode 100644 index 0000000..33ea077 --- /dev/null +++ b/app/views/consolidacao_entregas/validar.html.erb @@ -0,0 +1,166 @@ +<%# app/views/consolidacao_entregas/validar.html.erb — PASSO 2: Validar entregas %> +<% content_for :title, "Validar — #{@motorista}" %> + +
+ + <%# Header %> +
+ <%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao), class: 'text-gray-500 hover:text-orange-500 text-sm' %> +
+
+

🚚 <%= @motorista %>

+

<%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>

+
+ <%= link_to 'Revisar (Passo 3) →', + revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista), + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %> +
+
+ + <%# Barra de progresso %> +
+
+ + <%= @classificacoes.size %> + de <%= @entregas.size %> entregas classificadas + + +
+
+
+
+
+ + <%# Ações em massa + filtro %> +
+ Ações em massa: + + + +
+ Selecionados como: + + + + +
+ +
+ <%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas', + validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, + 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' %> +
+
+ +
+ + <%# ── Lista de entregas (3 colunas) ─────────────────── %> +
+ <% if @entregas.any? %> + <% @entregas.each do |e| %> + <% classificacao = @classificacoes[e.tracking_id] %> +
+ + <%# Checkbox seleção em massa %> + + + <%# Dados da entrega %> +
+

+ NF <%= e.numero_nf %> · <%= e.local %> +

+

+ 📅 <%= e.planned_date %> · 📍 <%= e.route_id.to_s.first(8) %>… + <% if e.atraso_minutos > 0 %> · ⏱️ atraso <%= e.atraso_minutos %>min<% end %> +

+
+ + <%# Toggles coloridos — laranja=normal, laranja escuro=retirada, branco=bônus, preto=desconto %> +
+ <% [ + ['entrega_normal', 'Normal', 'bg-orange-500 text-black'], + ['retirada', 'Retirada', 'bg-orange-800 text-white'], + ['bonus', 'Bônus', 'bg-white text-black border border-orange-500'], + ['desconto', 'Desc.', 'bg-black text-white border border-gray-700'] + ].each do |tipo, label, cores| %> + + <% end %> +
+
+ <% end %> + <% else %> +
+

🎉

+

Nenhuma entrega pendente neste filtro.

+
+ <% end %> +
+ + <%# ── Resumo lateral (1 coluna, sticky) ─────────────── %> +
+
+

📊 Resumo

+ +
+
+ Normal + 0 +
+
+ Retirada + 0 +
+
+ Bônus + 0 +
+
+ Desconto + 0 +
+
+ +
+

Total do motorista

+

R$ 0,00

+
+ +
+

💰 Normal: <%= moeda(@precos[:entrega_normal]) %>

+

📦 Retirada: <%= moeda(@precos[:retirada]) %>

+

⭐ Bônus: <%= moeda(@precos[:bonus]) %>

+

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

+
+
+
+
+
+ +<%# Estado inicial para o Stimulus %> + diff --git a/app/views/consolidacoes/index.html.erb b/app/views/consolidacoes/index.html.erb new file mode 100644 index 0000000..cc8d776 --- /dev/null +++ b/app/views/consolidacoes/index.html.erb @@ -0,0 +1,65 @@ +<%# app/views/consolidacoes/index.html.erb %> +<% content_for :title, 'Consolidações' %> + +
+

📦 Consolidações

+ <%= link_to '+ Nova Consolidação', new_consolidacao_path, + class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %> +
+ +<%# ── Filtros ──────────────────────────────────────────── %> +<%= form_with url: consolidacoes_path, method: :get, + class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-5 gap-3' do |f| %> + <%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…', + class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %> + + <%= f.select :status, + options_for_select([['Todos os status', ''], ['📝 Rascunho', 'rascunho'], ['✅ Finalizada', 'finalizada']], params[:status]), + {}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + + <%= f.date_field :inicio, value: params[:inicio], + class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + + <%= f.date_field :fim, value: params[:fim], + class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %> + +
+ <%= f.submit 'Filtrar', class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer' %> + <%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg' %> +
+<% end %> + +<%# ── Lista ────────────────────────────────────────────── %> +<% if @consolidacoes.any? %> +
+ <% @consolidacoes.each do |c| %> + <%= link_to consolidacao_path(c), + class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %> +
+
+
+

<%= c.nome %>

+ <%= badge_status(c.status) %> +
+

+ 📅 <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %> + · 👤 <%= c.consolidacao_motoristas.count %> motorista(s) + · Criada por <%= c.criador.nome_display %> +

+
+
+

<%= moeda(c.valor_total) %>

+

<%= c.consolidacao_entregas.count %> entregas classificadas

+
+
+ <% end %> + <% end %> +
+<% else %> +
+

📭

+

Nenhuma consolidação encontrada.

+ <%= link_to 'Criar primeira consolidação', new_consolidacao_path, + class: 'inline-block bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg' %> +
+<% end %> diff --git a/app/views/consolidacoes/new.html.erb b/app/views/consolidacoes/new.html.erb new file mode 100644 index 0000000..8017fc5 --- /dev/null +++ b/app/views/consolidacoes/new.html.erb @@ -0,0 +1,89 @@ +<%# app/views/consolidacoes/new.html.erb %> +<% content_for :title, 'Nova Consolidação' %> + +
+

➕ Nova Consolidação

+ + <%= form_with model: @consolidacao, class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-6 space-y-6' do |f| %> + + <% if @consolidacao.errors.any? %> +
+

⚠️ Corrija os erros:

+
    + <% @consolidacao.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + + <%# Nome %> +
+ <%= f.label :nome, 'Nome da consolidação', class: 'block text-white font-semibold mb-2' %> + <%= f.text_field :nome, placeholder: 'Ex: Pagamento Janeiro/2026 — Quinzena 1', + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none text-base' %> +
+ + <%# Período %> +
+
+ <%= f.label :data_inicio, 'Data início', class: 'block text-white font-semibold mb-2' %> + <%= f.date_field :data_inicio, + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none' %> +
+
+ <%= f.label :data_fim, 'Data fim', class: 'block text-white font-semibold mb-2' %> + <%= f.date_field :data_fim, + class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none' %> +
+
+ + <%# Motoristas — multi-select %> +
+ +
+ <% if @motoristas.any? %> + + <% @motoristas.each do |m| %> + + <% end %> + <% else %> +

Nenhum motorista encontrado na tabela de entregas.

+ <% end %> +
+
+ + <%# Rotas — multi-select opcional %> +
+ +
+ <% @rotas.each do |r| %> + + <% end %> +
+
+ + <%# Ações %> +
+ <%= f.submit 'Criar e iniciar validação →', + class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3.5 rounded-lg min-h-[48px] cursor-pointer transition-colors text-base' %> + <%= link_to 'Cancelar', consolidacoes_path, + class: 'px-6 py-3.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg min-h-[48px] flex items-center' %> +
+ <% end %> +
diff --git a/app/views/consolidacoes/wizard.html.erb b/app/views/consolidacoes/wizard.html.erb new file mode 100644 index 0000000..8cdf056 --- /dev/null +++ b/app/views/consolidacoes/wizard.html.erb @@ -0,0 +1,91 @@ +<%# app/views/consolidacoes/wizard.html.erb — PASSO 1: Selecionar motorista %> +<% content_for :title, "Wizard — #{@consolidacao.nome}" %> + +
+ + <%# Header do wizard %> +
+ <%= link_to '← Voltar para consolidações', consolidacoes_path, class: 'text-gray-500 hover:text-orange-500 text-sm' %> +

<%= @consolidacao.nome %>

+

+ 📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %> + · <%= badge_status(@consolidacao.status) %> +

+
+ + <%# Indicador de passos %> +
+
+ 1 + Selecionar motorista +
+
+
+ 2 + Validar entregas +
+
+
+ 3 + Revisar +
+
+ + <%# Lista de motoristas %> +
+ <% @motoristas.each do |m| %> + <%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: m[:registro].motorista_nome), + class: "block bg-[#1a1a1a] border rounded-xl p-5 transition-colors + #{m[:completo] ? 'border-green-600 hover:border-green-500' : 'border-[#2a2a2a] hover:border-orange-500'}" do %> +
+
+
+ <%= m[:completo] ? '✓' : m[:registro].motorista_nome.first.upcase %> +
+
+

<%= m[:registro].motorista_nome %>

+

+ <%= m[:total] %> entregas pagas no período + · + <%= m[:classificadas] %>/<%= m[:total] %> classificadas + +

+
+
+
+

<%= moeda(m[:registro].valor_total) %>

+ +
+
+
+ <% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %> +
+
+
+
+ <% end %> + <% end %> +
+ + <%# Finalizar — só habilita se tudo classificado %> + <% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %> +
+
+

Total da consolidação

+

<%= moeda(@consolidacao.valor_total) %>

+
+ <% if @consolidacao.rascunho? %> + <%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao), + method: :post, + disabled: !tudo_completo, + data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' }, + class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors + #{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %> + <% end %> +
+ <% unless tudo_completo %> +

⚠️ Classifique todas as entregas de todos os motoristas para poder finalizar.

+ <% end %> +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 74efb66..efbbbe8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,169 +1,95 @@ -<%# app/views/layouts/application.html.erb %> - + + - <%= content_for?(:title) ? yield(:title) + ' · ' : '' %>Gade Logística + <%= content_for?(:title) ? "#{yield(:title)} | Gade Logística" : "Gade Logística" %> - <%= stylesheet_link_tag 'application', data: { turbo_track: 'reload' } %> - <%= javascript_importmap_tags %> + <%# Tailwind via CDN em desenvolvimento — compilar em produção %> + + + + <%# Google Fonts — Inter %> + + + + <%= yield :head %> - + - <% if user_signed_in? && !current_user.motorista? %> - <%# Layout principal com sidebar %> -
- - <%# Sidebar Desktop %> - - - <%# Overlay mobile %> - - - <%# Sidebar Mobile (drawer) %> - - - <%# Main content %> -
- <%# Topbar %> -
- <%# Hamburger mobile %> - - - <%# Breadcrumb / título da página %> -
- - <%= content_for?(:page_title) ? yield(:page_title) : 'Dashboard' %> - -
- - <%# Ações do header %> -
- <%# Toggle tema %> - <%= button_to toggle_tema_configuracoes_path, method: :post, - class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 - rounded-lg transition-colors cursor-pointer', - title: @tema == 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro' do %> - <% if @tema == 'dark' %> - - - - <% else %> - - - - <% end %> - <% end %> - - <%# Avatar / menu usuário %> -
- -
- - <%# Logout %> - <%= button_to destroy_user_session_path, method: :delete, - class: 'px-3 py-2 text-gray-500 hover:text-red-400 text-sm - hover:bg-red-900/20 rounded-lg transition-colors cursor-pointer' do %> - Sair - <% end %> -
-
- - <%# Conteúdo principal %> -
- <%= yield %> -
-
-
- - <% elsif user_signed_in? && current_user.motorista? %> - <%# Layout simplificado para motorista %> -
-
- <%= yield %> -
-
- - <% else %> - <%# Layout sem sidebar (login, etc) %> - <%= yield %> + <%# ── Navbar ─────────────────────────────────────────── %> + <% if user_signed_in? %> + <%= render 'layouts/navbar' %> <% end %> + <%# ── Flash Messages ─────────────────────────────────── %> + <% if notice.present? %> +
+ <%= notice %> +
+ <% end %> + + <% if alert.present? %> +
+ ⚠️ <%= alert %> +
+ <% end %> + + <%# ── Conteúdo principal ──────────────────────────────── %> +
+ <%= yield %> +
+ + <%# Auto-hide flash após 4s (JS simples) %> + diff --git a/config/routes.rb b/config/routes.rb index ef0ee97..4711c49 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,58 +1,56 @@ # config/routes.rb Rails.application.routes.draw do - # Devise com controller customizado para sessões - devise_for :users, - controllers: { - sessions: 'users/sessions' - } + # Devise — login padrão para admin/gerente/operador + devise_for :users, path: 'auth', path_names: { + sign_in: 'login', + sign_out: 'logout', + password: 'senha' + } - # Root - authenticated :user do - root 'dashboard#index', as: :authenticated_root - end - root 'devise/sessions#new' + # Root — redireciona por role + root 'dashboard#index' - # Dashboard (Fase 3) + # Dashboard principal (admin/gerente/operador) get '/dashboard', to: 'dashboard#index', as: :dashboard - # Painel motorista (Fase 2 / 8) - get '/motorista', to: 'motorista#index', as: :motorista + # Painel motorista + get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard - # Usuários (Fase 2) - resources :usuarios do - member do - post :toggle_ativo - end - end - - # Configurações (Fase 2) - resources :configuracoes, only: [:index, :edit, :update] do - collection do - post :toggle_tema - end - end - - # Consolidações (Fases 5 e 6) + # Consolidações resources :consolidacoes do member do - patch :fechar - patch :aprovar - patch :cancelar - post :reabrir + get :wizard # Passo 1 — selecionar motorista + post :finalizar + post :arquivar + get :preview_holerite + get :gerar_pdf_relatorio + get :gerar_pdf_holerite + end + + resources :consolidacao_entregas, only: [] do + collection do + get :validar # Passo 2 — toggles de classificação + post :classificar # toggle individual (JSON) + post :classificar_em_massa # ações em massa (JSON) + get :revisar # Passo 3 — revisão do motorista + end end - resources :consolidacao_motoristas, only: [:create, :update, :destroy], shallow: true end - # Histórico estimado (Fase 4) - resources :historico_estimados, only: [:index, :show] + # Configurações (apenas admin/gerente) + namespace :admin do + resources :usuarios, except: [:show] + resources :configuracoes, only: [:index, :update] + resources :auditoria_logs, only: [:index] + end - # Logs de auditoria (Fase 4) - resources :auditoria_logs, only: [:index, :show] + # API interna — Dashboard métricas + namespace :api do + namespace :v1 do + get 'dashboard/metricas', to: 'dashboard#metricas' + end + end - # PDFs (Fase 7) - # get '/consolidacoes/:id/pdf', to: 'pdfs#consolidacao', as: :pdf_consolidacao - # get '/consolidacao_motoristas/:id/pdf', to: 'pdfs#holerite', as: :pdf_holerite - - # Health check - get '/health', to: proc { [200, {}, ['ok']] } + # Health check (Docker) + get '/health', to: proc { [200, {}, ['OK']] } end diff --git a/config/schedule.rb b/config/schedule.rb new file mode 100644 index 0000000..d3abb0c --- /dev/null +++ b/config/schedule.rb @@ -0,0 +1,12 @@ +# config/schedule.rb +# Agendamento com Whenever gem +# Atualizar crontab: bundle exec whenever --update-crontab +# Ver crontab gerado: bundle exec whenever + +set :output, "log/cron.log" +set :environment, ENV.fetch("RAILS_ENV", "production") + +# A cada 1 hora — atualiza o valor estimado do dia +every 1.hour do + rake "historico:atualizar" +end diff --git a/db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb b/db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb new file mode 100644 index 0000000..92931b8 --- /dev/null +++ b/db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb @@ -0,0 +1,6 @@ +class AddRouteIdsToConsolidacoes < ActiveRecord::Migration[7.1] + def change + add_column :consolidacoes, :route_ids, :jsonb, default: [] + add_index :consolidacoes, :route_ids, using: :gin + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 36fb761..c7d8ab7 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,78 +1,47 @@ # db/seeds.rb -# Rodar com: docker-compose exec app bundle exec rails db:seed +# Dados iniciais do sistema Gade Hospitalar +# Execute com: bundle exec rails db:seed -puts "🌱 Iniciando seeds..." +puts "🌱 Criando dados iniciais..." -# ─── Configurações de preço (Fase 1 + confirmação) ─────────────────────────── -configs = [ - { tipo: :entrega, valor: 10.50, descricao: 'Entrega padrão para UBS, EMAD, STS, SAD' }, - { tipo: :retirada, valor: 8.00, descricao: 'Retirada de material nos pontos de origem' }, - { tipo: :bonus, valor: 5.00, descricao: 'Bônus por desempenho / entregas especiais' }, - { tipo: :desconto, valor: 2.00, descricao: 'Desconto aplicado por ocorrência' } -] - -configs.each do |c| - Configuracao.find_or_create_by!(tipo: c[:tipo]) do |cfg| - cfg.valor = c[:valor] - cfg.descricao = c[:descricao] - end +# ── Usuário Admin padrão ───────────────────────────────────── +admin = User.find_or_create_by!(email: 'admin@gade.com') do |u| + u.nome = 'Administrador Gade' + u.password = 'Gade@2026!' + u.role = :admin + u.ativo = true end -puts " ✅ #{Configuracao.count} configurações de preço" +puts " ✅ Admin criado: #{admin.email} / senha: Gade@2026!" -# ─── Usuários ──────────────────────────────────────────────────────────────── -usuarios = [ - { - name: 'Admin Gade', - email: 'admin@gade.com', - password: 'Gade@2026!', - role: :admin, - ativo: true - }, - { - name: 'Gerente Operações', - email: 'gerente@gade.com', - password: 'Gade@2026!', - role: :gerente, - ativo: true - }, - { - name: 'Operador Entregas', - email: 'operador@gade.com', - password: 'Gade@2026!', - role: :operador, - ativo: true - }, - # Motoristas com PIN - { name: 'João Silva', role: :motorista, pin_acesso: '1234', ativo: true }, - { name: 'Maria Oliveira', role: :motorista, pin_acesso: '5678', ativo: true }, - { name: 'Carlos Santos', role: :motorista, pin_acesso: '9012', ativo: true } +# ── Usuário Gerente de exemplo ─────────────────────────────── +gerente = User.find_or_create_by!(email: 'gerente@gade.com') do |u| + u.nome = 'Gerente Gade' + u.password = 'Gade@2026!' + u.role = :gerente + u.ativo = true +end +puts " ✅ Gerente criado: #{gerente.email}" + +# ── Configurações de preço padrão ──────────────────────────── +configs = [ + { chave: 'preco_entrega', valor: '15.00', descricao: 'Valor pago por entrega bem-sucedida (R$)' }, + { chave: 'preco_retirada', valor: '20.00', descricao: 'Valor pago por retirada de equipamento (R$)' }, + { chave: 'preco_bonus', valor: '10.00', descricao: 'Valor de bônus por meta/critério (R$)' }, + { chave: 'preco_desconto', valor: '15.00', descricao: 'Valor descontado por problema (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: 'Gade Hospitalar', descricao: 'Nome da empresa exibido no sistema' }, ] -usuarios.each do |u| - user = User.find_or_initialize_by( - u[:role] == :motorista ? { name: u[:name], role: :motorista } : { email: u[:email] } - ) - - user.assign_attributes(u.except(:email)) - user.email = u[:email] || "#{u[:name].downcase.gsub(' ', '.')}@gade.local" - user.password = u[:password] || SecureRandom.hex(12) if user.new_record? - - if user.save - puts " ✅ #{user.role.upcase}: #{user.name}" - else - puts " ⚠️ #{user.name}: #{user.errors.full_messages.join(', ')}" +configs.each do |cfg| + Configuracao.find_or_create_by!(chave: cfg[:chave]) do |c| + c.valor = cfg[:valor] + c.descricao = cfg[:descricao] end + puts " ✅ Config: #{cfg[:chave]} = #{cfg[:valor]}" end puts "" puts "✅ Seeds concluídos!" -puts "" -puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -puts " Admin: admin@gade.com / Gade@2026!" -puts " Gerente: gerente@gade.com / Gade@2026!" -puts " Operador: operador@gade.com / Gade@2026!" -puts " Motorista João: PIN 1234" -puts " Motorista Maria: PIN 5678" -puts " Motorista Carlos: PIN 9012" -puts " ⚠️ ALTERE AS SENHAS NO PRIMEIRO ACESSO!" -puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +puts " Admin: admin@gade.com / Gade@2026!" +puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!" diff --git a/lib/tasks/historico.rake b/lib/tasks/historico.rake new file mode 100644 index 0000000..67364d5 --- /dev/null +++ b/lib/tasks/historico.rake @@ -0,0 +1,13 @@ +# lib/tasks/historico.rake +namespace :historico do + desc "Atualiza o histórico de valor estimado (rodado a cada 1h via cron/whenever)" + task atualizar: :environment do + puts "[#{Time.current}] Atualizando histórico estimado..." + registro = AtualizarHistoricoEstimadoJob.perform_now + puts "[#{Time.current}] OK — #{registro.entregas_contadas} entregas | R$ #{registro.valor_total_estimado}" + rescue => e + puts "[#{Time.current}] ERRO: #{e.message}" + Rails.logger.error("[historico:atualizar] #{e.message}\n#{e.backtrace.first(5).join("\n")}") + raise + end +end