diff --git a/README_FASE2_3.md b/README_FASE2_3.md new file mode 100644 index 0000000..e8f2cb7 --- /dev/null +++ b/README_FASE2_3.md @@ -0,0 +1,163 @@ +# 🚀 Fase 2 e 3 — Gade Logística + +## O que foi implementado + +--- + +### ✅ FASE 2 — Login, Usuários e Configurações + +#### Autenticação e Login +- **`app/controllers/users/sessions_controller.rb`** — Devise customizado: + - Redirecionamento por role: admin/gerente/operador → `/dashboard`, motorista → `/motorista` + - Login por PIN de 4 dígitos para motoristas (sem precisar de e-mail) + - Auditoria de login no banco + +- **`app/views/devise/sessions/new.html.erb`** — Tela de login com: + - Duas abas: E-mail e PIN Motorista + - Teclado numérico visual (touchscreen-friendly) para PIN + - Auto-submit ao digitar o 4º dígito + - Suporte a teclado físico no campo PIN + +#### CRUD de Usuários +- **`app/controllers/usuarios_controller.rb`** — CRUD completo com Pundit +- **`app/views/usuarios/`** — Lista com hover actions + formulário dinâmico + - Formulário adapta campos conforme o role (e-mail/senha para staff, PIN para motorista) + - Toggle ativo/inativo sem excluir o usuário + +#### Configurações e Preços +- **`app/controllers/configuracoes_controller.rb`** — CRUD de pilares de preço +- **`app/views/configuracoes/`** — Cards com edição inline e informações do banco externo +- **Toggle de tema claro/escuro** via `POST /configuracoes/toggle_tema` (salvo na sessão) + +#### Pundit (Permissões) +- **`app/policies/application_policy.rb`** — Base com helpers `admin?`, `gerente?`, etc. +- **`app/policies/user_policy.rb`** — Admin vê tudo; gerente não vê outros admins +- **`app/policies/configuracao_policy.rb`** — Apenas admin edita preços + +--- + +### ✅ FASE 3 — Dashboard com Chart.js + +- **`app/controllers/dashboard_controller.rb`** — Cálculos ao vivo: + - Total de entregas, pagas, pendentes (da tabela read-only) + - Valor estimado (entregas pagas × preço da Configuracao) + - Top 10 motoristas por valor + - Distribuição por local (contact_name) + - Evolução diária para o gráfico (labels + valores + qtds) + +- **`app/views/dashboard/index.html.erb`** — Layout visual: + - **Card laranja grande** com valor estimado total + - **3 cards menores** (entregas, consolidações, motoristas) + - **Gráfico de linha** com Chart.js 4.4 (CDN) — evolução diária do mês + - **Ranking de motoristas** com barras de progresso + - **Grid de locais** com percentuais + - Navegação por mês (← →) + +- **`app/views/motorista/index.html.erb`** — Painel simplificado do motorista: + - Cards com suas métricas pessoais + - Lista das últimas entregas pagas + - Fechamentos/consolidações aprovadas + +--- + +## Arquivos gerados + +``` +app/ +├── controllers/ +│ ├── application_controller.rb ← Pundit + tema +│ ├── users/sessions_controller.rb ← PIN login + redirect por role +│ ├── usuarios_controller.rb ← CRUD usuários +│ ├── configuracoes_controller.rb ← Preços + toggle tema +│ ├── dashboard_controller.rb ← Cálculos Fase 3 +│ └── motorista_controller.rb ← Painel motorista +├── models/ +│ ├── user.rb ← PIN, roles, validações +│ ├── configuracao.rb ← mapa_de_precos, labels, ícones +│ ├── entrega.rb ← READ-ONLY, scopes atualizado +│ ├── consolidacao.rb ← status enum, do_mes scope +│ └── auditoria_log.rb ← método .registrar() +├── policies/ +│ ├── application_policy.rb +│ ├── user_policy.rb +│ └── configuracao_policy.rb +├── helpers/ +│ └── application_helper.rb ← moeda(), badge_status(), badge_role(), nav_link_to() +└── views/ + ├── devise/sessions/new.html.erb ← Login com PIN keypad + ├── usuarios/ + │ ├── index.html.erb + │ ├── new.html.erb + │ ├── edit.html.erb + │ └── _form.html.erb + ├── configuracoes/ + │ ├── index.html.erb + │ └── edit.html.erb + ├── dashboard/ + │ └── index.html.erb ← Chart.js + cards grandes + ├── motorista/ + │ └── index.html.erb + ├── layouts/ + │ ├── application.html.erb ← Tema toggle + sidebar responsiva + │ └── _sidebar.html.erb ← Navegação por role + └── shared/ + └── _flash.html.erb + +config/ +└── routes.rb ← Rotas completas Fases 1-8 + +db/ +├── migrate/20260610_add_fase2_fields_to_users.rb ← PIN + ativo + trackable + lockable +└── seeds.rb ← Admin + Gerente + Operador + 3 motoristas com PIN +``` + +--- + +## Como aplicar + +```bash +# 1. Copiar os arquivos para o projeto +cp -r fase2_3/* . + +# 2. Renomear a migration com timestamp real +TIMESTAMP=$(date +%Y%m%d%H%M%S) +mv db/migrate/20260610_add_fase2_fields_to_users.rb \ + "db/migrate/${TIMESTAMP}_add_fase2_fields_to_users.rb" + +# 3. Rodar migrations +docker-compose exec app bundle exec rails db:migrate + +# 4. Rodar seeds +docker-compose exec app bundle exec rails db:seed + +# 5. Verificar rotas +docker-compose exec app bundle exec rails routes | grep -E 'usuario|config|dashboard|motor' + +# 6. Subir +docker-compose up -d +``` + +--- + +## Credenciais de teste + +| Usuário | Login | Senha/PIN | Vai para | +|---------|-------|-----------|----------| +| Admin | admin@gade.com | Gade@2026! | /dashboard | +| Gerente | gerente@gade.com | Gade@2026! | /dashboard | +| Operador | operador@gade.com | Gade@2026! | /dashboard | +| Motorista João | PIN | 1234 | /motorista | +| Motorista Maria | PIN | 5678 | /motorista | +| Motorista Carlos | PIN | 9012 | /motorista | + +--- + +## Próximas fases + +| Fase | O que implementar | +|------|-------------------| +| **4** | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog UI | +| **5** | Nova Consolidação + Wizard Passo 1 (selecionar motorista) | +| **6** | Toggle switches + ações em massa + barra progresso (Stimulus) | +| **7** | PDFs com Prawn: relatório individual + holerite com QR code | +| **8** | Notificações WhatsApp/email + responsividade final | diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6110295..ba5ff14 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,24 +4,26 @@ 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? - # 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 + rescue_from Pundit::NotAuthorizedError, with: :usuario_nao_autorizado + + protect_from_forgery with: :exception private def set_tema - @tema = current_user&.tema_preferido || 'dark' + # Lê o tema da sessão (padrão: escuro) + @tema = session[:tema] || 'dark' end - def after_sign_in_path_for(resource) - if resource.motorista? - motorista_dashboard_path - else - dashboard_path - 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) end end diff --git a/app/controllers/configuracoes_controller.rb b/app/controllers/configuracoes_controller.rb new file mode 100644 index 0000000..f84ee71 --- /dev/null +++ b/app/controllers/configuracoes_controller.rb @@ -0,0 +1,44 @@ +# app/controllers/configuracoes_controller.rb +class ConfiguracoesController < ApplicationController + before_action :set_configuracao, only: [:edit, :update] + + def index + authorize Configuracao + @configuracoes = Configuracao.all.order(:tipo) + end + + def edit + authorize @configuracao + end + + def update + authorize @configuracao + + valor_anterior = @configuracao.valor + + if @configuracao.update(configuracao_params) + AuditoriaLog.registrar(current_user, 'editar_preco', request.remote_ip, + detalhes: "#{@configuracao.tipo}: R$ #{valor_anterior} → R$ #{@configuracao.valor}") + redirect_to configuracoes_path, notice: "Preço de #{@configuracao.tipo_label} atualizado." + else + render :edit, status: :unprocessable_entity + end + end + + # POST /configuracoes/toggle_tema + def toggle_tema + skip_authorization + session[:tema] = session[:tema] == 'light' ? 'dark' : 'light' + redirect_back(fallback_location: dashboard_path) + end + + private + + def set_configuracao + @configuracao = Configuracao.find(params[:id]) + end + + def configuracao_params + params.require(:configuracao).permit(:valor, :descricao) + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb new file mode 100644 index 0000000..c05c87a --- /dev/null +++ b/app/controllers/dashboard_controller.rb @@ -0,0 +1,77 @@ +# app/controllers/dashboard_controller.rb +class DashboardController < ApplicationController + def index + skip_authorization + + @data_referencia = params[:data] ? Date.parse(params[:data]) : Date.today + @mes_inicio = @data_referencia.beginning_of_month + @mes_fim = @data_referencia.end_of_month + + carregar_dados_dashboard + end + + private + + def carregar_dados_dashboard + # Entregas do mês atual (da tabela read-only) + entregas_mes = Entrega.do_mes(@data_referencia) + + # Totais gerais + @total_entregas = entregas_mes.count + @entregas_pagas = entregas_mes.pagas.count + @entregas_pendentes = @total_entregas - @entregas_pagas + + # Configurações de preço + config = Configuracao.mapa_de_precos + + # Valor estimado total + @valor_estimado = (@entregas_pagas * config[:entrega]).round(2) + + # Por motorista (top 10) + @motoristas = entregas_mes.pagas + .group(:driver) + .count + .sort_by { |_, v| -v } + .first(10) + .map do |driver, qtd| + { + nome: driver, + entregas: qtd, + valor: (qtd * config[:entrega]).round(2) + } + end + + # Por operação (route_id → contact_name) + @por_operacao = entregas_mes.pagas + .group(:contact_name) + .count + .sort_by { |_, v| -v } + .first(6) + + # Evolução diária do mês (para Chart.js) + @grafico_diario = build_grafico_diario(entregas_mes, config[:entrega]) + + # Consolidações do mês + @consolidacoes_mes = Consolidacao.do_mes(@data_referencia) + @consolidacoes_abertas = @consolidacoes_mes.where(status: :aberta).count + @consolidacoes_fechadas = @consolidacoes_mes.where(status: :fechada).count + + # Histórico estimado mais recente + @historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5) + end + + def build_grafico_diario(entregas, preco_entrega) + dias = ((@mes_inicio)..[@mes_fim, Date.today].min).map(&:to_s) + + contagem = entregas.pagas + .group("DATE(planned_date)") + .count + .transform_keys { |k| k.to_s } + + labels = dias.map { |d| Date.parse(d).strftime('%d/%m') } + valores = dias.map { |d| ((contagem[d] || 0) * preco_entrega).round(2) } + qtds = dias.map { |d| contagem[d] || 0 } + + { labels: labels, valores: valores, qtds: qtds } + end +end diff --git a/app/controllers/motorista_controller.rb b/app/controllers/motorista_controller.rb new file mode 100644 index 0000000..dea828a --- /dev/null +++ b/app/controllers/motorista_controller.rb @@ -0,0 +1,42 @@ +# app/controllers/motorista_controller.rb +class MotoristaController < ApplicationController + before_action :verificar_motorista + + def index + skip_authorization + + @data_referencia = params[:data] ? Date.parse(params[:data]) : Date.today + config = Configuracao.mapa_de_precos + + # Entregas do motorista logado + @entregas_mes = Entrega + .do_mes(@data_referencia) + .where("LOWER(driver) = ?", current_user.name.downcase) + + @total_entregas = @entregas_mes.count + @pagas = @entregas_mes.pagas.count + @pendentes = @total_entregas - @pagas + @valor_estimado = (@pagas * config[:entrega]).round(2) + + # Últimas entregas + @ultimas_entregas = @entregas_mes.pagas + .order(planned_date: :desc) + .limit(10) + + # Consolidações do motorista + @consolidacoes = ConsolidacaoMotorista + .joins(:consolidacao) + .where(user: current_user) + .where(consolidacoes: { status: [:fechada, :aprovada] }) + .order('consolidacoes.created_at DESC') + .limit(5) + end + + private + + def verificar_motorista + unless current_user&.motorista? + redirect_to dashboard_path, alert: 'Acesso restrito a motoristas.' + end + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 0000000..ea2dc18 --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,56 @@ +# app/controllers/users/sessions_controller.rb +class Users::SessionsController < Devise::SessionsController + before_action :configure_sign_in_params, only: [:create] + + # GET /users/sign_in + def new + @pin_login = params[:pin].present? + super + end + + # POST /users/sign_in + def create + # PIN login para motoristas + if params[:pin_login].present? + handle_pin_login + else + super + end + end + + protected + + def after_sign_in_path_for(resource) + case resource.role + when 'motorista' + motorista_path + else + dashboard_path + end + end + + def after_sign_out_path_for(resource_or_scope) + new_user_session_path + end + + def configure_sign_in_params + devise_parameter_sanitizer.permit(:sign_in, keys: [:email, :password, :pin]) + end + + private + + def handle_pin_login + pin = params[:user][:pin].to_s.strip + user = User.find_by(pin_acesso: pin, role: 'motorista') + + if user&.active? + sign_in(user) + AuditoriaLog.registrar(user, 'login_pin', request.remote_ip) + redirect_to motorista_path, notice: "Bem-vindo, #{user.nome_display}!" + else + flash.now[:alert] = 'PIN inválido ou motorista inativo.' + @pin_login = true + render :new, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/usuarios_controller.rb b/app/controllers/usuarios_controller.rb new file mode 100644 index 0000000..e3565ae --- /dev/null +++ b/app/controllers/usuarios_controller.rb @@ -0,0 +1,88 @@ +# app/controllers/usuarios_controller.rb +class UsuariosController < ApplicationController + before_action :set_usuario, only: [:show, :edit, :update, :destroy, :toggle_ativo] + + def index + authorize User + @usuarios = policy_scope(User).order(:name).page(params[:page]).per(20) + @roles = User.roles.keys + end + + def show + authorize @usuario + end + + def new + @usuario = User.new + authorize @usuario + end + + def create + @usuario = User.new(usuario_params) + authorize @usuario + + if @usuario.save + AuditoriaLog.registrar(current_user, 'criar_usuario', request.remote_ip, + detalhes: "Criou usuário #{@usuario.email} (#{@usuario.role})") + redirect_to usuarios_path, notice: 'Usuário criado com sucesso.' + else + render :new, status: :unprocessable_entity + end + end + + def edit + authorize @usuario + end + + def update + authorize @usuario + + if @usuario.update(usuario_params_update) + AuditoriaLog.registrar(current_user, 'editar_usuario', request.remote_ip, + detalhes: "Editou usuário #{@usuario.email}") + redirect_to usuarios_path, notice: 'Usuário atualizado.' + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + authorize @usuario + + if @usuario == current_user + redirect_to usuarios_path, alert: 'Você não pode excluir sua própria conta.' + return + end + + @usuario.destroy + AuditoriaLog.registrar(current_user, 'excluir_usuario', request.remote_ip, + detalhes: "Excluiu usuário #{@usuario.email}") + redirect_to usuarios_path, notice: 'Usuário removido.' + end + + def toggle_ativo + authorize @usuario, :update? + @usuario.update!(ativo: !@usuario.ativo) + status = @usuario.ativo? ? 'ativado' : 'desativado' + AuditoriaLog.registrar(current_user, "usuario_#{status}", request.remote_ip, + detalhes: "#{@usuario.email} foi #{status}") + redirect_to usuarios_path, notice: "Usuário #{status} com sucesso." + end + + private + + def set_usuario + @usuario = User.find(params[:id]) + end + + def usuario_params + params.require(:user).permit(:name, :email, :password, :password_confirmation, + :role, :pin_acesso, :ativo) + end + + def usuario_params_update + permitted = [:name, :email, :role, :pin_acesso, :ativo] + permitted += [:password, :password_confirmation] if params[:user][:password].present? + params.require(:user).permit(permitted) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 55e1dc5..daf28b7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,46 +1,82 @@ # app/helpers/application_helper.rb module ApplicationHelper - # 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 + # Formata valor monetário → "R$ 1.250,00" def moeda(valor) - "R$ #{"%.2f" % valor.to_f}".gsub('.', ',') + "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" end - # Badge de status de consolidação + # 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 def badge_status(status) - 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 + 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 - 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]}" + # 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') end end - # 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" - } + # 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}") 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/models/auditoria_log.rb b/app/models/auditoria_log.rb index 8e44e43..c5bac93 100644 --- a/app/models/auditoria_log.rb +++ b/app/models/auditoria_log.rb @@ -1,29 +1,22 @@ # app/models/auditoria_log.rb class AuditoriaLog < ApplicationRecord - belongs_to :user, optional: true # opcional caso user seja deletado + belongs_to :user, optional: true - ACOES = %w[criar editar finalizar arquivar excluir login logout].freeze - - validates :acao, presence: true - validates :entidade, presence: true + validates :acao, presence: true scope :recentes, -> { order(created_at: :desc) } - scope :por_user, ->(user_id) { where(user_id: user_id) } - scope :por_entidade, ->(entidade) { where(entidade: entidade) } + scope :do_usuario, ->(user) { where(user: user) } - def self.registrar(user:, acao:, entidade:, entidade_id: nil, - dados_anteriores: {}, dados_novos: {}, request: nil) + # 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) create!( - 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 + user: user, + acao: acao, + ip: ip, + detalhes: detalhes ) - rescue => e - Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}") + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn("AuditoriaLog falhou: #{e.message}") end end diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb index ccd8756..e46642f 100644 --- a/app/models/configuracao.rb +++ b/app/models/configuracao.rb @@ -1,55 +1,39 @@ # app/models/configuracao.rb class Configuracao < ApplicationRecord - # Chaves válidas do sistema - CHAVES = %w[ - preco_entrega - preco_retirada - preco_bonus - preco_desconto - notificacao_whatsapp - notificacao_email - empresa_nome - empresa_logo - ].freeze + TIPOS = %w[entrega retirada bonus desconto].freeze - CHAVES_MOEDA = %w[ - preco_entrega - preco_retirada - preco_bonus - preco_desconto - ].freeze + enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 } - validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true - validates :valor, presence: true + validates :tipo, presence: true, uniqueness: true + validates :valor, presence: true, + numericality: { greater_than_or_equal_to: 0 } - # ── Acesso rápido ─────────────────────────────────────────── + LABELS = { + 'entrega' => 'Entrega Normal', + 'retirada' => 'Retirada', + 'bonus' => 'Bônus', + 'desconto' => 'Desconto' + }.freeze - def self.valor(chave) - find_by(chave: chave)&.valor + ICONES = { + 'entrega' => '🚚', + 'retirada' => '📦', + 'bonus' => '⭐', + 'desconto' => '🔻' + }.freeze + + def tipo_label + LABELS[tipo] || tipo.humanize end - def self.preco_entrega - valor('preco_entrega').to_f + def icone + ICONES[tipo] || '💰' 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 + # 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 end end diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index dd883a4..93a23e7 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -1,75 +1,34 @@ # app/models/consolidacao.rb class Consolidacao < ApplicationRecord - belongs_to :criador, class_name: 'User', foreign_key: :created_by - belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true + # Soft delete + acts_as_paranoid if respond_to?(:acts_as_paranoid) + 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, dependent: :destroy + has_many :consolidacao_entregas, through: :consolidacao_motoristas + has_many :motoristas, through: :consolidacao_motoristas, source: :user - # ── Enums ─────────────────────────────────────────────────── - enum status: { rascunho: 0, finalizada: 1, arquivada: 2 } + validates :referencia_mes, presence: true + validates :status, presence: true - # ── Soft delete ───────────────────────────────────────────── - scope :ativas, -> { where(deleted_at: nil) } - scope :arquivadas, -> { where.not(deleted_at: nil) } + scope :do_mes, ->(data = Date.today) { + where(referencia_mes: data.beginning_of_month..data.end_of_month) + } - # ── Validações ────────────────────────────────────────────── - validates :nome, presence: true - validates :data_inicio, presence: true - validates :data_fim, presence: true - validates :created_by, presence: true - validate :periodo_valido + scope :abertas, -> { where(status: :aberta) } + scope :fechadas, -> { where(status: [:fechada, :aprovada]) } - # ── Callbacks ─────────────────────────────────────────────── - before_save :recalcular_valor_total + def progresso_percentual + return 0 if consolidacao_motoristas.none? - # ── 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) + finalizados = consolidacao_motoristas.where(finalizado: true).count + total = consolidacao_motoristas.count + ((finalizados.to_f / total) * 100).round end - 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) + def mes_label + referencia_mes&.strftime('%B/%Y')&.capitalize end end diff --git a/app/models/entrega.rb b/app/models/entrega.rb index 7e18d34..6297062 100644 --- a/app/models/entrega.rb +++ b/app/models/entrega.rb @@ -1,88 +1,39 @@ # app/models/entrega.rb -# -# 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. -# +# ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026 +# NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa 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 — segurança contra mutações acidentais + # Apenas leitura def readonly? true end - # ── Scopes ────────────────────────────────────────────────── - scope :concluidas, -> { where(status: 'completed') } - scope :com_checkin, -> { where.not(checkin: nil) } - scope :pagas, -> { concluidas.com_checkin } - scope :pendentes, -> { where.not(status: 'completed') } + # 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)) } - scope :no_periodo, ->(inicio, fim) { - where(planned_date: inicio.to_date..fim.to_date) + 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 :do_motorista, ->(nome) { - where(driver: nome) + where('LOWER(driver) = ?', nome.to_s.downcase) } - 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?) + # Helpers de instância + def paga? + status == 'completed' && checkin.present? end - # 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 } + def data_formatada + planned_date&.strftime('%d/%m/%Y') end end diff --git a/app/models/user.rb b/app/models/user.rb index d9bbf6c..6048369 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,69 +1,59 @@ # app/models/user.rb class User < ApplicationRecord - devise :database_authenticatable, - :registerable, - :recoverable, - :rememberable, - :validatable + # Devise modules + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :validatable, + :trackable, :lockable - # ── Roles ────────────────────────────────────────────────── + # Roles enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 } - ROLES_LABEL = { - 'admin' => 'Administrador', - 'gerente' => 'Gerente', - 'operador' => 'Operador', - 'motorista' => 'Motorista' - }.freeze + # 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' } - # ── 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? + validate :pin_obrigatorio_para_motorista - validate :pin_unico_para_motoristas, if: :motorista? + # Callbacks + before_validation :normalizar_pin - # Motoristas podem não ter e-mail (login via PIN) - def email_required? - !motorista? - end + # Associações + has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify + has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error - def password_required? - !motorista? ? super : false - end + # Scopes + scope :ativos, -> { where(ativo: true) } + scope :motoristas, -> { where(role: :motorista) } - # ── Escopos ───────────────────────────────────────────────── - scope :ativos, -> { where(ativo: true) } - scope :por_nome, -> { order(:nome) } - scope :nao_motoristas, -> { where.not(role: :motorista) } - - # ── Helpers ───────────────────────────────────────────────── def nome_display - nome.presence || email + name.split.first.capitalize end - def role_label - ROLES_LABEL[role] || role.humanize + def active? + ativo? end - def pode_ver_config? + def pode_acessar_admin? admin? || gerente? end - def pode_consolidar? + def pode_criar_consolidacao? admin? || gerente? || operador? end private - def pin_unico_para_motoristas - return if pin_code.blank? + def normalizar_pin + self.pin_acesso = pin_acesso.to_s.strip.presence + 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? + def pin_obrigatorio_para_motorista + if motorista? && pin_acesso.blank? + errors.add(:pin_acesso, 'é obrigatório para motoristas') + end end end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index 32a7560..74750b2 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -3,15 +3,44 @@ 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? = 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? + 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 class Scope def initialize(user, scope) @@ -20,7 +49,7 @@ class ApplicationPolicy end def resolve - @scope.all + raise NotImplementedError, "#{self.class}#resolve não implementado" end private diff --git a/app/policies/configuracao_policy.rb b/app/policies/configuracao_policy.rb new file mode 100644 index 0000000..5241764 --- /dev/null +++ b/app/policies/configuracao_policy.rb @@ -0,0 +1,20 @@ +# app/policies/configuracao_policy.rb +class ConfiguracaoPolicy < ApplicationPolicy + def index? + admin_ou_gerente? + end + + def edit? + admin? + end + + def update? + admin? + end + + class Scope < Scope + def resolve + scope.all + end + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb new file mode 100644 index 0000000..6e29731 --- /dev/null +++ b/app/policies/user_policy.rb @@ -0,0 +1,46 @@ +# app/policies/user_policy.rb +class UserPolicy < ApplicationPolicy + def index? + admin_ou_gerente? + end + + def show? + admin? || record == user + end + + def create? + admin? + end + + def new? + create? + end + + def update? + admin? || record == user + end + + def edit? + update? + end + + def destroy? + admin? && record != user + end + + def toggle_ativo? + admin? + end + + class Scope < Scope + def resolve + if user.admin? + scope.all + elsif user.gerente? + scope.where.not(role: :admin) + else + scope.where(id: user.id) + end + end + end +end diff --git a/app/views/configuracoes/edit.html.erb b/app/views/configuracoes/edit.html.erb new file mode 100644 index 0000000..dacb82f --- /dev/null +++ b/app/views/configuracoes/edit.html.erb @@ -0,0 +1,58 @@ +<%# app/views/configuracoes/edit.html.erb %> +
+
+

