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 %> +
Atualize o valor do pilar de preço
+Valor atual: <%= moeda(@configuracao.valor) %>
+Pilares de preço e parâmetros do sistema
+<%= cfg.tipo_label %>
+<%= moeda(cfg.valor) %>
+ <% if cfg.descricao.present? %> +<%= cfg.descricao %>
+ <% end %> +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. +
++ <%= @data_referencia.strftime('%B de %Y').capitalize %> · Atualizado às <%= Time.now.strftime('%H:%M') %> +
+💰 Valor Estimado
++ <%= moeda(@valor_estimado) %> +
++ <%= @entregas_pagas %> entregas pagas +
+📦 Total Entregas
+<%= @total_entregas %>
+📋 Consolidações
+<%= @consolidacoes_mes.count %>
+🚗 Motoristas
+<%= @motoristas.count %>
+com entregas no mês
+Valor estimado por dia no mês
+Nenhuma entrega paga neste mês
+<%= qtd %>
+<%= local.to_s.truncate(20) %>
+<%= ((qtd / total_op) * 100).round %>%
+Sistema de Controle de Custos
++ Gade Hospitalar © <%= Date.today.year %> +
+Gade Logística
+Controle de Custos
+<%= current_user.name %>
+<%= current_user.role.humanize %>
++ Suas entregas de <%= @data_referencia.strftime('%B/%Y').capitalize %> +
+💰 Estimado
+<%= moeda(@valor_estimado) %>
+Total
+<%= @total_entregas %>
+✅ Pagas
+<%= @pagas %>
+⏳ Pendentes
+<%= @pendentes %>
+<%= entrega.contact_name %>
+NF: <%= entrega.reference_id %>
+✅ Paga
+<%= entrega.data_formatada %>
+Nenhuma entrega paga este mês ainda.
+<%= cm.consolidacao.mes_label %>
+<%= cm.consolidacao_entregas.count %> entregas
++ <%= usuario.new_record? ? 'Preencha os dados para criar um novo acesso' : 'Atualize as informações do usuário' %> +
++ <%= pluralize(usuario.errors.count, 'erro', 'erros') %> encontrado<%= usuario.errors.count > 1 ? 's' : '' %>: +
+Usuários inativos não conseguem fazer login
+Credenciais de acesso
+PIN de acesso
+Deve ser único entre todos os motoristas
+Gerencie o acesso ao sistema
+| Nome | +E-mail / PIN | +Perfil | +Status | +Açõ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. + |
+ ||||
public.db_reem_simplerout_2026| Coluna | Tipo | Uso no Sistema |
|---|---|---|
| tracking_id | string | ID único da entrega (PK lógico) |
| reference_id | integer | Número da NF (Nota Fiscal) |
| planned_date | date | Data planejada da entrega |
| driver | string | Nome do motorista |
| codrivers | string | Ajudante (geralmente NULL) |
| vehicle | string | Placa/ID do veículo (ex: GADE_037) |
| title | string | Título da entrega (ex: "NF 46497 - ELISABETH ROCHA FERREIRA") |
| address | string | Endereço completo da entrega |
| eta | time | Hora estimada de chegada |
| etd | time | Hora estimada de saída |
| checkin | datetime | Horário real de chegada (pode ser NULL) |
| checkout | datetime | Horário real de saída |
| responsible_person | string | Quem fez a entrega (mesmo que driver) |
| estimated_service_time | interval | Tempo estimado de serviço (fixo 00:10:00) |
| real_service_time | interval | Tempo real de serviço (checkout - checkin) |
| advance | interval | Quanto chegou antes do planejado |
| delay | interval | Quanto atrasou (geralmente 00:00) |
| latitude / longitude | decimal | Coordenadas de chegada |
| checkout_latitude / checkout_longitude | decimal | Coordenadas de saída |
| load / load_2 / load_3 | integer | Quantidade de cargas (geralmente 1/0/0) |
| status | string | Status da entrega (completed, pending, etc.) |
| comments | text | Comentários do motorista |
| observation | string | Observações internas |
| start_of_time_window_1 / end_of_time_window_1 | time | Janela de horário 1 (08:00-18:00) |
| start_of_time_window_2 / end_of_time_window_2 | time | Janela de horário 2 (23:59-23:59) |
| required_skills / optional_skills | string | Habilidades necessárias |
| notes | string | Complementos (ex: "COMPL: CASA 1", "TEL: (11) 99999-9999") |
| contact_name | string | Nome do local (ex: UBS SACOMA, EMAD VELEIROS, STS BUTANTA) |
| contact_phone | string | Telefone do local |
| contact_email | string | Email do local (geralmente NULL) |
| route_id | uuid | ID da rota/operacao (ex: UBS SUL, UBS LESTE) |
| account_id | integer | ID da conta (95907 = Gade Hospitalar) |
| account_name | string | Nome da conta |
| nota_fiscal | url | Foto da NF (URL S3) |
| protocolo_de_entrega | url | Foto do protocolo (URL S3) |
| termo_de_recebimento | url | Foto do termo (URL S3) |
| foto_da_fachada | url | Foto da fachada (URL S3) |
| relatorio_de_visita | url | Foto do relatório (URL S3) |
| numero_de_serie_da_base_aparelho | string | Número de série do equipamento |
tracking_id únicoreference_id = número da NFdriver é o motorista principal (codrivers geralmente NULL)contact_name identifica o local/UBS de destinoroute_id agrupa entregas da mesma operação (ex: UBS SUL, UBS LESTE)status = 'completed' = entrega bem-sucedida (paga)status != 'completed' = pode ser desconto, pendente, canceladacheckin 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)Cards com cores da marca (preto/laranja/branco):
+Botões de ação em massa na consolidação:
+Modal de preview HTML antes de gerar PDF — evita erros e re-trabalho.
+Ao finalizar consolidação, enviar para motoristas: "Sua consolidação [Período] está pronta. Valor: R$ [X]. Baixe: [link]"
+Gráfico de barras lado a lado no Dashboard: Estimado (cinza) vs Consolidado (laranja) vs Diferença (preto).
+Busca por nome da operação, período, motorista, status, route_id.
+Consolidações finalizadas NUNCA excluídas — apenas arquivadas. Log completo de quem fez o quê.
+Toggle ☀️/🌙 no header. Tema escuro como padrão (preto + laranja fica incrível).
+Toggle switches grandes coloridos: 🟠 laranja = Entrega Normal | ⚫ preto = Desconto | ⚪ branco = Bônus | 🟠 laranja escuro = Retirada
+Passo 1: Selecionar motorista | Passo 2: Classificar entregas com toggles + ações em massa | Passo 3: Revisar resumo → Próximo ou Finalizar
+Job persiste histórico, mas Dashboard calcula na hora ao abrir. Sem esperar 1h.
+Motoristas usam PIN numérico ou escaneiam QR do holerite. Sem senha complexa.
+| Camada | Tecnologia |
|---|---|
| Backend | Ruby on Rails 7+ (API + Views) |
| Frontend | Rails Views + Hotwire (Turbo + Stimulus) |
| CSS | Tailwind CSS (tema customizado preto/laranja/branco) |
| Banco Existente | PostgreSQL 15 — tabela db_reem_simplerout_2026 (apenas leitura) |
| Banco Novo | PostgreSQL 15 — tabelas do sistema (mesma instância, schema separado ou prefixo) |
| Auth | Devise + Pundit (roles: admin, gerente, operador, motorista) |
| Prawn (mais leve que WickedPDF) | |
| Job/Cron | Whenever gem |
| Notificações | Twilio (WhatsApp) ou ActionMailer — opcional Fase 8 |
| Docker | Docker + docker-compose.yml |
| Config | .env (NUNCA versionar) |
| Git | Servidor privado: https://git.xenserver.com.br |
URL: https://git.xenserver.com.br (servidor próprio, privado)
Repositório: logistica-controle-custos (criar como PRIVADO)
https://git.xenserver.com.br e faça login com credenciais pessoaislogistica-controle-custos+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 ++ +
+# 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 ++ +
.env no .gitignoreconfig/database.yml usa variáveis de ambientedocker-compose.yml usa env_file: .envrails secret e armazenada em variável de ambiente| Perfil | Acesso |
|---|---|
| Admin | Tudo: Dashboard, Consolidação, Configurações, Motoristas, Auditoria |
| Gerente | Dashboard, Consolidação, gerar PDFs/holerites, visualizar arquivadas |
| Operador | Apenas Consolidação (criar, validar entregas, toggles), ver próprias consolidações |
| Motorista | Dashboard pessoal: valores estimados + consolidados validados, baixar próprio PDF |
| Pilar | Descrição | Tipo | Quando Aplicar |
|---|---|---|---|
| Entrega Bem-Sucedida | Valor pago por entrega concluída | Valor fixo (R$) | status = 'completed' E checkin IS NOT NULL |
| Retirada de Equipamento | Valor pago por retirada/devolução | Valor fixo (R$) | Quando o operador marcar como "Retirada" na consolidação |
| Bônus | Valor adicional por meta/critério | Valor fixo (R$) | Quando o operador marcar como "Bônus" na consolidação |
| Desconto | Valor descontado por problema | Valor fixo (R$) | Quando o operador marcar como "Desconto" na consolidação |
driver do banco)route_id)
+-- 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
+
+
++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-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"
+
+
+| Elemento | Cor | Hex |
|---|---|---|
| Fundo principal (tema escuro) | Preto | #0a0a0a ou #111111 |
| Fundo cards | Cinza escuro | #1a1a1a ou #1e1e1e |
| Cor primária (botões, destaques) | Laranja | #f97316 ou #ea580c |
| Cor secundária (textos, ícones) | Branco | #ffffff |
| Cor de alerta/sucesso | Verde | #22c55e |
| Cor de erro/desconto | Vermelho | #ef4444 |
| Cor de info/bônus | Azul claro | #38bdf8 |
+# 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.
+
+
+docker-compose.ymldb_reem_simplerout_2026 — APENAS LEITURA)DbReemSimplerout2026 com self.table_name = 'db_reem_simplerout_2026' e self.primary_key = 'tracking_id'.env (NUNCA versionar .env)docker-compose up e acesso ao Railsgit init, criar .gitignore completo, primeiro commitgit.xenserver.com.brdb_reem_simplerout_2026status='completed' + checkin IS NOT NULL do dia, multiplicar por preco_entrega, salvar em historico_estimadosAuditoriaLog para registrar ações críticas (criar, editar, finalizar, arquivar consolidação)driver do banco), multi-select de rotas (route_id)consolidacoes (status: rascunho) + consolidacao_motoristasdb_reem_simplerout_2026)self.table_name = 'db_reem_simplerout_2026' e self.primary_key = 'tracking_id' — NUNCA crie migration para essa tabelamd:, lg:)dark: prefix do Tailwind + class strategy — tema escuro como padrãotailwind.config.js — preto #0a0a0a, laranja #f97316, branco #ffffffgit add . && git commit -m "Fase X completa" && git pushDB_HOST no .env apontando para o IP correto+# 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 ++ +
ENV['VARIAVEL'] no código Railsgit.xenserver.com.brdb_reem_simplerout_2026 existente