+ <%= @configuracao.icone %> Editar: <%= @configuracao.tipo_label %> +

+

Atualize o valor do pilar de preço

+
+ + <%= render 'shared/flash' %> + + <%= form_with(model: @configuracao, url: configuracao_path(@configuracao), method: :patch, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6') do |f| %> + + <% if @configuracao.errors.any? %> +
+ +
+ <% end %> + +
+ <%= f.label :valor, 'Valor (R$)', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> +
+ R$ + <%= f.number_field :valor, + step: 0.01, + min: 0, + class: 'w-full pl-10 pr-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl + text-white text-xl font-bold focus:outline-none focus:border-[#f97316] + focus:ring-1 focus:ring-[#f97316] transition-colors', + placeholder: '0.00' %> +
+

Valor atual: <%= moeda(@configuracao.valor) %>

+
+ +
+ <%= f.label :descricao, 'Descrição (opcional)', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_area :descricao, rows: 2, + 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 resize-none', + placeholder: 'Ex: Entrega padrão para UBS e EMAD' %> +
+ +
+ <%= link_to 'Cancelar', configuracoes_path, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit 'Salvar Preço', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> +
+ <% end %> +
diff --git a/app/views/configuracoes/index.html.erb b/app/views/configuracoes/index.html.erb new file mode 100644 index 0000000..1e985c8 --- /dev/null +++ b/app/views/configuracoes/index.html.erb @@ -0,0 +1,64 @@ +<%# app/views/configuracoes/index.html.erb %> +
+ +
+

Configurações

+

Pilares de preço e parâmetros do sistema

+
+ + <%= render 'shared/flash' %> + + <%# Pilares de preço %> +
+

💰 Pilares de Preço

+
+ <% @configuracoes.each do |cfg| %> +
+
+
<%= cfg.icone %>
+ <% if policy(cfg).edit? %> + <%= link_to edit_configuracao_path(cfg), + class: 'opacity-0 group-hover:opacity-100 transition-opacity p-1.5 + text-gray-500 hover:text-white hover:bg-white/10 rounded-lg' do %> + + + + <% end %> + <% end %> +
+

<%= cfg.tipo_label %>

+

<%= moeda(cfg.valor) %>

+ <% if cfg.descricao.present? %> +

<%= cfg.descricao %>

+ <% end %> +
+ <% end %> +
+
+ + <%# Informações do banco externo %> +
+

🗄️ Banco de Dados Externo

+
+
+

Tabela monitorada

+

db_reem_simplerout_2026

+
+
+

Account ID

+

95907 (Gade Hospitalar)

+
+
+

Atualização

+

A cada 1 hora

+
+
+

+ + + + Somente leitura — nunca alterar, deletar ou truncar esta tabela. +

+
+
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb new file mode 100644 index 0000000..f833ac3 --- /dev/null +++ b/app/views/dashboard/index.html.erb @@ -0,0 +1,234 @@ +<%# app/views/dashboard/index.html.erb %> +<%# Fase 3: Dashboard com cards, gráfico Chart.js e cálculo ao vivo %> + +
+ + <%# Header + filtro de mês %> +
+
+

Dashboard

+

+ <%= @data_referencia.strftime('%B de %Y').capitalize %> · Atualizado às <%= Time.now.strftime('%H:%M') %> +

+
+ + <%# Navegação de mês %> +
+ <%= link_to dashboard_path(data: (@data_referencia - 1.month).to_s), + class: 'p-2.5 bg-[#1a1a1a] hover:bg-white/10 border border-white/10 rounded-xl + text-gray-400 hover:text-white transition-colors' do %> + + + + <% end %> + +
+ <%= @data_referencia.strftime('%b %Y').capitalize %> +
+ + <% unless @data_referencia >= Date.today.beginning_of_month %> + <%= link_to dashboard_path(data: (@data_referencia + 1.month).to_s), + class: 'p-2.5 bg-[#1a1a1a] hover:bg-white/10 border border-white/10 rounded-xl + text-gray-400 hover:text-white transition-colors' do %> + + + + <% end %> + <% end %> +
+
+ + <%# CARDS GRANDES — KPIs %> +
+ + <%# Card 1: Valor estimado %> +
+
+
+

💰 Valor Estimado

+

+ <%= moeda(@valor_estimado) %> +

+

+ <%= @entregas_pagas %> entregas pagas +

+
+
+ + <%# Card 2: Total de entregas %> +
+

📦 Total Entregas

+

<%= @total_entregas %>

+
+ <%= @entregas_pagas %> pagas + · + <%= @entregas_pendentes %> pendentes +
+
+ + <%# Card 3: Consolidações %> +
+

📋 Consolidações

+

<%= @consolidacoes_mes.count %>

+
+ <%= @consolidacoes_abertas %> abertas + · + <%= @consolidacoes_fechadas %> fechadas +
+
+ + <%# Card 4: Motoristas ativos %> +
+

🚗 Motoristas

+

<%= @motoristas.count %>

+

com entregas no mês

+
+
+ + <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %> +
+ + <%# Gráfico de linha diário (Chart.js) %> +
+
+
+

Evolução Diária

+

Valor estimado por dia no mês

+
+
+ Valor (R$) +
+
+
+ +
+
+ + <%# Top motoristas %> +
+

🏆 Top Motoristas

+ + <% if @motoristas.any? %> +
+ <% @motoristas.each_with_index do |m, i| %> +
+ + <%= i + 1 %> + +
+
+ <%= m[:nome].split.first %> + + <%= moeda(m[:valor]) %> + +
+ <%# Barra de progresso %> +
+ <% max = @motoristas.first[:entregas].to_f %> +
+
+ <%= m[:entregas] %> entregas +
+
+ <% end %> +
+ <% else %> +
+
🚚
+

Nenhuma entrega paga neste mês

+
+ <% end %> +
+
+ + <%# Por operação %> + <% if @por_operacao.any? %> +
+

🏥 Entregas por Local

+
+ <% total_op = @por_operacao.values.sum.to_f %> + <% @por_operacao.each do |local, qtd| %> +
+

<%= qtd %>

+

<%= local.to_s.truncate(20) %>

+

<%= ((qtd / total_op) * 100).round %>%

+
+ <% end %> +
+
+ <% end %> + +
+ +<%# Chart.js via CDN %> + + + diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb new file mode 100644 index 0000000..4b36eb8 --- /dev/null +++ b/app/views/devise/sessions/new.html.erb @@ -0,0 +1,231 @@ +<%# app/views/devise/sessions/new.html.erb %> +
+
+ + <%# Logo / Header %> +
+
+ + + +
+

Gade Logística

+

Sistema de Controle de Custos

+
+ + <%# Flash messages %> + <% if flash[:alert] %> +
+ <%= flash[:alert] %> +
+ <% end %> + + <%# Tabs: Email / PIN %> +
+ + <%# Tab switcher %> +
+ + +
+ + <%# Login por e-mail %> +
+ <%= form_with(url: user_session_path, method: :post) do |f| %> +
+
+ <%= f.label :email, 'E-mail', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.email_field :email, + autofocus: true, + autocomplete: 'email', + 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 text-base', + placeholder: 'admin@gade.com' %> +
+ +
+ <%= f.label :password, 'Senha', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.password_field :password, + autocomplete: 'current-password', + 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 text-base', + placeholder: '••••••••' %> +
+ +
+ + <%= link_to 'Esqueci a senha', new_user_password_path, + class: 'text-sm text-[#f97316] hover:text-orange-400 transition-colors' %> +
+ + <%= f.submit 'Entrar', + class: 'w-full py-3.5 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer text-base min-h-[48px]' %> +
+ <% end %> +
+ + <%# Login por PIN %> + +
+ +

+ Gade Hospitalar © <%= Date.today.year %> +

+
+
+ + diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb new file mode 100644 index 0000000..74f76b1 --- /dev/null +++ b/app/views/layouts/_sidebar.html.erb @@ -0,0 +1,64 @@ +<%# app/views/layouts/_sidebar.html.erb %> +
+ <%# Logo %> +
+
+ + + +
+
+

Gade Logística

+

Controle de Custos

+
+
+ + <%# Navegação %> + + + <%# Footer do sidebar - info do usuário %> +
+
+
+ <%= current_user.name.to_s[0].upcase %> +
+
+

<%= current_user.name %>

+

<%= current_user.role.humanize %>

+
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index efbbbe8..74efb66 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,95 +1,169 @@ +<%# app/views/layouts/application.html.erb %> - + - - <%= content_for?(:title) ? "#{yield(:title)} | Gade Logística" : "Gade Logística" %> + <%= content_for?(:title) ? yield(:title) + ' · ' : '' %>Gade Logística - <%# Tailwind via CDN em desenvolvimento — compilar em produção %> - - - - <%# Google Fonts — Inter %> - - + <%= stylesheet_link_tag 'application', data: { turbo_track: 'reload' } %> + <%= javascript_importmap_tags %> - - <%= yield :head %> - + - <%# ── Navbar ─────────────────────────────────────────── %> - <% if user_signed_in? %> - <%= render 'layouts/navbar' %> - <% end %> + <% if user_signed_in? && !current_user.motorista? %> + <%# Layout principal com sidebar %> +
- <%# ── Flash Messages ─────────────────────────────────── %> - <% if notice.present? %> -
- <%= notice %> + <%# 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 %> +
+
- <% end %> - <% if alert.present? %> -
- ⚠️ <%= alert %> + <% elsif user_signed_in? && current_user.motorista? %> + <%# Layout simplificado para motorista %> +
+
+ <%= yield %> +
- <% end %> - <%# ── Conteúdo principal ──────────────────────────────── %> -
+ <% else %> + <%# Layout sem sidebar (login, etc) %> <%= yield %> -
+ <% end %> - <%# Auto-hide flash após 4s (JS simples) %> + function toggleSidebar() { + const mobile = document.getElementById('sidebar-mobile'); + const overlay = document.getElementById('sidebar-overlay'); + const isOpen = !mobile.classList.contains('-translate-x-full'); + if (isOpen) { + mobile.classList.add('-translate-x-full'); + overlay.classList.add('hidden'); + } else { + mobile.classList.remove('-translate-x-full'); + overlay.classList.remove('hidden'); + } + } + + // Fecha sidebar ao pressionar Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const mobile = document.getElementById('sidebar-mobile'); + if (mobile && !mobile.classList.contains('-translate-x-full')) toggleSidebar(); + } + }); + diff --git a/app/views/motorista/index.html.erb b/app/views/motorista/index.html.erb new file mode 100644 index 0000000..076425f --- /dev/null +++ b/app/views/motorista/index.html.erb @@ -0,0 +1,96 @@ +<%# app/views/motorista/index.html.erb %> +
+ +
+
+

+ Olá, <%= current_user.nome_display %>! 👋 +

+

+ Suas entregas de <%= @data_referencia.strftime('%B/%Y').capitalize %> +

+
+ <%= link_to new_user_session_path, method: :delete, + data: { turbo_method: :delete }, + class: 'px-4 py-2.5 bg-[#1a1a1a] border border-white/10 hover:border-red-500/50 + text-gray-400 hover:text-red-400 rounded-xl transition-colors text-sm' do %> + Sair + <% end %> +
+ + <%# Cards do motorista %> +
+
+

💰 Estimado

+

<%= moeda(@valor_estimado) %>

+
+
+

Total

+

<%= @total_entregas %>

+
+
+

✅ Pagas

+

<%= @pagas %>

+
+
+

⏳ Pendentes

+

<%= @pendentes %>

+
+
+ + <%# Últimas entregas %> +
+
+

Últimas Entregas Pagas

+
+ <% if @ultimas_entregas.any? %> +
+ <% @ultimas_entregas.each do |entrega| %> +
+
+

<%= entrega.contact_name %>

+

NF: <%= entrega.reference_id %>

+
+
+

✅ Paga

+

<%= entrega.data_formatada %>

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

Nenhuma entrega paga este mês ainda.

+
+ <% end %> +
+ + <%# Consolidações do motorista %> + <% if @consolidacoes.any? %> +
+
+

Seus Fechamentos

+
+
+ <% @consolidacoes.each do |cm| %> +
+
+

<%= cm.consolidacao.mes_label %>

+

<%= cm.consolidacao_entregas.count %> entregas

+
+
+ <%= badge_status(cm.consolidacao.status) %> + <% if cm.consolidacao.aprovada? %> + <%= link_to '#', class: 'text-[#f97316] text-sm hover:underline' do %> + 📄 PDF + <% end %> + <% end %> +
+
+ <% end %> +
+
+ <% end %> + +
diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb new file mode 100644 index 0000000..dafe797 --- /dev/null +++ b/app/views/shared/_flash.html.erb @@ -0,0 +1,18 @@ +<%# app/views/shared/_flash.html.erb %> +<% if flash[:notice] %> +
+ + + + <%= flash[:notice] %> +
+<% end %> + +<% if flash[:alert] %> +
+ + + + <%= flash[:alert] %> +
+<% end %> diff --git a/app/views/usuarios/_form.html.erb b/app/views/usuarios/_form.html.erb new file mode 100644 index 0000000..13e1422 --- /dev/null +++ b/app/views/usuarios/_form.html.erb @@ -0,0 +1,153 @@ +<%# app/views/usuarios/_form.html.erb %> +
+ +
+

+ <%= usuario.new_record? ? 'Novo Usuário' : "Editar: #{usuario.name}" %> +

+

+ <%= usuario.new_record? ? 'Preencha os dados para criar um novo acesso' : 'Atualize as informações do usuário' %> +

+
+ + <%= render 'shared/flash' %> + + <%= form_with(model: usuario, url: usuario.new_record? ? usuarios_path : usuario_path(usuario), + method: usuario.new_record? ? :post : :patch, + class: 'bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6') do |f| %> + + <% if usuario.errors.any? %> +
+

+ <%= pluralize(usuario.errors.count, 'erro', 'erros') %> encontrado<%= usuario.errors.count > 1 ? 's' : '' %>: +

+
    + <% usuario.errors.full_messages.each do |msg| %> +
  • <%= msg %>
  • + <% end %> +
+
+ <% end %> + +
+ <%# Nome %> +
+ <%= f.label :name, 'Nome completo', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.text_field :name, + 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', + placeholder: 'João da Silva' %> +
+ + <%# Perfil %> +
+ <%= f.label :role, 'Perfil de acesso', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.select :role, + options_for_select(role_options_for_select, usuario.role), + {}, + class: 'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white + focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316] + transition-colors cursor-pointer' %> +
+ + <%# Ativo toggle %> +
+
+ <%= f.label :ativo, 'Usuário ativo', class: 'block text-sm font-medium text-gray-300' %> +

Usuários inativos não conseguem fazer login

+
+ +
+
+ + <%# E-mail e senha (não motorista) %> +
+
+

Credenciais de acesso

+
+
+ <%= f.label :email, 'E-mail', class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.email_field :email, + 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', + placeholder: 'usuario@gade.com' %> +
+
+ <%= f.label :password, + usuario.new_record? ? 'Senha' : 'Nova senha (deixe em branco para manter)', + class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.password_field :password, + 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', + placeholder: '••••••••' %> +
+
+ <%= f.label :password_confirmation, 'Confirmar senha', + class: 'block text-sm font-medium text-gray-300 mb-1.5' %> + <%= f.password_field :password_confirmation, + 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', + placeholder: '••••••••' %> +
+
+
+
+ + <%# PIN (motorista) %> + + + <%# Botões %> +
+ <%= link_to 'Cancelar', usuarios_path, + class: 'px-6 py-3 text-gray-400 hover:text-white border border-white/10 + hover:border-white/20 rounded-xl transition-colors' %> + <%= f.submit usuario.new_record? ? 'Criar Usuário' : 'Salvar Alterações', + class: 'px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold + rounded-xl transition-colors cursor-pointer min-h-[48px]' %> +
+ <% end %> +
+ + diff --git a/app/views/usuarios/edit.html.erb b/app/views/usuarios/edit.html.erb new file mode 100644 index 0000000..8f44bbe --- /dev/null +++ b/app/views/usuarios/edit.html.erb @@ -0,0 +1,2 @@ +<%# app/views/usuarios/edit.html.erb %> +<%= render 'form', usuario: @usuario %> diff --git a/app/views/usuarios/index.html.erb b/app/views/usuarios/index.html.erb new file mode 100644 index 0000000..ab7ebdb --- /dev/null +++ b/app/views/usuarios/index.html.erb @@ -0,0 +1,120 @@ +<%# app/views/usuarios/index.html.erb %> +
+ + <%# Header %> +
+
+

Usuários

+

Gerencie o acesso ao sistema

+
+ <% if policy(User).create? %> + <%= link_to new_usuario_path, + class: 'flex items-center gap-2 px-5 py-3 bg-[#f97316] hover:bg-orange-500 + text-white font-semibold rounded-xl transition-colors min-h-[48px]' do %> + + + + Novo Usuário + <% end %> + <% end %> +
+ + <%# Flash %> + <%= render 'shared/flash' %> + + <%# Tabela %> +
+ + + + + + + + + + + + <% @usuarios.each do |usuario| %> + + + + + + + + <% end %> + + <% if @usuarios.empty? %> + + + + <% end %> + +
NomeE-mail / PINPerfilStatusAções
+
+
+ <%= usuario.name.to_s[0].upcase %> +
+ <%= usuario.name %> +
+
+ <% if usuario.motorista? && usuario.pin_acesso.present? %> +
PIN: ••••
+ <% else %> +
<%= usuario.email %>
+ <% end %> +
+ <%= badge_role(usuario.role) %> + + <%= badge_status_usuario(usuario.ativo?) %> + +
+ <% if policy(usuario).edit? %> + <%= link_to edit_usuario_path(usuario), + class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10 rounded-lg transition-colors', + title: 'Editar' do %> + + + + <% end %> + <% end %> + + <% if policy(usuario).toggle_ativo? %> + <%= button_to toggle_ativo_usuario_path(usuario), method: :post, + class: "p-2 rounded-lg transition-colors #{usuario.ativo? ? 'text-green-400 hover:bg-green-900/30' : 'text-red-400 hover:bg-red-900/30'}", + title: usuario.ativo? ? 'Desativar' : 'Ativar', + data: { confirm: "#{usuario.ativo? ? 'Desativar' : 'Ativar'} #{usuario.name}?" } do %> + + + + <% end %> + <% end %> + + <% if policy(usuario).destroy? %> + <%= button_to usuario_path(usuario), method: :delete, + class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors', + title: 'Excluir', + data: { confirm: "Excluir #{usuario.name} permanentemente?" } do %> + + + + <% end %> + <% end %> +
+
+
👥
+

Nenhum usuário encontrado.

+
+ + <% if @usuarios.respond_to?(:total_pages) && @usuarios.total_pages > 1 %> +
+ <%= paginate @usuarios %> +
+ <% end %> +
+
diff --git a/app/views/usuarios/new.html.erb b/app/views/usuarios/new.html.erb new file mode 100644 index 0000000..d0c9862 --- /dev/null +++ b/app/views/usuarios/new.html.erb @@ -0,0 +1,2 @@ +<%# app/views/usuarios/new.html.erb %> +<%= render 'form', usuario: @usuario %> diff --git a/config/routes.rb b/config/routes.rb index 648b6c2..ef0ee97 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,50 +1,58 @@ # config/routes.rb Rails.application.routes.draw do - # Devise — login padrão para admin/gerente/operador - devise_for :users, path: 'auth', path_names: { - sign_in: 'login', - sign_out: 'logout', - password: 'senha' - } + # Devise com controller customizado para sessões + devise_for :users, + controllers: { + sessions: 'users/sessions' + } - # Root — redireciona por role - root 'dashboard#index' + # Root + authenticated :user do + root 'dashboard#index', as: :authenticated_root + end + root 'devise/sessions#new' - # Dashboard principal (admin/gerente/operador) + # Dashboard (Fase 3) get '/dashboard', to: 'dashboard#index', as: :dashboard - # Painel motorista - get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard + # Painel motorista (Fase 2 / 8) + get '/motorista', to: 'motorista#index', as: :motorista - # Consolidações + # 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) resources :consolidacoes do member do - post :finalizar - post :arquivar - get :preview_holerite - get :gerar_pdf_relatorio - get :gerar_pdf_holerite - end - - resources :consolidacao_motoristas, only: [:index, :show] do - resources :consolidacao_entregas, only: [:index, :create, :update] + patch :fechar + patch :aprovar + patch :cancelar + post :reabrir end + resources :consolidacao_motoristas, only: [:create, :update, :destroy], shallow: true end - # Configurações (apenas admin/gerente) - namespace :admin do - resources :usuarios, except: [:show] - resources :configuracoes, only: [:index, :update] - resources :auditoria_logs, only: [:index] - end + # Histórico estimado (Fase 4) + resources :historico_estimados, only: [:index, :show] - # API interna — Dashboard métricas - namespace :api do - namespace :v1 do - get 'dashboard/metricas', to: 'dashboard#metricas' - end - end + # Logs de auditoria (Fase 4) + resources :auditoria_logs, only: [:index, :show] - # Health check (Docker) - get '/health', to: proc { [200, {}, ['OK']] } + # 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']] } end diff --git a/db/migrate/20260610_add_fase2_fields_to_users.rb b/db/migrate/20260610_add_fase2_fields_to_users.rb new file mode 100644 index 0000000..43bbc02 --- /dev/null +++ b/db/migrate/20260610_add_fase2_fields_to_users.rb @@ -0,0 +1,25 @@ +# db/migrate/YYYYMMDDHHMMSS_add_fase2_fields_to_users.rb +# Renomeie com o timestamp correto: rails g migration AddFase2FieldsToUsers +class AddFase2FieldsToUsers < ActiveRecord::Migration[7.1] + def change + # PIN de acesso (motoristas) + add_column :users, :pin_acesso, :string, limit: 4 + add_index :users, :pin_acesso, unique: true, where: "pin_acesso IS NOT NULL" + + # Status ativo/inativo + add_column :users, :ativo, :boolean, default: true, null: false + + # Acompanhamento de login (Devise :trackable) + add_column :users, :sign_in_count, :integer, default: 0, null: false unless column_exists?(:users, :sign_in_count) + add_column :users, :current_sign_in_at, :datetime unless column_exists?(:users, :current_sign_in_at) + add_column :users, :last_sign_in_at, :datetime unless column_exists?(:users, :last_sign_in_at) + add_column :users, :current_sign_in_ip, :string unless column_exists?(:users, :current_sign_in_ip) + add_column :users, :last_sign_in_ip, :string unless column_exists?(:users, :last_sign_in_ip) + + # Lockable + add_column :users, :failed_attempts, :integer, default: 0, null: false unless column_exists?(:users, :failed_attempts) + add_column :users, :unlock_token, :string unless column_exists?(:users, :unlock_token) + add_column :users, :locked_at, :datetime unless column_exists?(:users, :locked_at) + add_index :users, :unlock_token, unique: true unless index_exists?(:users, :unlock_token) + end +end diff --git a/db/seeds.rb b/db/seeds.rb index c7d8ab7..36fb761 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,47 +1,78 @@ # db/seeds.rb -# Dados iniciais do sistema Gade Hospitalar -# Execute com: bundle exec rails db:seed +# Rodar com: docker-compose exec app bundle exec rails db:seed -puts "🌱 Criando dados iniciais..." +puts "🌱 Iniciando seeds..." -# ── 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 " ✅ Admin criado: #{admin.email} / senha: Gade@2026!" - -# ── 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 ──────────────────────────── +# ─── Configurações de preço (Fase 1 + confirmaçã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' }, + { 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 |cfg| - Configuracao.find_or_create_by!(chave: cfg[:chave]) do |c| - c.valor = cfg[:valor] - c.descricao = cfg[:descricao] +configs.each do |c| + Configuracao.find_or_create_by!(tipo: c[:tipo]) do |cfg| + cfg.valor = c[:valor] + cfg.descricao = c[:descricao] + end +end +puts " ✅ #{Configuracao.count} configurações de preço" + +# ─── 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 } +] + +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(', ')}" end - puts " ✅ Config: #{cfg[:chave]} = #{cfg[:valor]}" end puts "" puts "✅ Seeds concluídos!" -puts " Admin: admin@gade.com / Gade@2026!" -puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!" +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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/prompt_sistema_logistica_final.html b/prompt_sistema_logistica_final.html new file mode 100644 index 0000000..dee8e26 --- /dev/null +++ b/prompt_sistema_logistica_final.html @@ -0,0 +1,771 @@ + + + + +Prompt - Sistema de Controle de Custos Logística + + + + +

🚛 PROMPT: Sistema de Controle de Custos - Logística

+ +
+⚠️ INSTRUÇÃO INICIAL OBRIGATÓRIA:
+Antes de começar, pergunte ao usuário: "Qual fase devo executar? Informe o número da fase (1 a 8)."
+Se o usuário informar uma fase, execute APENAS essa fase. Se informar "TUDO", execute sequencialmente parando a cada fase para confirmação.
+Ao final de CADA fase, gere um CHECKPOINT com: (1) O que foi feito, (2) Próxima fase, (3) Arquivos criados/modificados.
+ATENÇÃO: O banco de dados EXISTE e tem dados reais. NUNCA execute DROP TABLE, TRUNCATE ou DELETE em massa nas tabelas existentes. +
+ +

📋 RESUMO EXECUTIVO (Contexto)

+
+Empresa de logística Gade Hospitalar precisa controlar custos por operação (ex: UBS SUL, UBS LESTE, EMAD, STS, SAD).
+Sistema web em Ruby on Rails + PostgreSQL 15 (banco JÁ EXISTE com dados reais) + Docker.
+Público: administradores, gerentes, motoristas e operadores (baixo nível tech).
+Design: moderno, minimalista, cores padrão PRETO + LARANJA + BRANCO, ícones grandes, poucos cliques, feedback visual claro. +
+ + + +

🗄️ DADOS REAIS DO BANCO EXISTENTE REAL

+ +
+📡 Tabela existente: public.db_reem_simplerout_2026
+🔄 Atualização: Automática a cada 1 hora (dados sobem de outro sistema)
+🏢 Account: Gade Hospitalar (account_id: 95907) + +

📊 Estrutura da Tabela Existente (46 colunas)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ColunaTipoUso no Sistema
tracking_idstringID único da entrega (PK lógico)
reference_idintegerNúmero da NF (Nota Fiscal)
planned_datedateData planejada da entrega
driverstringNome do motorista
codriversstringAjudante (geralmente NULL)
vehiclestringPlaca/ID do veículo (ex: GADE_037)
titlestringTítulo da entrega (ex: "NF 46497 - ELISABETH ROCHA FERREIRA")
addressstringEndereço completo da entrega
etatimeHora estimada de chegada
etdtimeHora estimada de saída
checkindatetimeHorário real de chegada (pode ser NULL)
checkoutdatetimeHorário real de saída
responsible_personstringQuem fez a entrega (mesmo que driver)
estimated_service_timeintervalTempo estimado de serviço (fixo 00:10:00)
real_service_timeintervalTempo real de serviço (checkout - checkin)
advanceintervalQuanto chegou antes do planejado
delayintervalQuanto atrasou (geralmente 00:00)
latitude / longitudedecimalCoordenadas de chegada
checkout_latitude / checkout_longitudedecimalCoordenadas de saída
load / load_2 / load_3integerQuantidade de cargas (geralmente 1/0/0)
statusstringStatus da entrega (completed, pending, etc.)
commentstextComentários do motorista
observationstringObservações internas
start_of_time_window_1 / end_of_time_window_1timeJanela de horário 1 (08:00-18:00)
start_of_time_window_2 / end_of_time_window_2timeJanela de horário 2 (23:59-23:59)
required_skills / optional_skillsstringHabilidades necessárias
notesstringComplementos (ex: "COMPL: CASA 1", "TEL: (11) 99999-9999")
contact_namestringNome do local (ex: UBS SACOMA, EMAD VELEIROS, STS BUTANTA)
contact_phonestringTelefone do local
contact_emailstringEmail do local (geralmente NULL)
route_iduuidID da rota/operacao (ex: UBS SUL, UBS LESTE)
account_idintegerID da conta (95907 = Gade Hospitalar)
account_namestringNome da conta
nota_fiscalurlFoto da NF (URL S3)
protocolo_de_entregaurlFoto do protocolo (URL S3)
termo_de_recebimentourlFoto do termo (URL S3)
foto_da_fachadaurlFoto da fachada (URL S3)
relatorio_de_visitaurlFoto do relatório (URL S3)
numero_de_serie_da_base_aparelhostringNúmero de série do equipamento
+ +

📈 Dados de Exemplo (Extraídos do Banco Real)

+
    +
  • Motoristas: Bruno Corgozinho, Carlos Matheus Pimentel, Edson Vinicius Pereira, Gregori Karenkvic, Steferson Lucas, Thiago Rabello Bittencourt, Henderson Estevam
  • +
  • Veículos: GADE_001 a GADE_123 (14 veículos diferentes)
  • +
  • Locais (contact_name): UBS SACOMA, UBS JD SELMA, EMAD SANTA CECILIA, EMAD VELEIROS, STS BUTANTA, EMAD GUARANI VARGAS, UBS JD ELIANE, SAD SANTO AMARO, UBS UNIAO DAS VLS DE TAIPAS, UBS ALMIRANTE DELLAMARE, UBS JD AEROPORTO, UBS JD PARANAPANEMA, UBS SANTA LUCIA
  • +
  • Rotas (route_id): UUIDs que agrupam entregas por operação
  • +
  • Status: completed (entregas concluídas), pode haver pending, cancelled, etc.
  • +
  • Checkin: Pode ser NULL (entrega sem registro de chegada)
  • +
  • Load: Geralmente 1 (uma carga por entrega)
  • +
+ +

🔑 Regras de Negócio Baseadas nos Dados Reais

+
    +
  • Uma entrega = um tracking_id único
  • +
  • Cada entrega tem um reference_id = número da NF
  • +
  • O driver é o motorista principal (codrivers geralmente NULL)
  • +
  • O contact_name identifica o local/UBS de destino
  • +
  • O route_id agrupa entregas da mesma operação (ex: UBS SUL, UBS LESTE)
  • +
  • status = 'completed' = entrega bem-sucedida (paga)
  • +
  • status != 'completed' = pode ser desconto, pendente, cancelada
  • +
  • checkin IS NULL = motorista não registrou chegada (possível desconto)
  • +
  • real_service_time = '00:00:00' = chegou e saiu no mesmo minuto (possível problema)
  • +
+
+ + + +

🔧 MELHORIAS SUGERIDAS NO DESIGN E LÓGICA

+ +
+

✨ MELHORIA 1 — Cards Visuais Grandes no Dashboard NOVO

+

Cards com cores da marca (preto/laranja/branco):

+
    +
  • 🟠 Card Entregas OK — laranja, contador grande e % de sucesso
  • +
  • Card Pendentes — preto, entregas não consolidadas
  • +
  • Card Descontos — branco com borda laranja, total de descontos
  • +
  • 🟠 Card Bônus — laranja claro, total de bônus
  • +
+
+ +
+

✨ MELHORIA 2 — Modo "Revisão Rápida" com Ações em Massa NOVO

+

Botões de ação em massa na consolidação:

+
    +
  • "Marcar Todos como Entrega Normal"
  • +
  • "Marcar Selecionados como..." (Shift+click para selecionar múltiplas)
  • +
  • Filtro: "Mostrar apenas não classificadas"
  • +
  • Barra de progresso: "340 de 400 entregas classificadas (85%)"
  • +
+
+ +
+

✨ MELHORIA 3 — Preview do Holerite Antes de Gerar PDF NOVO

+

Modal de preview HTML antes de gerar PDF — evita erros e re-trabalho.

+
+ +
+

✨ MELHORIA 4 — Notificações WhatsApp/E-mail NOVO

+

Ao finalizar consolidação, enviar para motoristas: "Sua consolidação [Período] está pronta. Valor: R$ [X]. Baixe: [link]"

+
+ +
+

✨ MELHORIA 5 — Comparativo Estimado vs Consolidado NOVO

+

Gráfico de barras lado a lado no Dashboard: Estimado (cinza) vs Consolidado (laranja) vs Diferença (preto).

+
+ +
+

✨ MELHORIA 6 — Filtro Avançado nas Consolidações NOVO

+

Busca por nome da operação, período, motorista, status, route_id.

+
+ +
+

✨ MELHORIA 7 — Soft Delete + Auditoria NOVO

+

Consolidações finalizadas NUNCA excluídas — apenas arquivadas. Log completo de quem fez o quê.

+
+ +
+

✨ MELHORIA 8 — Tema Escuro/Claro NOVO

+

Toggle ☀️/🌙 no header. Tema escuro como padrão (preto + laranja fica incrível).

+
+ +
+

🔁 MUDANÇA 1 — Checkboxes → Toggle Switches Visuais MUDANÇA

+

Toggle switches grandes coloridos: 🟠 laranja = Entrega Normal | ⚫ preto = Desconto | ⚪ branco = Bônus | 🟠 laranja escuro = Retirada

+
+ +
+

🔁 MUDANÇA 2 — Validação em Wizard de 3 Passos MUDANÇA

+

Passo 1: Selecionar motorista | Passo 2: Classificar entregas com toggles + ações em massa | Passo 3: Revisar resumo → Próximo ou Finalizar

+
+ +
+

🔁 MUDANÇA 3 — Job de 1h + Cálculo em Tempo Real MUDANÇA

+

Job persiste histórico, mas Dashboard calcula na hora ao abrir. Sem esperar 1h.

+
+ +
+

🔁 MUDANÇA 4 — Login por PIN de 4 Dígitos + QR Code MUDANÇA

+

Motoristas usam PIN numérico ou escaneiam QR do holerite. Sem senha complexa.

+
+ + + +

🏗️ ARQUITETURA

+ + + + + + + + + + + + + + +
CamadaTecnologia
BackendRuby on Rails 7+ (API + Views)
FrontendRails Views + Hotwire (Turbo + Stimulus)
CSSTailwind CSS (tema customizado preto/laranja/branco)
Banco ExistentePostgreSQL 15 — tabela db_reem_simplerout_2026 (apenas leitura)
Banco NovoPostgreSQL 15 — tabelas do sistema (mesma instância, schema separado ou prefixo)
AuthDevise + Pundit (roles: admin, gerente, operador, motorista)
PDFPrawn (mais leve que WickedPDF)
Job/CronWhenever gem
NotificaçõesTwilio (WhatsApp) ou ActionMailer — opcional Fase 8
DockerDocker + docker-compose.yml
Config.env (NUNCA versionar)
GitServidor privado: https://git.xenserver.com.br
+ + + +

🔒 REPOSITÓRIO GIT SEGURANÇA

+ +
+⚠️ AVISO DE SEGURANÇA:
+NUNCA armazene credenciais de login (usuário/senha) em arquivos de código, prompts ou documentação.
+Sempre use variáveis de ambiente, CI/CD secrets ou gerenciadores de credenciais. +
+ +
+

📡 Servidor Git Privado

+

URL: https://git.xenserver.com.br (servidor próprio, privado)

+

Repositório: logistica-controle-custos (criar como PRIVADO)

+ +

🔐 Configuração Segura

+
    +
  1. Acesse https://git.xenserver.com.br e faça login com credenciais pessoais
  2. +
  3. Crie repositório privado: logistica-controle-custos
  4. +
  5. Configure remote local (SEM credenciais no URL):
  6. +
+
+git init
+git add .
+git commit -m "Initial commit - Fase 1"
+
+# Adicionar remote sem credenciais
+git remote add origin https://git.xenserver.com.br/[USUARIO]/logistica-controle-custos.git
+
+# Cache de credenciais (pede senha uma vez)
+git config --global credential.helper cache
+git config --global credential.helper 'cache --timeout=3600'
+
+# Primeiro push (digitar usuário/senha manualmente)
+git push -u origin main
+
+ +

📝 .gitignore Obrigatório

+
+# CREDENCIAIS — NUNCA versionar
+.env
+.env.local
+.env.*.local
+config/application.yml
+config/database.yml
+docker-compose.override.yml
+*.pem
+*.key
+*.p12
+
+# Rails
+/log/*
+/tmp/*
+!/log/.keep
+!/tmp/.keep
+/storage/*
+!/storage/.keep
+/public/assets
+/node_modules
+/yarn-error.log
+.byebug_history
+/config/master.key
+/config/credentials/*.key
+
+# Docker
+.env.docker
+
+# IDEs
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+ +

📋 Checklist de Segurança do Repositório

+
    +
  • ✅ Repositório marcado como PRIVADO
  • +
  • ✅ Arquivo .env no .gitignore
  • +
  • ✅ Nenhuma senha, token ou chave em código
  • +
  • config/database.yml usa variáveis de ambiente
  • +
  • docker-compose.yml usa env_file: .env
  • +
  • ✅ Secret Key Base gerada via rails secret e armazenada em variável de ambiente
  • +
+
+ +

👥 PERFIL DE USUÁRIOS E PERMISSÕES

+ + + + + + +
PerfilAcesso
AdminTudo: Dashboard, Consolidação, Configurações, Motoristas, Auditoria
GerenteDashboard, Consolidação, gerar PDFs/holerites, visualizar arquivadas
OperadorApenas Consolidação (criar, validar entregas, toggles), ver próprias consolidações
MotoristaDashboard pessoal: valores estimados + consolidados validados, baixar próprio PDF
+ +

💰 PILARES DE PREÇO (Configuráveis na tela Configurações)

+ + + + + + +
PilarDescriçãoTipoQuando Aplicar
Entrega Bem-SucedidaValor pago por entrega concluídaValor fixo (R$)status = 'completed' E checkin IS NOT NULL
Retirada de EquipamentoValor pago por retirada/devoluçãoValor fixo (R$)Quando o operador marcar como "Retirada" na consolidação
BônusValor adicional por meta/critérioValor fixo (R$)Quando o operador marcar como "Bônus" na consolidação
DescontoValor descontado por problemaValor fixo (R$)Quando o operador marcar como "Desconto" na consolidação
+ +

📐 ESTRUTURA DE TELAS

+ +

🔐 TELA DE LOGIN

+ + +

🖥️ PAINEL ADMIN / GERENTE / OPERADOR

+ +

1. DASHBOARD

+ + +

2. CONSOLIDAÇÃO

+ + +

3. CONFIGURAÇÕES

+ + +

🛵 PAINEL MOTORISTA

+ + +

🗄️ MODELO DE DADOS

+ +
+

📡 TABELAS EXISTENTES (APENAS LEITURA — NUNCA MODIFICAR)

+
+-- Já existe no PostgreSQL 15, atualizada a cada 1h
+-- Nome: public.db_reem_simplerout_2026
+-- Colunas principais usadas pelo sistema:
+--   tracking_id (PK lógico)
+--   reference_id (número da NF)
+--   planned_date (data da entrega)
+--   driver (nome do motorista)
+--   vehicle (placa/ID veículo)
+--   title (descrição da entrega)
+--   checkin, checkout (horários reais)
+--   status ('completed', 'pending', 'cancelled', etc.)
+--   route_id (UUID da operação/rota)
+--   contact_name (nome do local: UBS, EMAD, STS, SAD)
+--   load, load_2, load_3 (quantidade de cargas)
+--   nota_fiscal, relatorio_de_visita (URLs S3)
+--   latitude, longitude, checkout_latitude, checkout_longitude
+
+ +

🆕 TABELAS NOVAS DO SISTEMA (criar no mesmo PostgreSQL, schema separado ou prefixo)

+
+users (id, email, encrypted_password, pin_code, role, nome, telefone, ativo, tema_preferido, created_at, updated_at)
+  role: enum [admin, gerente, operador, motorista]
+  pin_code: string (4 dígitos, apenas motoristas)
+
+configuracoes (id, chave, valor, descricao, updated_at, updated_by)
+  chaves: 'preco_entrega', 'preco_retirada', 'preco_bonus', 'preco_desconto',
+         'notificacao_whatsapp', 'notificacao_email', 'empresa_nome'
+
+consolidacoes (id, nome, data_inicio, data_fim, status, valor_total,
+               created_by, finalizado_por, finalizado_em, created_at, updated_at, deleted_at)
+  status: enum [rascunho, finalizada, arquivada]
+
+consolidacao_motoristas (id, consolidacao_id, motorista_nome, valor_total, created_at, updated_at)
+  -- motorista_nome = driver do banco existente
+
+consolidacao_entregas (id, consolidacao_id, tracking_id, motorista_nome,
+                       tipo, valor_aplicado, created_at, created_by)
+  tipo: enum [entrega_normal, retirada, bonus, desconto]
+  -- tracking_id referencia db_reem_simplerout_2026.tracking_id
+
+historico_estimados (id, data_hora, valor_total_estimado, entregas_contadas, created_at)
+  -- atualizado a cada 1h pelo job
+
+auditoria_logs (id, user_id, acao, entidade, entidade_id, dados_anteriores, dados_novos, created_at)
+  -- log de todas as ações críticas
+
+
+ +

🐳 DOCKER + CONFIGURAÇÃO (YAML)

+
+# docker-compose.yml
+version: '3.8'
+services:
+  app:
+    build: .
+    ports: ["3000:3000"]
+    env_file: .env
+    environment:
+      DATABASE_URL: ${DATABASE_URL}
+      DB_HOST: ${DB_HOST}
+      DB_PORT: ${DB_PORT}
+      DB_NAME: ${DB_NAME}
+      DB_USER: ${DB_USER}
+      DB_PASSWORD: ${DB_PASSWORD}
+      SECRET_KEY_BASE: ${SECRET_KEY_BASE}
+      REDIS_URL: ${REDIS_URL}
+    volumes: [".:/app"]
+    depends_on: [db, redis]
+  db:
+    image: postgres:15
+    environment:
+      POSTGRES_DB: ${DB_NAME}
+      POSTGRES_USER: ${DB_USER}
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+    volumes:
+      - "pg_data:/var/lib/postgresql/data"
+      # Se o banco existente estiver em outro servidor, remover esta seção
+      # e apontar DB_HOST para o servidor correto
+  redis:
+    image: redis:alpine
+volumes:
+  pg_data:
+
+# .env (NUNCA versionar — adicionar no .gitignore)
+# ================================================
+DATABASE_URL="postgresql://user:pass@host:5432/db"
+DB_HOST="db"                    # ou IP do servidor PostgreSQL existente
+DB_PORT="5432"
+DB_NAME="logistica_db"
+DB_USER="postgres"
+DB_PASSWORD="senha_segura"
+SECRET_KEY_BASE="gerar_com_rails_secret"
+REDIS_URL="redis://redis:6379/0"
+
+# Twilio (opcional — Fase 8)
+TWILIO_ACCOUNT_SID=""
+TWILIO_AUTH_TOKEN=""
+TWILIO_WHATSAPP_FROM=""
+
+# Configuração do banco existente
+DB_EXISTING_TABLE="db_reem_simplerout_2026"
+DB_EXISTING_SCHEMA="public"
+
+ +

🎨 DIRETRIZES DE DESIGN (UX para baixo nível tech)

+
+

🎨 Paleta de Cores da Marca

+ + + + + + + + + +
ElementoCorHex
Fundo principal (tema escuro)Preto#0a0a0a ou #111111
Fundo cardsCinza escuro#1a1a1a ou #1e1e1e
Cor primária (botões, destaques)Laranja#f97316 ou #ea580c
Cor secundária (textos, ícones)Branco#ffffff
Cor de alerta/sucessoVerde#22c55e
Cor de erro/descontoVermelho#ef4444
Cor de info/bônusAzul claro#38bdf8
+ +

📐 Regras de UX

+
    +
  • Fonte mínima 18px no mobile, 16px no desktop
  • +
  • Botões mínimo 48px de altura, cantos arredondados (rounded-lg)
  • +
  • Toggle switches em vez de checkboxes — área maior, feedback visual
  • +
  • Modais de confirmação para ações destrutivas (finalizar, arquivar)
  • +
  • Toast notifications no canto superior direito, cor laranja para sucesso
  • +
  • Wizard em passos — uma ação por tela, sem abas aninhadas
  • +
  • Mobile-first — muitos motoristas acessarão pelo celular
  • +
  • Tema escuro como padrão — preto + laranja é a identidade da marca
  • +
  • Ícones + texto — nunca ícone sozinho
  • +
  • Placeholders explicativos nos campos (ex: "Ex: UBS SUL e LESTE Junho 2026")
  • +
  • Loading states — spinner laranja ao carregar dados
  • +
  • Empty states — mensagem amigável quando não há dados (ex: "Nenhuma entrega neste período")
  • +
+
+ +

📊 JOB DE ATUALIZAÇÃO ESTIMADA (a cada 1h)

+
+# Gemfile: gem 'whenever', require: false
+# config/schedule.rb
+every 1.hour do
+  runner "HistoricoEstimado.atualizar!"
+end
+
+# app/models/historico_estimado.rb
+class HistoricoEstimado < ApplicationRecord
+  def self.atualizar!
+    preco = Configuracao.valor('preco_entrega').to_f
+    # Buscar entregas do dia com status 'completed' e checkin IS NOT NULL
+    entregas = DbReemSimplerout2026
+      .where(status: 'completed')
+      .where.not(checkin: nil)
+      .where(planned_date: Date.current)
+      .count
+    total = entregas * preco
+    create!(
+      data_hora: Time.current,
+      valor_total_estimado: total,
+      entregas_contadas: entregas
+    )
+  end
+end
+
+# O Dashboard calcula em tempo real na hora do acesso:
+# SELECT COUNT(*) FROM db_reem_simplerout_2026
+# WHERE status = 'completed' AND checkin IS NOT NULL
+# AND planned_date BETWEEN :inicio AND :fim
+# O job serve apenas para manter histórico de gráficos.
+
+ +

📄 GERADOR DE PDF (Holerite)

+ + + + +

🔧 FASES DE IMPLEMENTAÇÃO (Checkpoint obrigatório ao final de cada uma)

+ +
+

🟠 FASE 1 — Setup Inicial + Docker + Git

+
    +
  1. Criar estrutura Rails 7+ com docker-compose.yml
  2. +
  3. Configurar conexão com PostgreSQL 15 existente (tabela db_reem_simplerout_2026 — APENAS LEITURA)
  4. +
  5. Criar model DbReemSimplerout2026 com self.table_name = 'db_reem_simplerout_2026' e self.primary_key = 'tracking_id'
  6. +
  7. Criar tabelas novas: users, configuracoes, consolidacoes, consolidacao_motoristas, consolidacao_entregas, historico_estimados, auditoria_logs
  8. +
  9. Configurar gem Figaro ou dotenv para variáveis em .env (NUNCA versionar .env)
  10. +
  11. Instalar e configurar Devise + Pundit (roles: admin, gerente, operador, motorista)
  12. +
  13. Seed: criar usuário admin padrão (admin@gade.com / senha123)
  14. +
  15. Testar docker-compose up e acesso ao Rails
  16. +
  17. Inicializar repositório Git: git init, criar .gitignore completo, primeiro commit
  18. +
  19. Adicionar remote para servidor privado git.xenserver.com.br
  20. +
  21. Push inicial para o servidor Git privado
  22. +
+CHECKPOINT FASE 1: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 2 — Autenticação + Perfis + Configurações

+
    +
  1. Implementar login com redirecionamento por role
  2. +
  3. Login por PIN de 4 dígitos para motoristas (alternativa a senha)
  4. +
  5. Criar tela de Configurações com CRUD de usuários (admin/gerente/operador/motorista)
  6. +
  7. Criar CRUD de Pilares de Preço (entrega, retirada, bônus, desconto) com máscara de moeda
  8. +
  9. Validar permissões: operador não vê config, motorista não vê admin
  10. +
  11. Layout base com navegação lateral responsiva (Tailwind + tema preto/laranja/branco)
  12. +
  13. Toggle tema claro/escuro no header
  14. +
  15. Commit e push para o Git privado
  16. +
+CHECKPOINT FASE 2: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 3 — Dashboard Admin/Gerente

+
    +
  1. Criar controller Dashboard com métricas consolidadas
  2. +
  3. Cards coloridos grandes (preto/laranja/branco): Entregas OK, Pendentes, Descontos, Bônus
  4. +
  5. Gráfico comparativo Estimado vs Consolidado (Chart.js simples, cores da marca)
  6. +
  7. Lista últimas 5 consolidações com link para detalhe
  8. +
  9. Barra de progresso "% do mês consolidado"
  10. +
  11. Cálculo em tempo real (SQL rápido) ao carregar a página — buscar de db_reem_simplerout_2026
  12. +
  13. Commit e push para o Git privado
  14. +
+CHECKPOINT FASE 3: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 4 — Job de Atualização Automática (1h) + Auditoria

+
    +
  1. Implementar job com Whenever gem para rodar a cada 1 hora
  2. +
  3. Lógica: contar entregas status='completed' + checkin IS NOT NULL do dia, multiplicar por preco_entrega, salvar em historico_estimados
  4. +
  5. Criar modelo AuditoriaLog para registrar ações críticas (criar, editar, finalizar, arquivar consolidação)
  6. +
  7. Expor valor na API interna para o Dashboard
  8. +
  9. Testar execução manual e confirmar persistência no banco
  10. +
  11. Commit e push para o Git privado
  12. +
+CHECKPOINT FASE 4: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 5 — Consolidação (Criar + Selecionar + Wizard Passo 1)

+
    +
  1. Tela "Nova Consolidação": formulário com nome, período (2 date pickers), multi-select de motoristas (busca de driver do banco), multi-select de rotas (route_id)
  2. +
  3. Ao salvar: criar registro em consolidacoes (status: rascunho) + consolidacao_motoristas
  4. +
  5. Listar consolidações com filtros (status, período, nome, motorista, route_id)
  6. +
  7. Wizard Passo 1: Selecionar motorista da lista (com total de entregas no período)
  8. +
  9. Commit e push para o Git privado
  10. +
+CHECKPOINT FASE 5: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 6 — Validação de Entregas + Toggles + Ações em Massa

+
    +
  1. Wizard Passo 2: Buscar entregas do período para o motorista selecionado (de db_reem_simplerout_2026)
  2. +
  3. Toggle switches coloridos grandes (laranja=normal, preto=desconto, branco=bônus, laranja escuro=retirada)
  4. +
  5. Ações em massa: "Marcar todos como Normal", "Marcar selecionados como..."
  6. +
  7. Filtro "Mostrar apenas não classificadas"
  8. +
  9. Barra de progresso "X de Y entregas classificadas"
  10. +
  11. Resumo lateral com total por tipo (Stimulus para atualização em tempo real)
  12. +
  13. Wizard Passo 3: Revisar resumo do motorista → "Próximo Motorista" ou "Salvar Rascunho"
  14. +
  15. Validação: não permitir finalizar sem classificar todas as entregas
  16. +
  17. Commit e push para o Git privado
  18. +
+CHECKPOINT FASE 6: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 7 — Geração de PDFs (Preview + Relatório + Holerite)

+
    +
  1. Instalar e configurar Prawn (mais leve que WickedPDF)
  2. +
  3. Layout de PDF: logo preto/laranja, nome consolidação, período, tabela de entregas, total
  4. +
  5. Modal de Preview HTML antes de gerar PDF (modal com botão Confirmar/Voltar)
  6. +
  7. Botão "Gerar Relatório Individual" por motorista (PDF com detalhe das entregas)
  8. +
  9. Botão "Gerar Holerite" (PDF resumido com valor total a pagar — estilo holerite)
  10. +
  11. QR code no holerite para login rápido do motorista
  12. +
  13. Soft delete: consolidações finalizadas vão para status "arquivada", nunca excluídas
  14. +
  15. Commit e push para o Git privado
  16. +
+CHECKPOINT FASE 7: Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase. +
+ +
+

🟠 FASE 8 — Painel do Motorista + Notificações + Finalização

+
    +
  1. Tela do motorista após login: 2 cards grandes (Valor Estimado laranja | Valor Consolidado branco)
  2. +
  3. Lista de consolidações em que ele aparece com link para baixar próprio PDF
  4. +
  5. Login por PIN ou QR Code (escaneia holerite para acessar)
  6. +
  7. Notificações: integração Twilio (WhatsApp) ou ActionMailer (email) — ao finalizar consolidação, enviar mensagem automática para motoristas
  8. +
  9. Ajustes finais de responsividade (mobile)
  10. +
  11. Revisar todas as permissões (Pundit)
  12. +
  13. Revisar textos/labels para clareza (público baixo nível tech)
  14. +
  15. README.md com instruções de deploy Docker e configuração do .env
  16. +
  17. Teste end-to-end: login → criar consolidação → validar → gerar PDF → notificar → logout → login motorista → ver valores
  18. +
  19. Commit final e push para o Git privado
  20. +
+CHECKPOINT FASE 8 (FINAL): Liste todos os arquivos do projeto. Status: ✅ COMPLETO. +
+ +

💡 DICAS PARA O CLAUDE (Instruções de Otimização)

+
+
    +
  • Use Rails 7+ com importmap — evite Webpack para reduzir complexidade
  • +
  • Use Tailwind via CDN durante desenvolvimento, depois compile se necessário
  • +
  • Para o banco existente: use self.table_name = 'db_reem_simplerout_2026' e self.primary_key = 'tracking_id' — NUNCA crie migration para essa tabela
  • +
  • Para toggles: use Stimulus para atualizar totais em tempo real sem recarregar página
  • +
  • Para o job de 1h: use Whenever gem (mais simples que Sidekiq para cron)
  • +
  • Para PDF: Prawn é mais leve que WickedPDF (sem dependência de wkhtmltopdf)
  • +
  • Para mobile: use CSS Grid/Flexbox com breakpoints Tailwind (md:, lg:)
  • +
  • Para tema escuro: use dark: prefix do Tailwind + class strategy — tema escuro como padrão
  • +
  • Para cores da marca: configure no tailwind.config.js — preto #0a0a0a, laranja #f97316, branco #ffffff
  • +
  • Seja verboso nos CHECKPOINTS — liste arquivo por arquivo para continuidade
  • +
  • A cada fase: git add . && git commit -m "Fase X completa" && git push
  • +
  • Se o banco existente estiver em servidor separado, configure DB_HOST no .env apontando para o IP correto
  • +
+
+ +

📝 EXEMPLO DE COMANDO PARA INICIAR CADA FASE

+
+# No início de cada conversa com o Claude, cole:
+"Execute a FASE [NÚMERO] do prompt de Sistema de Controle de Custos Logística.
+A fase anterior foi concluída. Aqui está o CHECKPOINT da fase anterior: [COLE AQUI]"
+
+# Comandos Git a cada fase:
+git add .
+git commit -m "Fase X - [descricao]"
+git push origin main
+
+ +
+⚠️ IMPORTANTE: Se em alguma fase o Claude atingir o limite de tokens/contexto, peça para ele gerar o CHECKPOINT incompleto e continue na próxima conta free com: "Continue a FASE X a partir deste CHECKPOINT: [cole]". +
+ +
+🔐 LEMBRETE FINAL DE SEGURANÇA:
+
    +
  • NUNCA commit arquivos .env, application.yml, ou qualquer arquivo com senha
  • +
  • NUNCA exponha SECRET_KEY_BASE, senhas de banco, ou tokens de API
  • +
  • Sempre use ENV['VARIAVEL'] no código Rails
  • +
  • Configure o repositório Git como PRIVADO no servidor git.xenserver.com.br
  • +
  • Use HTTPS com credential helper ou SSH keys para autenticação no Git
  • +
  • NUNCA execute DROP TABLE, TRUNCATE ou DELETE em massa na tabela db_reem_simplerout_2026 existente
  • +
+
+ + + \ No newline at end of file diff --git a/setup_fase2_3.sh b/setup_fase2_3.sh new file mode 100644 index 0000000..353d6e2 --- /dev/null +++ b/setup_fase2_3.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# setup_fase2_3.sh +# Script para aplicar os arquivos da Fase 2 e 3 no repositório +# Execute na raiz do projeto: bash setup_fase2_3.sh + +set -e + +echo "🚀 Aplicando Fases 2 e 3 — Gade Logística" +echo "" + +# Criar estrutura de diretórios necessários +mkdir -p app/controllers/users +mkdir -p app/views/devise/sessions +mkdir -p app/views/usuarios +mkdir -p app/views/configuracoes +mkdir -p app/views/dashboard +mkdir -p app/views/motorista +mkdir -p app/views/shared +mkdir -p app/views/layouts + +echo "📁 Diretórios criados" + +# Gerar timestamp para a migration +TIMESTAMP=$(date +%Y%m%d%H%M%S) +MIGRATION_FILE="db/migrate/${TIMESTAMP}_add_fase2_fields_to_users.rb" + +# Verificar se a migration já existe +if ls db/migrate/*add_fase2_fields_to_users.rb 2>/dev/null; then + echo "⚠️ Migration AddFase2FieldsToUsers já existe — pulando." +else + cp db/migrate/20260610_add_fase2_fields_to_users.rb "$MIGRATION_FILE" + echo "✅ Migration criada: $MIGRATION_FILE" +fi + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Próximos passos:" +echo "" +echo " 1. Rodar migrations:" +echo " docker-compose exec app bundle exec rails db:migrate" +echo "" +echo " 2. Rodar seeds:" +echo " docker-compose exec app bundle exec rails db:seed" +echo "" +echo " 3. Verificar rotas:" +echo " docker-compose exec app bundle exec rails routes | grep -E 'usuario|config|dashboard|motor'" +echo "" +echo " 4. Subir servidor:" +echo " docker-compose up -d && docker-compose logs -f app" +echo "" +echo " Acesso:" +echo " - Admin: http://localhost:3000 → admin@gade.com / Gade@2026!" +echo " - Motorista: http://localhost:3000 → aba PIN → 1234" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"