implantação da fase 2 e 3

This commit is contained in:
2026-06-10 20:12:20 -03:00
parent d37237ead2
commit 472484798f
33 changed files with 2915 additions and 430 deletions

163
README_FASE2_3.md Normal file
View File

@@ -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 |

View File

@@ -4,24 +4,26 @@ class ApplicationController < ActionController::Base
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_tema 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, with: :usuario_nao_autorizado
rescue_from Pundit::NotAuthorizedError do |e|
flash[:alert] = "Você não tem permissão para realizar esta ação." protect_from_forgery with: :exception
redirect_to root_path
end
private private
def set_tema def set_tema
@tema = current_user&.tema_preferido || 'dark' # Lê o tema da sessão (padrão: escuro)
@tema = session[:tema] || 'dark'
end end
def after_sign_in_path_for(resource) def skip_pundit?
if resource.motorista? devise_controller? || params[:controller] =~ /rails\/|action_mailbox|action_text/
motorista_dashboard_path
else
dashboard_path
end 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
end end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,46 +1,82 @@
# app/helpers/application_helper.rb # app/helpers/application_helper.rb
module ApplicationHelper module ApplicationHelper
# Link de navegação com estado ativo # Formata valor monetário → "R$ 1.250,00"
def nav_link_to(label, path, **opts)
active = current_page?(path)
css = [
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px]",
active ? "bg-orange-500 text-black" : "text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
].join(" ")
link_to label, path, class: css
end
# Formata moeda BR
def moeda(valor) def moeda(valor)
"R$ #{"%.2f" % valor.to_f}".gsub('.', ',') "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}"
end 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) def badge_status(status)
cfg = case status.to_s config = {
when 'rascunho' then { cor: 'bg-yellow-500 text-black', icone: '📝', label: 'Rascunho' } 'aberta' => { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Aberta' },
when 'finalizada' then { cor: 'bg-green-600 text-white', icone: '', label: 'Finalizada' } 'em_revisao' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Em Revisão' },
when 'arquivada' then { cor: 'bg-gray-700 text-gray-300', icone: '📁', label: 'Arquivada' } 'fechada' => { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Fechada' },
else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.humanize } '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 end
tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do # Badge de role do usuário
"#{cfg[:icone]} #{cfg[:label]}" 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
end end
# Barra de progresso laranja # Barra de progresso
def progress_bar(percentual, label: nil) def progress_bar(percentual, color: '#f97316')
pct = percentual.to_i.clamp(0, 100) perc = [[percentual.to_i, 0].max, 100].min
tag.div class: "w-full" do content_tag(:div, class: 'w-full bg-white/10 rounded-full h-2') do
concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") { content_tag(:div, '',
concat tag.span(label || "#{pct}% classificado") class: 'h-2 rounded-full transition-all duration-500',
concat tag.span("#{pct}%") style: "width: #{perc}%; background-color: #{color}")
}
concat tag.div(class: "w-full bg-[#2a2a2a] rounded-full h-2") {
tag.div style: "width: #{pct}%",
class: "h-2 rounded-full bg-orange-500 transition-all duration-500"
}
end end
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 end

View File

@@ -1,29 +1,22 @@
# app/models/auditoria_log.rb # app/models/auditoria_log.rb
class AuditoriaLog < ApplicationRecord 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 :acao, presence: true
validates :entidade, presence: true
scope :recentes, -> { order(created_at: :desc) } scope :recentes, -> { order(created_at: :desc) }
scope :por_user, ->(user_id) { where(user_id: user_id) } scope :do_usuario, ->(user) { where(user: user) }
scope :por_entidade, ->(entidade) { where(entidade: entidade) }
def self.registrar(user:, acao:, entidade:, entidade_id: nil, # Cria um log de auditoria de forma simples
dados_anteriores: {}, dados_novos: {}, request: nil) # AuditoriaLog.registrar(current_user, 'criar_consolidacao', ip, detalhes: '...')
def self.registrar(user, acao, ip = nil, detalhes: nil)
create!( create!(
user_id: user&.id, user: user,
acao: acao.to_s, acao: acao,
entidade: entidade.to_s, ip: ip,
entidade_id: entidade_id, detalhes: detalhes
dados_anteriores: dados_anteriores,
dados_novos: dados_novos,
ip_address: request&.remote_ip,
user_agent: request&.user_agent
) )
rescue => e rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}") Rails.logger.warn("AuditoriaLog falhou: #{e.message}")
end end
end end

View File

@@ -1,55 +1,39 @@
# app/models/configuracao.rb # app/models/configuracao.rb
class Configuracao < ApplicationRecord class Configuracao < ApplicationRecord
# Chaves válidas do sistema TIPOS = %w[entrega retirada bonus desconto].freeze
CHAVES = %w[
preco_entrega
preco_retirada
preco_bonus
preco_desconto
notificacao_whatsapp
notificacao_email
empresa_nome
empresa_logo
].freeze
CHAVES_MOEDA = %w[ enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 }
preco_entrega
preco_retirada
preco_bonus
preco_desconto
].freeze
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true validates :tipo, presence: true, uniqueness: true
validates :valor, presence: 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) ICONES = {
find_by(chave: chave)&.valor 'entrega' => '🚚',
'retirada' => '📦',
'bonus' => '⭐',
'desconto' => '🔻'
}.freeze
def tipo_label
LABELS[tipo] || tipo.humanize
end end
def self.preco_entrega def icone
valor('preco_entrega').to_f ICONES[tipo] || '💰'
end end
def self.preco_retirada # Retorna um hash { entrega: 10.50, retirada: 8.00, bonus: 5.00, desconto: 2.00 }
valor('preco_retirada').to_f def self.mapa_de_precos
all.each_with_object({}) do |c, h|
h[c.tipo.to_sym] = c.valor.to_f
end end
def self.preco_bonus
valor('preco_bonus').to_f
end
def self.preco_desconto
valor('preco_desconto').to_f
end
def moeda?
CHAVES_MOEDA.include?(chave)
end
def valor_formatado
return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda?
valor
end end
end end

View File

@@ -1,75 +1,34 @@
# app/models/consolidacao.rb # app/models/consolidacao.rb
class Consolidacao < ApplicationRecord class Consolidacao < ApplicationRecord
belongs_to :criador, class_name: 'User', foreign_key: :created_by # Soft delete
belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true 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_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 ─────────────────────────────────────────────────── validates :referencia_mes, presence: true
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 } validates :status, presence: true
# ── Soft delete ───────────────────────────────────────────── scope :do_mes, ->(data = Date.today) {
scope :ativas, -> { where(deleted_at: nil) } where(referencia_mes: data.beginning_of_month..data.end_of_month)
scope :arquivadas, -> { where.not(deleted_at: nil) } }
# ── Validações ────────────────────────────────────────────── scope :abertas, -> { where(status: :aberta) }
validates :nome, presence: true scope :fechadas, -> { where(status: [:fechada, :aprovada]) }
validates :data_inicio, presence: true
validates :data_fim, presence: true
validates :created_by, presence: true
validate :periodo_valido
# ── Callbacks ─────────────────────────────────────────────── def progresso_percentual
before_save :recalcular_valor_total return 0 if consolidacao_motoristas.none?
# ── Escopos ───────────────────────────────────────────────── finalizados = consolidacao_motoristas.where(finalizado: true).count
scope :recentes, -> { order(created_at: :desc) } total = consolidacao_motoristas.count
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) } ((finalizados.to_f / total) * 100).round
# ── Métodos ─────────────────────────────────────────────────
def arquivar!(user)
update!(deleted_at: Time.current, status: :arquivada)
end end
def finalizar!(user) def mes_label
return false unless todas_entregas_classificadas? referencia_mes&.strftime('%B/%Y')&.capitalize
update!(
status: :finalizada,
finalizado_por: user.id,
finalizado_em: Time.current
)
end
def todas_entregas_classificadas?
total_entregas = ConsolidacaoMotorista
.where(consolidacao_id: id)
.sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) }
consolidacao_entregas.count >= total_entregas
end
def percentual_classificado
return 0 if total_entregas_estimadas.zero?
((consolidacao_entregas.count.to_f / total_entregas_estimadas) * 100).round
end
def total_entregas_estimadas
@total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm|
Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome)
end
end
private
def periodo_valido
return unless data_inicio && data_fim
errors.add(:data_fim, 'deve ser após a data de início') if data_fim < data_inicio
end
def recalcular_valor_total
self.valor_total = consolidacao_motoristas.sum(:valor_total)
end end
end end

View File

@@ -1,88 +1,39 @@
# app/models/entrega.rb # app/models/entrega.rb
# # ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026
# Model de LEITURA para a tabela existente da Gade Hospitalar. # NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa tabela.
# NUNCA criar migration para esta tabela.
# NUNCA executar INSERT, UPDATE, DELETE ou DROP nesta tabela.
#
class Entrega < ApplicationRecord class Entrega < ApplicationRecord
self.table_name = 'db_reem_simplerout_2026' self.table_name = 'db_reem_simplerout_2026'
self.primary_key = 'tracking_id' self.primary_key = 'tracking_id'
# Apenas leitura — segurança contra mutações acidentais # Apenas leitura
def readonly? def readonly?
true true
end end
# ── Scopes ────────────────────────────────────────────────── # Scopes
scope :concluidas, -> { where(status: 'completed') } scope :do_account, -> { where(account_id: 95907) }
scope :com_checkin, -> { where.not(checkin: nil) } scope :pagas, -> { where(status: 'completed').where.not(checkin: nil) }
scope :pagas, -> { concluidas.com_checkin } scope :pendentes, -> { where.not(status: 'completed').or(where(checkin: nil)) }
scope :pendentes, -> { where.not(status: 'completed') }
scope :no_periodo, ->(inicio, fim) { scope :do_mes, ->(data = Date.today) {
where(planned_date: inicio.to_date..fim.to_date) 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) { scope :do_motorista, ->(nome) {
where(driver: nome) where('LOWER(driver) = ?', nome.to_s.downcase)
} }
scope :da_rota, ->(route_id) { # Helpers de instância
where(route_id: route_id) def paga?
} status == 'completed' && checkin.present?
scope :da_conta_gade, -> {
where(account_id: ENV.fetch('DB_EXISTING_ACCOUNT_ID', 95907).to_i)
}
# ── Métodos de classe ────────────────────────────────────────
# Lista motoristas únicos (para selects, consolidações)
def self.motoristas_ativos(inicio: nil, fim: nil)
base = da_conta_gade
base = base.no_periodo(inicio, fim) if inicio && fim
base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?)
end end
# Lista rotas únicas (route_id → descrição) def data_formatada
def self.rotas_unicas(inicio: nil, fim: nil) planned_date&.strftime('%d/%m/%Y')
base = da_conta_gade
base = base.no_periodo(inicio, fim) if inicio && fim
base.distinct.pluck(:route_id).compact
end
# Contagem de entregas pagas para cálculo estimado
def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil)
base = pagas.da_conta_gade.no_periodo(inicio, fim)
base = base.do_motorista(motorista) if motorista.present?
base = base.da_rota(route_id) if route_id.present?
base.count
end
# ── Helpers de instância ─────────────────────────────────────
def numero_nf
reference_id
end
def local
contact_name.presence || address
end
def concluida?
status == 'completed'
end
def checkin_registrado?
checkin.present?
end
def elegivel_pagamento?
concluida? && checkin_registrado?
end
def atraso_minutos
return 0 unless delay.present?
delay.to_s.split(':').then { |h, m, _s| h.to_i * 60 + m.to_i }
end end
end end

View File

@@ -1,69 +1,59 @@
# app/models/user.rb # app/models/user.rb
class User < ApplicationRecord class User < ApplicationRecord
devise :database_authenticatable, # Devise modules
:registerable, devise :database_authenticatable, :registerable,
:recoverable, :recoverable, :rememberable, :validatable,
:rememberable, :trackable, :lockable
:validatable
# ── Roles ────────────────────────────────────────────────── # Roles
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 } enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
ROLES_LABEL = { # Validações
'admin' => 'Administrador', validates :name, presence: true
'gerente' => 'Gerente',
'operador' => 'Operador',
'motorista' => 'Motorista'
}.freeze
# ── Validações ──────────────────────────────────────────────
validates :nome, presence: true, length: { minimum: 3 }
validates :role, presence: true validates :role, presence: true
validates :pin_code, validates :pin_acesso,
length: { is: 4 }, uniqueness: { allow_blank: true },
numericality: { only_integer: true }, length: { is: 4, allow_blank: true },
allow_nil: true, format: { with: /\A\d{4}\z/, allow_blank: true, message: 'deve ter exatamente 4 dígitos numéricos' }
if: :motorista?
validate :pin_unico_para_motoristas, if: :motorista? validate :pin_obrigatorio_para_motorista
# Motoristas podem não ter e-mail (login via PIN) # Callbacks
def email_required? before_validation :normalizar_pin
!motorista?
end
def password_required? # Associações
!motorista? ? super : false has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify
end has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error
# ── Escopos ───────────────────────────────────────────────── # Scopes
scope :ativos, -> { where(ativo: true) } scope :ativos, -> { where(ativo: true) }
scope :por_nome, -> { order(:nome) } scope :motoristas, -> { where(role: :motorista) }
scope :nao_motoristas, -> { where.not(role: :motorista) }
# ── Helpers ─────────────────────────────────────────────────
def nome_display def nome_display
nome.presence || email name.split.first.capitalize
end end
def role_label def active?
ROLES_LABEL[role] || role.humanize ativo?
end end
def pode_ver_config? def pode_acessar_admin?
admin? || gerente? admin? || gerente?
end end
def pode_consolidar? def pode_criar_consolidacao?
admin? || gerente? || operador? admin? || gerente? || operador?
end end
private private
def pin_unico_para_motoristas def normalizar_pin
return if pin_code.blank? self.pin_acesso = pin_acesso.to_s.strip.presence
end
conflito = User.motorista.where(pin_code: pin_code).where.not(id: id) def pin_obrigatorio_para_motorista
errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists? if motorista? && pin_acesso.blank?
errors.add(:pin_acesso, 'é obrigatório para motoristas')
end
end end
end end

View File

@@ -3,15 +3,44 @@ class ApplicationPolicy
attr_reader :user, :record attr_reader :user, :record
def initialize(user, record) def initialize(user, record)
raise Pundit::NotAuthorizedError, 'Você precisa estar logado.' unless user
@user = user @user = user
@record = record @record = record
end end
def index? = user.pode_consolidar? || user.admin? def index? = false
def show? = user.pode_consolidar? || user.admin? def show? = false
def create? = user.pode_consolidar? || user.admin? def create? = false
def update? = user.pode_consolidar? || user.admin? def new? = create?
def destroy? = user.admin? 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 class Scope
def initialize(user, scope) def initialize(user, scope)
@@ -20,7 +49,7 @@ class ApplicationPolicy
end end
def resolve def resolve
@scope.all raise NotImplementedError, "#{self.class}#resolve não implementado"
end end
private private

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,58 @@
<%# app/views/configuracoes/edit.html.erb %>
<div class="max-w-lg mx-auto space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">
<%= @configuracao.icone %> Editar: <%= @configuracao.tipo_label %>
</h1>
<p class="text-gray-400 text-sm mt-0.5">Atualize o valor do pilar de preço</p>
</div>
<%= 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? %>
<div class="p-4 bg-red-900/30 border border-red-500/40 rounded-xl">
<ul class="list-disc list-inside space-y-1">
<% @configuracao.errors.full_messages.each do |msg| %>
<li class="text-red-300 text-sm"><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= f.label :valor, 'Valor (R$)', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<div class="relative">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium">R$</span>
<%= 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' %>
</div>
<p class="text-gray-500 text-xs mt-1.5">Valor atual: <%= moeda(@configuracao.valor) %></p>
</div>
<div>
<%= 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' %>
</div>
<div class="flex items-center justify-between pt-2 border-t border-white/5">
<%= 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]' %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,64 @@
<%# app/views/configuracoes/index.html.erb %>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">Configurações</h1>
<p class="text-gray-400 text-sm mt-0.5">Pilares de preço e parâmetros do sistema</p>
</div>
<%= render 'shared/flash' %>
<%# Pilares de preço %>
<div>
<h2 class="text-lg font-semibold text-white mb-4">💰 Pilares de Preço</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<% @configuracoes.each do |cfg| %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 group relative">
<div class="flex items-start justify-between mb-4">
<div class="text-3xl"><%= cfg.icone %></div>
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<% end %>
<% end %>
</div>
<p class="text-gray-400 text-sm mb-1"><%= cfg.tipo_label %></p>
<p class="text-3xl font-bold text-white"><%= moeda(cfg.valor) %></p>
<% if cfg.descricao.present? %>
<p class="text-gray-500 text-xs mt-2"><%= cfg.descricao %></p>
<% end %>
</div>
<% end %>
</div>
</div>
<%# Informações do banco externo %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4">🗄️ Banco de Dados Externo</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="p-4 bg-[#0a0a0a] rounded-xl border border-white/5">
<p class="text-gray-400 text-xs mb-1">Tabela monitorada</p>
<p class="text-white font-mono text-sm">db_reem_simplerout_2026</p>
</div>
<div class="p-4 bg-[#0a0a0a] rounded-xl border border-white/5">
<p class="text-gray-400 text-xs mb-1">Account ID</p>
<p class="text-white font-mono text-sm">95907 (Gade Hospitalar)</p>
</div>
<div class="p-4 bg-[#0a0a0a] rounded-xl border border-white/5">
<p class="text-gray-400 text-xs mb-1">Atualização</p>
<p class="text-white font-mono text-sm">A cada 1 hora</p>
</div>
</div>
<p class="text-yellow-500/80 text-xs mt-4 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Somente leitura — nunca alterar, deletar ou truncar esta tabela.
</p>
</div>
</div>

View File

@@ -0,0 +1,234 @@
<%# app/views/dashboard/index.html.erb %>
<%# Fase 3: Dashboard com cards, gráfico Chart.js e cálculo ao vivo %>
<div class="space-y-6">
<%# Header + filtro de mês %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Dashboard</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @data_referencia.strftime('%B de %Y').capitalize %> · Atualizado às <%= Time.now.strftime('%H:%M') %>
</p>
</div>
<%# Navegação de mês %>
<div class="flex items-center gap-2">
<%= 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 %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
<% end %>
<div class="px-4 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl text-white font-medium min-w-[140px] text-center">
<%= @data_referencia.strftime('%b %Y').capitalize %>
</div>
<% 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 %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<% end %>
<% end %>
</div>
</div>
<%# CARDS GRANDES — KPIs %>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<%# Card 1: Valor estimado %>
<div class="sm:col-span-2 xl:col-span-1 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-transparent"></div>
<div class="relative">
<p class="text-orange-100 text-sm font-medium mb-2">💰 Valor Estimado</p>
<p class="text-4xl font-black text-white mb-1">
<%= moeda(@valor_estimado) %>
</p>
<p class="text-orange-200 text-sm">
<%= @entregas_pagas %> entregas pagas
</p>
</div>
</div>
<%# Card 2: Total de entregas %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">📦 Total Entregas</p>
<p class="text-4xl font-black text-white mb-1"><%= @total_entregas %></p>
<div class="flex items-center gap-2 text-sm">
<span class="text-green-400"><%= @entregas_pagas %> pagas</span>
<span class="text-gray-600">·</span>
<span class="text-yellow-500"><%= @entregas_pendentes %> pendentes</span>
</div>
</div>
<%# Card 3: Consolidações %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">📋 Consolidações</p>
<p class="text-4xl font-black text-white mb-1"><%= @consolidacoes_mes.count %></p>
<div class="flex items-center gap-2 text-sm">
<span class="text-yellow-500"><%= @consolidacoes_abertas %> abertas</span>
<span class="text-gray-600">·</span>
<span class="text-green-400"><%= @consolidacoes_fechadas %> fechadas</span>
</div>
</div>
<%# Card 4: Motoristas ativos %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2">🚗 Motoristas</p>
<p class="text-4xl font-black text-white mb-1"><%= @motoristas.count %></p>
<p class="text-gray-500 text-sm">com entregas no mês</p>
</div>
</div>
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<%# Gráfico de linha diário (Chart.js) %>
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-lg font-semibold text-white">Evolução Diária</h2>
<p class="text-gray-400 text-sm">Valor estimado por dia no mês</p>
</div>
<div class="flex items-center gap-2 text-xs text-gray-500">
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
</div>
</div>
<div class="relative h-64">
<canvas id="grafico-diario"></canvas>
</div>
</div>
<%# Top motoristas %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4">🏆 Top Motoristas</h2>
<% if @motoristas.any? %>
<div class="space-y-3">
<% @motoristas.each_with_index do |m, i| %>
<div class="flex items-center gap-3">
<span class="text-xs font-bold w-5 text-center
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-600' %>">
<%= i + 1 %>
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<span class="text-white text-sm font-medium truncate"><%= m[:nome].split.first %></span>
<span class="text-[#f97316] text-sm font-semibold ml-2 flex-shrink-0">
<%= moeda(m[:valor]) %>
</span>
</div>
<%# Barra de progresso %>
<div class="w-full bg-white/5 rounded-full h-1.5">
<% max = @motoristas.first[:entregas].to_f %>
<div class="h-1.5 rounded-full bg-[#f97316]"
style="width: <%= [(m[:entregas] / max * 100).round, 100].min %>%"></div>
</div>
<span class="text-gray-500 text-xs"><%= m[:entregas] %> entregas</span>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-8 text-gray-500">
<div class="text-3xl mb-2">🚚</div>
<p class="text-sm">Nenhuma entrega paga neste mês</p>
</div>
<% end %>
</div>
</div>
<%# Por operação %>
<% if @por_operacao.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4">🏥 Entregas por Local</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<% total_op = @por_operacao.values.sum.to_f %>
<% @por_operacao.each do |local, qtd| %>
<div class="bg-[#0a0a0a] rounded-xl p-4 border border-white/5 text-center">
<p class="text-2xl font-bold text-[#f97316]"><%= qtd %></p>
<p class="text-gray-400 text-xs mt-1 leading-tight"><%= local.to_s.truncate(20) %></p>
<p class="text-gray-600 text-xs mt-1"><%= ((qtd / total_op) * 100).round %>%</p>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<%# Chart.js via CDN %>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
const ctx = document.getElementById('grafico-diario');
const labels = <%= raw @grafico_diario[:labels].to_json %>;
const valores = <%= raw @grafico_diario[:valores].to_json %>;
const qtds = <%= raw @grafico_diario[:qtds].to_json %>;
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Valor (R$)',
data: valores,
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.08)',
borderWidth: 2.5,
pointBackgroundColor: '#f97316',
pointBorderColor: '#1a1a1a',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0a0a0a',
borderColor: '#f97316',
borderWidth: 1,
titleColor: '#9ca3af',
bodyColor: '#ffffff',
padding: 12,
callbacks: {
label: (ctx) => {
const idx = ctx.dataIndex;
return [
` Valor: R$ ${ctx.parsed.y.toFixed(2).replace('.', ',')}`,
` Entregas: ${qtds[idx]}`
];
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
ticks: { color: '#6b7280', font: { size: 11 }, maxTicksLimit: 10 }
},
y: {
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
ticks: {
color: '#6b7280',
font: { size: 11 },
callback: (v) => `R$ ${v.toFixed(0)}`
}
}
}
}
});
</script>

View File

@@ -0,0 +1,231 @@
<%# app/views/devise/sessions/new.html.erb %>
<div class="min-h-screen bg-[#0a0a0a] flex items-center justify-center px-4">
<div class="w-full max-w-md">
<%# Logo / Header %>
<div class="text-center mb-10">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-[#f97316] mb-4">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/>
</svg>
</div>
<h1 class="text-3xl font-bold text-white">Gade Logística</h1>
<p class="text-gray-400 mt-1 text-sm">Sistema de Controle de Custos</p>
</div>
<%# Flash messages %>
<% if flash[:alert] %>
<div class="mb-4 p-4 bg-red-900/40 border border-red-500/50 rounded-xl text-red-300 text-sm">
<%= flash[:alert] %>
</div>
<% end %>
<%# Tabs: Email / PIN %>
<div class="bg-[#1a1a1a] rounded-2xl overflow-hidden border border-white/5">
<%# Tab switcher %>
<div class="flex border-b border-white/10">
<button id="tab-email"
onclick="switchTab('email')"
class="flex-1 py-4 text-sm font-medium transition-colors tab-active text-[#f97316] border-b-2 border-[#f97316]">
📧 E-mail
</button>
<button id="tab-pin"
onclick="switchTab('pin')"
class="flex-1 py-4 text-sm font-medium transition-colors text-gray-400 hover:text-white border-b-2 border-transparent">
🔑 PIN Motorista
</button>
</div>
<%# Login por e-mail %>
<div id="form-email" class="p-8">
<%= form_with(url: user_session_path, method: :post) do |f| %>
<div class="space-y-5">
<div>
<%= 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' %>
</div>
<div>
<%= 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: '••••••••' %>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 cursor-pointer">
<%= f.check_box :remember_me,
class: 'w-4 h-4 rounded accent-[#f97316]' %>
<span class="text-sm text-gray-400">Lembrar</span>
</label>
<%= link_to 'Esqueci a senha', new_user_password_path,
class: 'text-sm text-[#f97316] hover:text-orange-400 transition-colors' %>
</div>
<%= 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]' %>
</div>
<% end %>
</div>
<%# Login por PIN %>
<div id="form-pin" class="p-8 hidden">
<%= form_with(url: user_session_path, method: :post) do |f| %>
<%= f.hidden_field :pin_login, value: '1' %>
<div class="space-y-6">
<div class="text-center">
<p class="text-gray-400 text-sm mb-6">Digite seu PIN de 4 dígitos</p>
<%# PIN display visual %>
<div class="flex justify-center gap-3 mb-6" id="pin-display">
<% 4.times do |i| %>
<div id="pin-dot-<%= i %>"
class="w-14 h-14 rounded-xl bg-[#0a0a0a] border-2 border-white/10
flex items-center justify-center text-2xl font-bold text-white
transition-all duration-150">
</div>
<% end %>
</div>
<%= f.hidden_field :pin, id: 'pin-value' %>
<%# Teclado numérico %>
<div class="grid grid-cols-3 gap-3 max-w-[240px] mx-auto" id="pin-keypad">
<% [1,2,3,4,5,6,7,8,9].each do |n| %>
<button type="button"
onclick="pinPress('<%= n %>')"
class="h-14 rounded-xl bg-[#0a0a0a] border border-white/10 text-white
text-xl font-semibold hover:bg-[#f97316] hover:border-[#f97316]
active:scale-95 transition-all min-h-[48px]">
<%= n %>
</button>
<% end %>
<button type="button"
onclick="pinClear()"
class="h-14 rounded-xl bg-[#0a0a0a] border border-white/10 text-gray-400
text-sm hover:bg-red-900/40 hover:border-red-500/50 hover:text-red-400
active:scale-95 transition-all">
</button>
<button type="button"
onclick="pinPress('0')"
class="h-14 rounded-xl bg-[#0a0a0a] border border-white/10 text-white
text-xl font-semibold hover:bg-[#f97316] hover:border-[#f97316]
active:scale-95 transition-all">
0
</button>
<button type="button"
onclick="pinBackspace()"
class="h-14 rounded-xl bg-[#0a0a0a] border border-white/10 text-gray-400
text-lg hover:bg-white/5 active:scale-95 transition-all">
</button>
</div>
</div>
<%= f.submit 'Entrar com PIN',
id: 'btn-pin-submit',
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]
disabled:opacity-40 disabled:cursor-not-allowed',
disabled: true %>
</div>
<% end %>
</div>
</div>
<p class="text-center text-gray-600 text-xs mt-6">
Gade Hospitalar © <%= Date.today.year %>
</p>
</div>
</div>
<script>
let pinDigits = [];
function switchTab(tab) {
const emailForm = document.getElementById('form-email');
const pinForm = document.getElementById('form-pin');
const tabEmail = document.getElementById('tab-email');
const tabPin = document.getElementById('tab-pin');
if (tab === 'email') {
emailForm.classList.remove('hidden');
pinForm.classList.add('hidden');
tabEmail.classList.add('text-[#f97316]', 'border-[#f97316]');
tabEmail.classList.remove('text-gray-400', 'border-transparent');
tabPin.classList.add('text-gray-400', 'border-transparent');
tabPin.classList.remove('text-[#f97316]', 'border-[#f97316]');
} else {
pinForm.classList.remove('hidden');
emailForm.classList.add('hidden');
tabPin.classList.add('text-[#f97316]', 'border-[#f97316]');
tabPin.classList.remove('text-gray-400', 'border-transparent');
tabEmail.classList.add('text-gray-400', 'border-transparent');
tabEmail.classList.remove('text-[#f97316]', 'border-[#f97316]');
}
}
function updatePinDisplay() {
for (let i = 0; i < 4; i++) {
const dot = document.getElementById(`pin-dot-${i}`);
if (pinDigits[i] !== undefined) {
dot.textContent = '●';
dot.classList.add('border-[#f97316]');
dot.classList.remove('border-white/10');
} else {
dot.textContent = '';
dot.classList.remove('border-[#f97316]');
dot.classList.add('border-white/10');
}
}
document.getElementById('pin-value').value = pinDigits.join('');
const btn = document.getElementById('btn-pin-submit');
btn.disabled = pinDigits.length !== 4;
}
function pinPress(digit) {
if (pinDigits.length < 4) {
pinDigits.push(digit);
updatePinDisplay();
if (pinDigits.length === 4) {
document.getElementById('btn-pin-submit').click();
}
}
}
function pinBackspace() {
pinDigits.pop();
updatePinDisplay();
}
function pinClear() {
pinDigits = [];
updatePinDisplay();
}
// Suporte a teclado físico no PIN
document.addEventListener('keydown', (e) => {
if (!document.getElementById('form-pin').classList.contains('hidden')) {
if (e.key >= '0' && e.key <= '9') pinPress(e.key);
if (e.key === 'Backspace') pinBackspace();
}
});
<% if @pin_login %>
document.addEventListener('DOMContentLoaded', () => switchTab('pin'));
<% end %>
</script>

View File

@@ -0,0 +1,64 @@
<%# app/views/layouts/_sidebar.html.erb %>
<div class="flex flex-col h-full">
<%# Logo %>
<div class="flex items-center gap-3 px-6 py-5 border-b border-white/5">
<div class="w-10 h-10 rounded-xl bg-[#f97316] flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/>
</svg>
</div>
<div>
<p class="text-white font-bold text-sm leading-tight">Gade Logística</p>
<p class="text-gray-500 text-xs">Controle de Custos</p>
</div>
</div>
<%# Navegação %>
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto scrollbar-hidden">
<%# Dashboard (todos exceto motorista) %>
<%= nav_link_to 'Dashboard', dashboard_path, icon: '📊' %>
<%# Consolidações (admin, gerente, operador) %>
<% if current_user.pode_criar_consolidacao? %>
<%= nav_link_to 'Consolidações', consolidacoes_path, icon: '📋' %>
<% end %>
<%# Separador Admin %>
<% if current_user.pode_acessar_admin? %>
<div class="pt-3 pb-1">
<p class="px-4 text-xs font-semibold text-gray-600 uppercase tracking-wider">Administração</p>
</div>
<%= nav_link_to 'Usuários', usuarios_path, icon: '👥' %>
<%= nav_link_to 'Configurações', configuracoes_path, icon: '⚙️' %>
<% end %>
<%# PDFs (fase 7 — link desabilitado por enquanto) %>
<% if current_user.pode_acessar_admin? %>
<div class="pt-3 pb-1">
<p class="px-4 text-xs font-semibold text-gray-600 uppercase tracking-wider">Relatórios</p>
</div>
<div class="flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-gray-600 cursor-not-allowed">
<span class="text-lg w-5 text-center">📄</span>
<span>Relatórios PDF</span>
<span class="ml-auto text-[10px] bg-gray-800 text-gray-500 px-1.5 py-0.5 rounded">Em breve</span>
</div>
<% end %>
</nav>
<%# Footer do sidebar - info do usuário %>
<div class="px-3 py-4 border-t border-white/5">
<div class="flex items-center gap-3 px-3 py-3 rounded-xl bg-white/5">
<div class="w-8 h-8 rounded-full bg-[#f97316]/20 border border-[#f97316]/30
flex items-center justify-center text-[#f97316] font-bold text-sm flex-shrink-0">
<%= current_user.name.to_s[0].upcase %>
</div>
<div class="flex-1 min-w-0">
<p class="text-white text-sm font-medium truncate"><%= current_user.name %></p>
<p class="text-gray-500 text-xs truncate"><%= current_user.role.humanize %></p>
</div>
</div>
</div>
</div>

View File

@@ -1,95 +1,169 @@
<%# app/views/layouts/application.html.erb %>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="pt-BR" class="dark"> <html lang="pt-BR" class="<%= @tema == 'light' ? '' : 'dark' %>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-param" content="<%= request_forgery_protection_token %>">
<meta name="csrf-token" content="<%= form_authenticity_token %>"> <meta name="csrf-token" content="<%= form_authenticity_token %>">
<title><%= content_for?(:title) ? "#{yield(:title)} | Gade Logística" : "Gade Logística" %></title> <title><%= content_for?(:title) ? yield(:title) + ' · ' : '' %>Gade Logística</title>
<%# Tailwind via CDN em desenvolvimento — compilar em produção %> <%= stylesheet_link_tag 'application', data: { turbo_track: 'reload' } %>
<script src="https://cdn.tailwindcss.com"></script> <%= javascript_importmap_tags %>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
preto: '#0a0a0a',
'preto-card': '#1a1a1a',
laranja: '#f97316',
'laranja-escuro': '#ea580c',
branco: '#ffffff',
}
}
}
}
}
</script>
<%# Google Fonts — Inter %>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style> <style>
body { font-family: 'Inter', sans-serif; } :root {
/* Scrollbar tema escuro */ --bg-main: #0a0a0a;
::-webkit-scrollbar { width: 6px; } --bg-card: #1a1a1a;
::-webkit-scrollbar-track { background: #0a0a0a; } --accent: #f97316;
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; } --text-main: #ffffff;
/* Loading spinner laranja */ --text-muted:#9ca3af;
.spinner { --border: rgba(255,255,255,0.05);
border: 3px solid #1a1a1a;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
@keyframes spin { to { transform: rotate(360deg); } } html.light {
--bg-main: #f8fafc;
--bg-card: #ffffff;
--text-main: #0f172a;
--text-muted:#64748b;
--border: rgba(0,0,0,0.08);
}
body { background-color: var(--bg-main); color: var(--text-main); }
.scrollbar-hidden::-webkit-scrollbar { display: none; }
</style> </style>
<%= yield :head %>
</head> </head>
<body class="bg-[#0a0a0a] text-white min-h-screen"> <body class="font-sans antialiased min-h-screen" style="background-color: var(--bg-main)">
<%# ── Navbar ─────────────────────────────────────────── %> <% if user_signed_in? && !current_user.motorista? %>
<% if user_signed_in? %> <%# Layout principal com sidebar %>
<%= render 'layouts/navbar' %> <div class="flex min-h-screen">
<% end %>
<%# ── Flash Messages ─────────────────────────────────── %> <%# Sidebar Desktop %>
<% if notice.present? %> <aside id="sidebar"
<div id="flash-notice" class="fixed inset-y-0 left-0 z-50 w-64 flex-col hidden lg:flex
class="fixed top-4 right-4 z-50 bg-orange-500 text-black font-semibold px-5 py-3 rounded-lg shadow-lg flex items-center gap-2 animate-fade-in" border-r border-white/5"
data-controller="flash"> style="background-color: var(--bg-card)">
<span>✅</span> <%= notice %> <%= render 'layouts/sidebar' %>
</aside>
<%# Overlay mobile %>
<div id="sidebar-overlay"
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm hidden lg:hidden"
onclick="toggleSidebar()"></div>
<%# Sidebar Mobile (drawer) %>
<aside id="sidebar-mobile"
class="fixed inset-y-0 left-0 z-50 w-64 flex flex-col lg:hidden
transform -translate-x-full transition-transform duration-300 ease-in-out
border-r border-white/5"
style="background-color: var(--bg-card)">
<%= render 'layouts/sidebar' %>
</aside>
<%# Main content %>
<div class="flex-1 lg:ml-64 flex flex-col min-h-screen">
<%# Topbar %>
<header class="sticky top-0 z-30 px-4 lg:px-6 py-4 flex items-center justify-between
border-b border-white/5 backdrop-blur-md"
style="background-color: rgba(10,10,10,0.8)">
<%# Hamburger mobile %>
<button onclick="toggleSidebar()"
class="lg:hidden p-2 text-gray-400 hover:text-white rounded-lg hover:bg-white/10 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<%# Breadcrumb / título da página %>
<div class="flex-1 lg:ml-0 ml-3">
<span class="text-white font-semibold text-sm lg:text-base">
<%= content_for?(:page_title) ? yield(:page_title) : 'Dashboard' %>
</span>
</div> </div>
<%# Ações do header %>
<div class="flex items-center gap-3">
<%# 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' %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<% else %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
<% end %>
<% end %> <% end %>
<% if alert.present? %> <%# Avatar / menu usuário %>
<div id="flash-alert" <div class="relative" x-data="{ open: false }">
class="fixed top-4 right-4 z-50 bg-red-600 text-white font-semibold px-5 py-3 rounded-lg shadow-lg flex items-center gap-2" <button class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-white/10 transition-colors">
data-controller="flash"> <div class="w-8 h-8 rounded-full bg-[#f97316]/20 border border-[#f97316]/30
<span>⚠️</span> <%= alert %> flex items-center justify-center text-[#f97316] font-bold text-sm">
<%= current_user.name.to_s[0].upcase %>
</div>
<span class="hidden sm:block text-white text-sm font-medium pr-1">
<%= current_user.nome_display %>
</span>
</button>
</div> </div>
<% end %>
<%# ── Conteúdo principal ──────────────────────────────── %> <%# Logout %>
<main class="<%= user_signed_in? ? 'ml-0 md:ml-64 p-4 md:p-8' : '' %> min-h-screen"> <%= 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 %>
</div>
</header>
<%# Conteúdo principal %>
<main class="flex-1 p-4 lg:p-6">
<%= yield %> <%= yield %>
</main> </main>
</div>
</div>
<% elsif user_signed_in? && current_user.motorista? %>
<%# Layout simplificado para motorista %>
<div class="min-h-screen" style="background-color: var(--bg-main)">
<main class="max-w-2xl mx-auto px-4 py-6">
<%= yield %>
</main>
</div>
<% else %>
<%# Layout sem sidebar (login, etc) %>
<%= yield %>
<% end %>
<%# Auto-hide flash após 4s (JS simples) %>
<script> <script>
setTimeout(() => { function toggleSidebar() {
['flash-notice', 'flash-alert'].forEach(id => { const mobile = document.getElementById('sidebar-mobile');
const el = document.getElementById(id); const overlay = document.getElementById('sidebar-overlay');
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0', const isOpen = !mobile.classList.contains('-translate-x-full');
setTimeout(() => el.remove(), 500);
});
}, 4000);
</script>
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();
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,96 @@
<%# app/views/motorista/index.html.erb %>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">
Olá, <%= current_user.nome_display %>! 👋
</h1>
<p class="text-gray-400 text-sm mt-0.5">
Suas entregas de <%= @data_referencia.strftime('%B/%Y').capitalize %>
</p>
</div>
<%= 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 %>
</div>
<%# Cards do motorista %>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-[#f97316] rounded-2xl p-5 col-span-2 sm:col-span-1">
<p class="text-orange-100 text-xs font-medium mb-1">💰 Estimado</p>
<p class="text-2xl font-black text-white"><%= moeda(@valor_estimado) %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-5">
<p class="text-gray-400 text-xs mb-1">Total</p>
<p class="text-2xl font-bold text-white"><%= @total_entregas %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-5">
<p class="text-gray-400 text-xs mb-1 text-green-400">✅ Pagas</p>
<p class="text-2xl font-bold text-white"><%= @pagas %></p>
</div>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-5">
<p class="text-gray-400 text-xs mb-1 text-yellow-500">⏳ Pendentes</p>
<p class="text-2xl font-bold text-white"><%= @pendentes %></p>
</div>
</div>
<%# Últimas entregas %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
<div class="px-6 py-4 border-b border-white/10">
<h2 class="text-lg font-semibold text-white">Últimas Entregas Pagas</h2>
</div>
<% if @ultimas_entregas.any? %>
<div class="divide-y divide-white/5">
<% @ultimas_entregas.each do |entrega| %>
<div class="px-6 py-4 flex items-center justify-between">
<div>
<p class="text-white text-sm font-medium"><%= entrega.contact_name %></p>
<p class="text-gray-500 text-xs mt-0.5">NF: <%= entrega.reference_id %></p>
</div>
<div class="text-right">
<p class="text-green-400 text-sm font-semibold">✅ Paga</p>
<p class="text-gray-500 text-xs"><%= entrega.data_formatada %></p>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
<div class="text-4xl mb-3">🚚</div>
<p>Nenhuma entrega paga este mês ainda.</p>
</div>
<% end %>
</div>
<%# Consolidações do motorista %>
<% if @consolidacoes.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
<div class="px-6 py-4 border-b border-white/10">
<h2 class="text-lg font-semibold text-white">Seus Fechamentos</h2>
</div>
<div class="divide-y divide-white/5">
<% @consolidacoes.each do |cm| %>
<div class="px-6 py-4 flex items-center justify-between">
<div>
<p class="text-white text-sm font-medium"><%= cm.consolidacao.mes_label %></p>
<p class="text-gray-500 text-xs mt-0.5"><%= cm.consolidacao_entregas.count %> entregas</p>
</div>
<div class="flex items-center gap-3">
<%= badge_status(cm.consolidacao.status) %>
<% if cm.consolidacao.aprovada? %>
<%= link_to '#', class: 'text-[#f97316] text-sm hover:underline' do %>
📄 PDF
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,18 @@
<%# app/views/shared/_flash.html.erb %>
<% if flash[:notice] %>
<div class="p-4 bg-green-900/30 border border-green-500/40 rounded-xl text-green-300 text-sm flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:alert] %>
<div class="p-4 bg-red-900/30 border border-red-500/40 rounded-xl text-red-300 text-sm flex items-center gap-2">
<svg class="w-4 h-4 flex-shrink-0 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<%= flash[:alert] %>
</div>
<% end %>

View File

@@ -0,0 +1,153 @@
<%# app/views/usuarios/_form.html.erb %>
<div class="max-w-2xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">
<%= usuario.new_record? ? 'Novo Usuário' : "Editar: #{usuario.name}" %>
</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= usuario.new_record? ? 'Preencha os dados para criar um novo acesso' : 'Atualize as informações do usuário' %>
</p>
</div>
<%= 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? %>
<div class="p-4 bg-red-900/30 border border-red-500/40 rounded-xl">
<p class="text-red-400 text-sm font-medium mb-2">
<%= pluralize(usuario.errors.count, 'erro', 'erros') %> encontrado<%= usuario.errors.count > 1 ? 's' : '' %>:
</p>
<ul class="list-disc list-inside space-y-1">
<% usuario.errors.full_messages.each do |msg| %>
<li class="text-red-300 text-sm"><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<%# Nome %>
<div class="sm:col-span-2">
<%= 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' %>
</div>
<%# Perfil %>
<div>
<%= 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' %>
</div>
<%# Ativo toggle %>
<div class="flex items-center">
<div class="flex-1">
<%= f.label :ativo, 'Usuário ativo', class: 'block text-sm font-medium text-gray-300' %>
<p class="text-gray-500 text-xs mt-0.5">Usuários inativos não conseguem fazer login</p>
</div>
<label class="relative inline-flex items-center cursor-pointer ml-4">
<%= f.check_box :ativo, class: 'sr-only peer' %>
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]"></div>
</label>
</div>
</div>
<%# E-mail e senha (não motorista) %>
<div id="fields-email" class="space-y-6">
<div class="border-t border-white/5 pt-6">
<p class="text-sm font-medium text-gray-300 mb-4">Credenciais de acesso</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div class="sm:col-span-2">
<%= 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' %>
</div>
<div>
<%= 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: '••••••••' %>
</div>
<div>
<%= 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: '••••••••' %>
</div>
</div>
</div>
</div>
<%# PIN (motorista) %>
<div id="fields-pin" class="hidden border-t border-white/5 pt-6">
<p class="text-sm font-medium text-gray-300 mb-4">PIN de acesso</p>
<div class="max-w-xs">
<%= f.label :pin_acesso, 'PIN (4 dígitos)', class: 'block text-sm font-medium text-gray-300 mb-1.5' %>
<%= f.text_field :pin_acesso,
maxlength: 4,
pattern: '\d{4}',
inputmode: 'numeric',
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-center text-2xl tracking-[0.5em] font-bold',
placeholder: '0000' %>
<p class="text-gray-500 text-xs mt-1.5">Deve ser único entre todos os motoristas</p>
</div>
</div>
<%# Botões %>
<div class="flex items-center justify-between pt-2 border-t border-white/5">
<%= 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]' %>
</div>
<% end %>
</div>
<script>
const roleSelect = document.querySelector('select[name="user[role]"]');
const fieldsEmail = document.getElementById('fields-email');
const fieldsPin = document.getElementById('fields-pin');
function toggleFields() {
if (roleSelect.value === 'motorista') {
fieldsEmail.classList.add('hidden');
fieldsPin.classList.remove('hidden');
} else {
fieldsEmail.classList.remove('hidden');
fieldsPin.classList.add('hidden');
}
}
roleSelect.addEventListener('change', toggleFields);
toggleFields(); // run on load
</script>

View File

@@ -0,0 +1,2 @@
<%# app/views/usuarios/edit.html.erb %>
<%= render 'form', usuario: @usuario %>

View File

@@ -0,0 +1,120 @@
<%# app/views/usuarios/index.html.erb %>
<div class="space-y-6">
<%# Header %>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Usuários</h1>
<p class="text-gray-400 text-sm mt-0.5">Gerencie o acesso ao sistema</p>
</div>
<% 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 %>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
Novo Usuário
<% end %>
<% end %>
</div>
<%# Flash %>
<%= render 'shared/flash' %>
<%# Tabela %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-white/10">
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Nome</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">E-mail / PIN</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Perfil</th>
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-4 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% @usuarios.each do |usuario| %>
<tr class="hover:bg-white/5 transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-[#f97316]/20 border border-[#f97316]/30
flex items-center justify-center text-[#f97316] font-bold text-sm flex-shrink-0">
<%= usuario.name.to_s[0].upcase %>
</div>
<span class="text-white font-medium"><%= usuario.name %></span>
</div>
</td>
<td class="px-6 py-4">
<% if usuario.motorista? && usuario.pin_acesso.present? %>
<div class="text-gray-300 text-sm">PIN: ••••</div>
<% else %>
<div class="text-gray-300 text-sm"><%= usuario.email %></div>
<% end %>
</td>
<td class="px-6 py-4">
<%= badge_role(usuario.role) %>
</td>
<td class="px-6 py-4">
<%= badge_status_usuario(usuario.ativo?) %>
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
<% 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
<% end %>
<% end %>
</div>
</td>
</tr>
<% end %>
<% if @usuarios.empty? %>
<tr>
<td colspan="5" class="px-6 py-16 text-center text-gray-500">
<div class="text-4xl mb-3">👥</div>
<p>Nenhum usuário encontrado.</p>
</td>
</tr>
<% end %>
</tbody>
</table>
<% if @usuarios.respond_to?(:total_pages) && @usuarios.total_pages > 1 %>
<div class="px-6 py-4 border-t border-white/10">
<%= paginate @usuarios %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,2 @@
<%# app/views/usuarios/new.html.erb %>
<%= render 'form', usuario: @usuario %>

View File

@@ -1,50 +1,58 @@
# config/routes.rb # config/routes.rb
Rails.application.routes.draw do Rails.application.routes.draw do
# Devise — login padrão para admin/gerente/operador # Devise com controller customizado para sessões
devise_for :users, path: 'auth', path_names: { devise_for :users,
sign_in: 'login', controllers: {
sign_out: 'logout', sessions: 'users/sessions'
password: 'senha'
} }
# Root — redireciona por role # Root
root 'dashboard#index' 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 get '/dashboard', to: 'dashboard#index', as: :dashboard
# Painel motorista # Painel motorista (Fase 2 / 8)
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard 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 resources :consolidacoes do
member do member do
post :finalizar patch :fechar
post :arquivar patch :aprovar
get :preview_holerite patch :cancelar
get :gerar_pdf_relatorio post :reabrir
get :gerar_pdf_holerite end
resources :consolidacao_motoristas, only: [:create, :update, :destroy], shallow: true
end end
resources :consolidacao_motoristas, only: [:index, :show] do # Histórico estimado (Fase 4)
resources :consolidacao_entregas, only: [:index, :create, :update] resources :historico_estimados, only: [:index, :show]
end
end
# Configurações (apenas admin/gerente) # Logs de auditoria (Fase 4)
namespace :admin do resources :auditoria_logs, only: [:index, :show]
resources :usuarios, except: [:show]
resources :configuracoes, only: [:index, :update]
resources :auditoria_logs, only: [:index]
end
# API interna — Dashboard métricas # PDFs (Fase 7)
namespace :api do # get '/consolidacoes/:id/pdf', to: 'pdfs#consolidacao', as: :pdf_consolidacao
namespace :v1 do # get '/consolidacao_motoristas/:id/pdf', to: 'pdfs#holerite', as: :pdf_holerite
get 'dashboard/metricas', to: 'dashboard#metricas'
end
end
# Health check (Docker) # Health check
get '/health', to: proc { [200, {}, ['OK']] } get '/health', to: proc { [200, {}, ['ok']] }
end end

View File

@@ -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

View File

@@ -1,47 +1,78 @@
# db/seeds.rb # db/seeds.rb
# Dados iniciais do sistema Gade Hospitalar # Rodar com: docker-compose exec app bundle exec rails db:seed
# Execute com: bundle exec rails db:seed
puts "🌱 Criando dados iniciais..." puts "🌱 Iniciando seeds..."
# ── Usuário Admin padrão ───────────────────────────────────── # ─── Configurações de preço (Fase 1 + confirmaçã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 ────────────────────────────
configs = [ configs = [
{ chave: 'preco_entrega', valor: '15.00', descricao: 'Valor pago por entrega bem-sucedida (R$)' }, { tipo: :entrega, valor: 10.50, descricao: 'Entrega padrão para UBS, EMAD, STS, SAD' },
{ chave: 'preco_retirada', valor: '20.00', descricao: 'Valor pago por retirada de equipamento (R$)' }, { tipo: :retirada, valor: 8.00, descricao: 'Retirada de material nos pontos de origem' },
{ chave: 'preco_bonus', valor: '10.00', descricao: 'Valor de bônus por meta/critério (R$)' }, { tipo: :bonus, valor: 5.00, descricao: 'Bônus por desempenho / entregas especiais' },
{ chave: 'preco_desconto', valor: '15.00', descricao: 'Valor descontado por problema (R$)' }, { tipo: :desconto, valor: 2.00, descricao: 'Desconto aplicado por ocorrência' }
{ 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' },
] ]
configs.each do |cfg| configs.each do |c|
Configuracao.find_or_create_by!(chave: cfg[:chave]) do |c| Configuracao.find_or_create_by!(tipo: c[:tipo]) do |cfg|
c.valor = cfg[:valor] cfg.valor = c[:valor]
c.descricao = cfg[:descricao] 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 end
puts " ✅ Config: #{cfg[:chave]} = #{cfg[:valor]}"
end end
puts "" puts ""
puts "✅ Seeds concluídos!" puts "✅ Seeds concluídos!"
puts ""
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
puts " Admin: admin@gade.com / Gade@2026!" puts " Admin: admin@gade.com / Gade@2026!"
puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!" 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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

View File

@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Prompt - Sistema de Controle de Custos Logística</title>
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; max-width: 900px; margin: 40px auto; padding: 20px; background: #0f172a; color: #e2e8f0; line-height: 1.6; }
h1 { color: #f97316; border-bottom: 2px solid #f97316; padding-bottom: 10px; }
h2 { color: #fb923c; margin-top: 30px; }
h3 { color: #fdba74; }
.box { background: #1e293b; border-left: 4px solid #f97316; padding: 15px; margin: 15px 0; border-radius: 6px; }
.warning { background: #451a03; border-left-color: #fbbf24; }
.phase { background: #2a1810; border-left-color: #f97316; }
.improvement { background: #1e1b4b; border-left-color: #a78bfa; }
.security { background: #450a0a; border-left-color: #ef4444; }
.data { background: #0f172a; border-left-color: #22d3ee; border: 1px solid #1e293b; }
code { background: #334155; padding: 2px 6px; border-radius: 4px; font-family: 'Courier New', monospace; }
pre { background: #0f172a; padding: 15px; border-radius: 8px; overflow-x: auto; border: 1px solid #334155; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #334155; padding: 10px; text-align: left; }
th { background: #1e293b; color: #f97316; }
tr:nth-child(even) { background: #1e293b; }
ul { margin: 8px 0; }
li { margin: 4px 0; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75em; font-weight: bold; margin-left: 8px; }
.badge-new { background: #f97316; color: #000; }
.badge-change { background: #fbbf24; color: #000; }
.badge-security { background: #ef4444; color: #fff; }
.badge-data { background: #22d3ee; color: #000; }
</style>
</head>
<body>
<h1>🚛 PROMPT: Sistema de Controle de Custos - Logística</h1>
<div class="box warning">
<strong>⚠️ INSTRUÇÃO INICIAL OBRIGATÓRIA:</strong><br>
Antes de começar, pergunte ao usuário: <em>"Qual fase devo executar? Informe o número da fase (1 a 8)."</em><br>
Se o usuário informar uma fase, execute APENAS essa fase. Se informar "TUDO", execute sequencialmente parando a cada fase para confirmação.<br>
Ao final de CADA fase, gere um <strong>CHECKPOINT</strong> com: (1) O que foi feito, (2) Próxima fase, (3) Arquivos criados/modificados.<br>
<strong>ATENÇÃO:</strong> O banco de dados EXISTE e tem dados reais. NUNCA execute DROP TABLE, TRUNCATE ou DELETE em massa nas tabelas existentes.
</div>
<h2>📋 RESUMO EXECUTIVO (Contexto)</h2>
<div class="box">
Empresa de logística <strong>Gade Hospitalar</strong> precisa controlar custos por operação (ex: UBS SUL, UBS LESTE, EMAD, STS, SAD).<br>
Sistema web em <strong>Ruby on Rails</strong> + <strong>PostgreSQL 15</strong> (banco JÁ EXISTE com dados reais) + <strong>Docker</strong>.<br>
Público: administradores, gerentes, motoristas e operadores (baixo nível tech).<br>
Design: moderno, minimalista, <strong>cores padrão PRETO + LARANJA + BRANCO</strong>, ícones grandes, poucos cliques, feedback visual claro.
</div>
<!-- ==================== DADOS REAIS DO BANCO ==================== -->
<h1>🗄️ DADOS REAIS DO BANCO EXISTENTE <span class="badge badge-data">REAL</span></h1>
<div class="box data">
<strong>📡 Tabela existente:</strong> <code>public.db_reem_simplerout_2026</code><br>
<strong>🔄 Atualização:</strong> Automática a cada 1 hora (dados sobem de outro sistema)<br>
<strong>🏢 Account:</strong> Gade Hospitalar (account_id: 95907)
<h3>📊 Estrutura da Tabela Existente (46 colunas)</h3>
<table>
<tr><th>Coluna</th><th>Tipo</th><th>Uso no Sistema</th></tr>
<tr><td>tracking_id</td><td>string</td><td>ID único da entrega (PK lógico)</td></tr>
<tr><td>reference_id</td><td>integer</td><td>Número da NF (Nota Fiscal)</td></tr>
<tr><td>planned_date</td><td>date</td><td>Data planejada da entrega</td></tr>
<tr><td>driver</td><td>string</td><td>Nome do motorista</td></tr>
<tr><td>codrivers</td><td>string</td><td>Ajudante (geralmente NULL)</td></tr>
<tr><td>vehicle</td><td>string</td><td>Placa/ID do veículo (ex: GADE_037)</td></tr>
<tr><td>title</td><td>string</td><td>Título da entrega (ex: "NF 46497 - ELISABETH ROCHA FERREIRA")</td></tr>
<tr><td>address</td><td>string</td><td>Endereço completo da entrega</td></tr>
<tr><td>eta</td><td>time</td><td>Hora estimada de chegada</td></tr>
<tr><td>etd</td><td>time</td><td>Hora estimada de saída</td></tr>
<tr><td>checkin</td><td>datetime</td><td>Horário real de chegada (pode ser NULL)</td></tr>
<tr><td>checkout</td><td>datetime</td><td>Horário real de saída</td></tr>
<tr><td>responsible_person</td><td>string</td><td>Quem fez a entrega (mesmo que driver)</td></tr>
<tr><td>estimated_service_time</td><td>interval</td><td>Tempo estimado de serviço (fixo 00:10:00)</td></tr>
<tr><td>real_service_time</td><td>interval</td><td>Tempo real de serviço (checkout - checkin)</td></tr>
<tr><td>advance</td><td>interval</td><td>Quanto chegou antes do planejado</td></tr>
<tr><td>delay</td><td>interval</td><td>Quanto atrasou (geralmente 00:00)</td></tr>
<tr><td>latitude / longitude</td><td>decimal</td><td>Coordenadas de chegada</td></tr>
<tr><td>checkout_latitude / checkout_longitude</td><td>decimal</td><td>Coordenadas de saída</td></tr>
<tr><td>load / load_2 / load_3</td><td>integer</td><td>Quantidade de cargas (geralmente 1/0/0)</td></tr>
<tr><td>status</td><td>string</td><td>Status da entrega (completed, pending, etc.)</td></tr>
<tr><td>comments</td><td>text</td><td>Comentários do motorista</td></tr>
<tr><td>observation</td><td>string</td><td>Observações internas</td></tr>
<tr><td>start_of_time_window_1 / end_of_time_window_1</td><td>time</td><td>Janela de horário 1 (08:00-18:00)</td></tr>
<tr><td>start_of_time_window_2 / end_of_time_window_2</td><td>time</td><td>Janela de horário 2 (23:59-23:59)</td></tr>
<tr><td>required_skills / optional_skills</td><td>string</td><td>Habilidades necessárias</td></tr>
<tr><td>notes</td><td>string</td><td>Complementos (ex: "COMPL: CASA 1", "TEL: (11) 99999-9999")</td></tr>
<tr><td>contact_name</td><td>string</td><td>Nome do local (ex: UBS SACOMA, EMAD VELEIROS, STS BUTANTA)</td></tr>
<tr><td>contact_phone</td><td>string</td><td>Telefone do local</td></tr>
<tr><td>contact_email</td><td>string</td><td>Email do local (geralmente NULL)</td></tr>
<tr><td>route_id</td><td>uuid</td><td>ID da rota/operacao (ex: UBS SUL, UBS LESTE)</td></tr>
<tr><td>account_id</td><td>integer</td><td>ID da conta (95907 = Gade Hospitalar)</td></tr>
<tr><td>account_name</td><td>string</td><td>Nome da conta</td></tr>
<tr><td>nota_fiscal</td><td>url</td><td>Foto da NF (URL S3)</td></tr>
<tr><td>protocolo_de_entrega</td><td>url</td><td>Foto do protocolo (URL S3)</td></tr>
<tr><td>termo_de_recebimento</td><td>url</td><td>Foto do termo (URL S3)</td></tr>
<tr><td>foto_da_fachada</td><td>url</td><td>Foto da fachada (URL S3)</td></tr>
<tr><td>relatorio_de_visita</td><td>url</td><td>Foto do relatório (URL S3)</td></tr>
<tr><td>numero_de_serie_da_base_aparelho</td><td>string</td><td>Número de série do equipamento</td></tr>
</table>
<h3>📈 Dados de Exemplo (Extraídos do Banco Real)</h3>
<ul>
<li><strong>Motoristas:</strong> Bruno Corgozinho, Carlos Matheus Pimentel, Edson Vinicius Pereira, Gregori Karenkvic, Steferson Lucas, Thiago Rabello Bittencourt, Henderson Estevam</li>
<li><strong>Veículos:</strong> GADE_001 a GADE_123 (14 veículos diferentes)</li>
<li><strong>Locais (contact_name):</strong> 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</li>
<li><strong>Rotas (route_id):</strong> UUIDs que agrupam entregas por operação</li>
<li><strong>Status:</strong> completed (entregas concluídas), pode haver pending, cancelled, etc.</li>
<li><strong>Checkin:</strong> Pode ser NULL (entrega sem registro de chegada)</li>
<li><strong>Load:</strong> Geralmente 1 (uma carga por entrega)</li>
</ul>
<h3>🔑 Regras de Negócio Baseadas nos Dados Reais</h3>
<ul>
<li>Uma entrega = um <code>tracking_id</code> único</li>
<li>Cada entrega tem um <code>reference_id</code> = número da NF</li>
<li>O <code>driver</code> é o motorista principal (codrivers geralmente NULL)</li>
<li>O <code>contact_name</code> identifica o local/UBS de destino</li>
<li>O <code>route_id</code> agrupa entregas da mesma operação (ex: UBS SUL, UBS LESTE)</li>
<li><code>status = 'completed'</code> = entrega bem-sucedida (paga)</li>
<li><code>status != 'completed'</code> = pode ser desconto, pendente, cancelada</li>
<li><code>checkin IS NULL</code> = motorista não registrou chegada (possível desconto)</li>
<li><code>real_service_time = '00:00:00'</code> = chegou e saiu no mesmo minuto (possível problema)</li>
</ul>
</div>
<!-- ==================== MELHORIAS ==================== -->
<h1>🔧 MELHORIAS SUGERIDAS NO DESIGN E LÓGICA</h1>
<div class="box improvement">
<h3>✨ MELHORIA 1 — Cards Visuais Grandes no Dashboard <span class="badge badge-new">NOVO</span></h3>
<p>Cards com cores da marca (preto/laranja/branco):</p>
<ul>
<li>🟠 <strong>Card Entregas OK</strong> — laranja, contador grande e % de sucesso</li>
<li><strong>Card Pendentes</strong> — preto, entregas não consolidadas</li>
<li><strong>Card Descontos</strong> — branco com borda laranja, total de descontos</li>
<li>🟠 <strong>Card Bônus</strong> — laranja claro, total de bônus</li>
</ul>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 2 — Modo "Revisão Rápida" com Ações em Massa <span class="badge badge-new">NOVO</span></h3>
<p>Botões de ação em massa na consolidação:</p>
<ul>
<li><strong>"Marcar Todos como Entrega Normal"</strong></li>
<li><strong>"Marcar Selecionados como..."</strong> (Shift+click para selecionar múltiplas)</li>
<li><strong>Filtro:</strong> "Mostrar apenas não classificadas"</li>
<li><strong>Barra de progresso:</strong> "340 de 400 entregas classificadas (85%)"</li>
</ul>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 3 — Preview do Holerite Antes de Gerar PDF <span class="badge badge-new">NOVO</span></h3>
<p>Modal de preview HTML antes de gerar PDF — evita erros e re-trabalho.</p>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 4 — Notificações WhatsApp/E-mail <span class="badge badge-new">NOVO</span></h3>
<p>Ao finalizar consolidação, enviar para motoristas: "Sua consolidação [Período] está pronta. Valor: R$ [X]. Baixe: [link]"</p>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 5 — Comparativo Estimado vs Consolidado <span class="badge badge-new">NOVO</span></h3>
<p>Gráfico de barras lado a lado no Dashboard: Estimado (cinza) vs Consolidado (laranja) vs Diferença (preto).</p>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 6 — Filtro Avançado nas Consolidações <span class="badge badge-new">NOVO</span></h3>
<p>Busca por nome da operação, período, motorista, status, route_id.</p>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 7 — Soft Delete + Auditoria <span class="badge badge-new">NOVO</span></h3>
<p>Consolidações finalizadas NUNCA excluídas — apenas arquivadas. Log completo de quem fez o quê.</p>
</div>
<div class="box improvement">
<h3>✨ MELHORIA 8 — Tema Escuro/Claro <span class="badge badge-new">NOVO</span></h3>
<p>Toggle ☀️/🌙 no header. Tema escuro como padrão (preto + laranja fica incrível).</p>
</div>
<div class="box improvement">
<h3>🔁 MUDANÇA 1 — Checkboxes → Toggle Switches Visuais <span class="badge badge-change">MUDANÇA</span></h3>
<p>Toggle switches grandes coloridos: 🟠 laranja = Entrega Normal | ⚫ preto = Desconto | ⚪ branco = Bônus | 🟠 laranja escuro = Retirada</p>
</div>
<div class="box improvement">
<h3>🔁 MUDANÇA 2 — Validação em Wizard de 3 Passos <span class="badge badge-change">MUDANÇA</span></h3>
<p>Passo 1: Selecionar motorista | Passo 2: Classificar entregas com toggles + ações em massa | Passo 3: Revisar resumo → Próximo ou Finalizar</p>
</div>
<div class="box improvement">
<h3>🔁 MUDANÇA 3 — Job de 1h + Cálculo em Tempo Real <span class="badge badge-change">MUDANÇA</span></h3>
<p>Job persiste histórico, mas Dashboard calcula na hora ao abrir. Sem esperar 1h.</p>
</div>
<div class="box improvement">
<h3>🔁 MUDANÇA 4 — Login por PIN de 4 Dígitos + QR Code <span class="badge badge-change">MUDANÇA</span></h3>
<p>Motoristas usam PIN numérico ou escaneiam QR do holerite. Sem senha complexa.</p>
</div>
<!-- ==================== ARQUITETURA ==================== -->
<h2>🏗️ ARQUITETURA</h2>
<table>
<tr><th>Camada</th><th>Tecnologia</th></tr>
<tr><td>Backend</td><td>Ruby on Rails 7+ (API + Views)</td></tr>
<tr><td>Frontend</td><td>Rails Views + Hotwire (Turbo + Stimulus)</td></tr>
<tr><td>CSS</td><td>Tailwind CSS (tema customizado preto/laranja/branco)</td></tr>
<tr><td>Banco Existente</td><td>PostgreSQL 15 — tabela <code>db_reem_simplerout_2026</code> (apenas leitura)</td></tr>
<tr><td>Banco Novo</td><td>PostgreSQL 15 — tabelas do sistema (mesma instância, schema separado ou prefixo)</td></tr>
<tr><td>Auth</td><td>Devise + Pundit (roles: admin, gerente, operador, motorista)</td></tr>
<tr><td>PDF</td><td>Prawn (mais leve que WickedPDF)</td></tr>
<tr><td>Job/Cron</td><td>Whenever gem</td></tr>
<tr><td>Notificações</td><td>Twilio (WhatsApp) ou ActionMailer — opcional Fase 8</td></tr>
<tr><td>Docker</td><td>Docker + docker-compose.yml</td></tr>
<tr><td>Config</td><td>.env (NUNCA versionar)</td></tr>
<tr><td>Git</td><td>Servidor privado: <code>https://git.xenserver.com.br</code></td></tr>
</table>
<!-- ==================== REPOSITÓRIO GIT ==================== -->
<h2>🔒 REPOSITÓRIO GIT <span class="badge badge-security">SEGURANÇA</span></h2>
<div class="box security">
<strong>⚠️ AVISO DE SEGURANÇA:</strong><br>
NUNCA armazene credenciais de login (usuário/senha) em arquivos de código, prompts ou documentação.<br>
Sempre use variáveis de ambiente, CI/CD secrets ou gerenciadores de credenciais.
</div>
<div class="box">
<h3>📡 Servidor Git Privado</h3>
<p><strong>URL:</strong> <code>https://git.xenserver.com.br</code> (servidor próprio, privado)</p>
<p><strong>Repositório:</strong> <code>logistica-controle-custos</code> (criar como PRIVADO)</p>
<h3>🔐 Configuração Segura</h3>
<ol>
<li>Acesse <code>https://git.xenserver.com.br</code> e faça login com credenciais pessoais</li>
<li>Crie repositório privado: <code>logistica-controle-custos</code></li>
<li>Configure remote local (SEM credenciais no URL):</li>
</ol>
<pre>
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
</pre>
<h3>📝 .gitignore Obrigatório</h3>
<pre>
# 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
</pre>
<h3>📋 Checklist de Segurança do Repositório</h3>
<ul>
<li>✅ Repositório marcado como <strong>PRIVADO</strong></li>
<li>✅ Arquivo <code>.env</code> no <code>.gitignore</code></li>
<li>✅ Nenhuma senha, token ou chave em código</li>
<li><code>config/database.yml</code> usa variáveis de ambiente</li>
<li><code>docker-compose.yml</code> usa <code>env_file: .env</code></li>
<li>✅ Secret Key Base gerada via <code>rails secret</code> e armazenada em variável de ambiente</li>
</ul>
</div>
<h2>👥 PERFIL DE USUÁRIOS E PERMISSÕES</h2>
<table>
<tr><th>Perfil</th><th>Acesso</th></tr>
<tr><td>Admin</td><td>Tudo: Dashboard, Consolidação, Configurações, Motoristas, Auditoria</td></tr>
<tr><td>Gerente</td><td>Dashboard, Consolidação, gerar PDFs/holerites, visualizar arquivadas</td></tr>
<tr><td>Operador</td><td>Apenas Consolidação (criar, validar entregas, toggles), ver próprias consolidações</td></tr>
<tr><td>Motorista</td><td>Dashboard pessoal: valores estimados + consolidados validados, baixar próprio PDF</td></tr>
</table>
<h2>💰 PILARES DE PREÇO (Configuráveis na tela Configurações)</h2>
<table>
<tr><th>Pilar</th><th>Descrição</th><th>Tipo</th><th>Quando Aplicar</th></tr>
<tr><td>Entrega Bem-Sucedida</td><td>Valor pago por entrega concluída</td><td>Valor fixo (R$)</td><td>status = 'completed' E checkin IS NOT NULL</td></tr>
<tr><td>Retirada de Equipamento</td><td>Valor pago por retirada/devolução</td><td>Valor fixo (R$)</td><td>Quando o operador marcar como "Retirada" na consolidação</td></tr>
<tr><td>Bônus</td><td>Valor adicional por meta/critério</td><td>Valor fixo (R$)</td><td>Quando o operador marcar como "Bônus" na consolidação</td></tr>
<tr><td>Desconto</td><td>Valor descontado por problema</td><td>Valor fixo (R$)</td><td>Quando o operador marcar como "Desconto" na consolidação</td></tr>
</table>
<h2>📐 ESTRUTURA DE TELAS</h2>
<h3>🔐 TELA DE LOGIN</h3>
<ul>
<li>Campos: email, senha (admin/gerente/operador)</li>
<li>Alternativa motorista: <strong>PIN de 4 dígitos</strong> ou <strong>QR Code</strong></li>
<li>Botão grande "Entrar" com ícone</li>
<li>Esqueci minha senha → link mágico por email</li>
<li>Fundo preto, botão laranja, texto branco</li>
</ul>
<h3>🖥️ PAINEL ADMIN / GERENTE / OPERADOR</h3>
<h3>1. DASHBOARD</h3>
<ul>
<li><strong>Cards grandes (preto/laranja/branco):</strong>
<ul>
<li>🟠 <strong>Entregas OK</strong> — total de entregas completed no período</li>
<li><strong>Pendentes</strong> — entregas não consolidadas ainda</li>
<li><strong>Descontos</strong> — total de descontos aplicados</li>
<li>🟠 <strong>Bônus</strong> — total de bônus concedidos</li>
</ul>
</li>
<li><strong>Gráfico comparativo:</strong> barras lado a lado Estimado (cinza) vs Consolidado (laranja) por operação/route_id</li>
<li><strong>Lista últimas 5 consolidações</strong> com status e link</li>
<li><strong>Barra de progresso:</strong> "% do mês consolidado"</li>
<li>Atualização em tempo real ao carregar + job de 1h para histórico</li>
</ul>
<h3>2. CONSOLIDAÇÃO</h3>
<ul>
<li><strong>Lista de consolidações</strong> com filtros (nome, período, motorista, status, route_id)</li>
<li>Botão "Nova Consolidação" → modal:
<ul>
<li>Nome (ex: UBS SUL e LESTE Junho 2026)</li>
<li>Período (date range: planned_date)</li>
<li>Selecionar Motoristas (multi-select com busca — lista única de <code>driver</code> do banco)</li>
<li>Selecionar Rotas/Operações (multi-select de <code>route_id</code>)</li>
</ul>
</li>
<li><strong>Wizard de Validação (3 passos):</strong>
<ol>
<li><strong>Passo 1:</strong> Selecionar motorista da lista (com foto/nome e total de entregas no período)</li>
<li><strong>Passo 2:</strong> Classificar entregas com <strong>Toggle Switches coloridos</strong>:
<ul>
<li>🟠 Laranja = Entrega Normal (padrão)</li>
<li>⚫ Preto = Desconto</li>
<li>⚪ Branco = Bônus</li>
<li>🟠 Laranja Escuro = Retirada de Equipamento</li>
</ul>
+ Ações em massa + filtro "não classificadas"
</li>
<li><strong>Passo 3:</strong> Revisar resumo do motorista → "Próximo Motorista" ou "Finalizar Tudo"</li>
</ol>
</li>
<li><strong>Barra de progresso:</strong> "85% classificado — 340 de 400 entregas"</li>
<li><strong>Botões:</strong> "Salvar Rascunho" | "Finalizar Consolidação" (modal de confirmação)</li>
<li>Após finalizar: <strong>Preview do Holerite</strong> em modal HTML → "Gerar PDF"</li>
</ul>
<h3>3. CONFIGURAÇÕES</h3>
<ul>
<li><strong>Usuários:</strong> CRUD de logins com foto, role, status ativo/inativo</li>
<li><strong>Pilares de Preço:</strong> campos editáveis com máscara de moeda (R$), cores da marca</li>
<li><strong>Notificações:</strong> ligar/desligar WhatsApp/email, configurar Twilio</li>
<li><strong>Geral:</strong> nome da empresa, upload de logo, tema padrão</li>
<li><strong>Auditoria:</strong> log de quem criou/editou/finalizou o quê e quando</li>
</ul>
<h3>🛵 PAINEL MOTORISTA</h3>
<ul>
<li><strong>2 Cards grandes:</strong>
<ul>
<li>🟠 <strong>Valor Estimado</strong> (laranja) — cálculo ao vivo: entregas completed × preço_entrega</li>
<li><strong>Valor Consolidado</strong> (branco com borda laranja) — validado pelo operador</li>
</ul>
</li>
<li><strong>Lista de consolidações</strong> em que o motorista aparece (nome, período, valor, botão PDF)</li>
<li><strong>Botão de tema:</strong> ☀️/🌙 no canto superior</li>
<li>Design mobile-first: cards empilhados, fonte grande (min 18px), botões de dedo (min 48px)</li>
<li>Fundo preto, cards cinza escuro, destaques em laranja</li>
</ul>
<h2>🗄️ MODELO DE DADOS</h2>
<div class="box data">
<h3>📡 TABELAS EXISTENTES (APENAS LEITURA — NUNCA MODIFICAR)</h3>
<pre>
-- 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
</pre>
<h3>🆕 TABELAS NOVAS DO SISTEMA (criar no mesmo PostgreSQL, schema separado ou prefixo)</h3>
<pre>
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
</pre>
</div>
<h2>🐳 DOCKER + CONFIGURAÇÃO (YAML)</h2>
<pre>
# 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"
</pre>
<h2>🎨 DIRETRIZES DE DESIGN (UX para baixo nível tech)</h2>
<div class="box">
<h3>🎨 Paleta de Cores da Marca</h3>
<table>
<tr><th>Elemento</th><th>Cor</th><th>Hex</th></tr>
<tr><td>Fundo principal (tema escuro)</td><td>Preto</td><td>#0a0a0a ou #111111</td></tr>
<tr><td>Fundo cards</td><td>Cinza escuro</td><td>#1a1a1a ou #1e1e1e</td></tr>
<tr><td>Cor primária (botões, destaques)</td><td>Laranja</td><td>#f97316 ou #ea580c</td></tr>
<tr><td>Cor secundária (textos, ícones)</td><td>Branco</td><td>#ffffff</td></tr>
<tr><td>Cor de alerta/sucesso</td><td>Verde</td><td>#22c55e</td></tr>
<tr><td>Cor de erro/desconto</td><td>Vermelho</td><td>#ef4444</td></tr>
<tr><td>Cor de info/bônus</td><td>Azul claro</td><td>#38bdf8</td></tr>
</table>
<h3>📐 Regras de UX</h3>
<ul>
<li><strong>Fonte mínima 18px</strong> no mobile, 16px no desktop</li>
<li><strong>Botões mínimo 48px</strong> de altura, cantos arredondados (rounded-lg)</li>
<li><strong>Toggle switches</strong> em vez de checkboxes — área maior, feedback visual</li>
<li><strong>Modais de confirmação</strong> para ações destrutivas (finalizar, arquivar)</li>
<li><strong>Toast notifications</strong> no canto superior direito, cor laranja para sucesso</li>
<li><strong>Wizard em passos</strong> — uma ação por tela, sem abas aninhadas</li>
<li><strong>Mobile-first</strong> — muitos motoristas acessarão pelo celular</li>
<li><strong>Tema escuro como padrão</strong> — preto + laranja é a identidade da marca</li>
<li><strong>Ícones + texto</strong> — nunca ícone sozinho</li>
<li><strong>Placeholders explicativos</strong> nos campos (ex: "Ex: UBS SUL e LESTE Junho 2026")</li>
<li><strong>Loading states</strong> — spinner laranja ao carregar dados</li>
<li><strong>Empty states</strong> — mensagem amigável quando não há dados (ex: "Nenhuma entrega neste período")</li>
</ul>
</div>
<h2>📊 JOB DE ATUALIZAÇÃO ESTIMADA (a cada 1h)</h2>
<pre>
# 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.
</pre>
<h2>📄 GERADOR DE PDF (Holerite)</h2>
<ul>
<li><strong>Cabeçalho:</strong> logo da empresa (preto + laranja), nome da consolidação, período, data de geração</li>
<li><strong>Dados do motorista:</strong> nome, período trabalhado, total de entregas</li>
<li><strong>Corpo:</strong> tabela com entregas (data, NF, local/contact_name, tipo, valor unitário, valor total)</li>
<li><strong>Resumo:</strong> total de entregas normais, retiradas, bônus, descontos, valor final a pagar</li>
<li><strong>Rodapé:</strong> "Documento gerado automaticamente em [data]", QR code para validação</li>
<li><strong>Estilo:</strong> limpo, fonte legível, cores preto/laranja/branco</li>
</ul>
<!-- ==================== FASES DE IMPLEMENTAÇÃO ==================== -->
<h1>🔧 FASES DE IMPLEMENTAÇÃO (Checkpoint obrigatório ao final de cada uma)</h1>
<div class="box phase">
<h3>🟠 FASE 1 — Setup Inicial + Docker + Git</h3>
<ol>
<li>Criar estrutura Rails 7+ com <code>docker-compose.yml</code></li>
<li>Configurar conexão com PostgreSQL 15 existente (tabela <code>db_reem_simplerout_2026</code> — APENAS LEITURA)</li>
<li>Criar model <code>DbReemSimplerout2026</code> com <code>self.table_name = 'db_reem_simplerout_2026'</code> e <code>self.primary_key = 'tracking_id'</code></li>
<li>Criar tabelas novas: users, configuracoes, consolidacoes, consolidacao_motoristas, consolidacao_entregas, historico_estimados, auditoria_logs</li>
<li>Configurar gem Figaro ou dotenv para variáveis em <code>.env</code> (NUNCA versionar .env)</li>
<li>Instalar e configurar Devise + Pundit (roles: admin, gerente, operador, motorista)</li>
<li>Seed: criar usuário admin padrão (admin@gade.com / senha123)</li>
<li>Testar <code>docker-compose up</code> e acesso ao Rails</li>
<li>Inicializar repositório Git: <code>git init</code>, criar <code>.gitignore</code> completo, primeiro commit</li>
<li>Adicionar remote para servidor privado <code>git.xenserver.com.br</code></li>
<li>Push inicial para o servidor Git privado</li>
</ol>
<strong>CHECKPOINT FASE 1:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 2 — Autenticação + Perfis + Configurações</h3>
<ol>
<li>Implementar login com redirecionamento por role</li>
<li>Login por PIN de 4 dígitos para motoristas (alternativa a senha)</li>
<li>Criar tela de Configurações com CRUD de usuários (admin/gerente/operador/motorista)</li>
<li>Criar CRUD de Pilares de Preço (entrega, retirada, bônus, desconto) com máscara de moeda</li>
<li>Validar permissões: operador não vê config, motorista não vê admin</li>
<li>Layout base com navegação lateral responsiva (Tailwind + tema preto/laranja/branco)</li>
<li>Toggle tema claro/escuro no header</li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 2:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 3 — Dashboard Admin/Gerente</h3>
<ol>
<li>Criar controller Dashboard com métricas consolidadas</li>
<li>Cards coloridos grandes (preto/laranja/branco): Entregas OK, Pendentes, Descontos, Bônus</li>
<li>Gráfico comparativo Estimado vs Consolidado (Chart.js simples, cores da marca)</li>
<li>Lista últimas 5 consolidações com link para detalhe</li>
<li>Barra de progresso "% do mês consolidado"</li>
<li>Cálculo em tempo real (SQL rápido) ao carregar a página — buscar de <code>db_reem_simplerout_2026</code></li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 3:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 4 — Job de Atualização Automática (1h) + Auditoria</h3>
<ol>
<li>Implementar job com Whenever gem para rodar a cada 1 hora</li>
<li>Lógica: contar entregas <code>status='completed'</code> + <code>checkin IS NOT NULL</code> do dia, multiplicar por <code>preco_entrega</code>, salvar em <code>historico_estimados</code></li>
<li>Criar modelo <code>AuditoriaLog</code> para registrar ações críticas (criar, editar, finalizar, arquivar consolidação)</li>
<li>Expor valor na API interna para o Dashboard</li>
<li>Testar execução manual e confirmar persistência no banco</li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 4:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 5 — Consolidação (Criar + Selecionar + Wizard Passo 1)</h3>
<ol>
<li>Tela "Nova Consolidação": formulário com nome, período (2 date pickers), multi-select de motoristas (busca de <code>driver</code> do banco), multi-select de rotas (<code>route_id</code>)</li>
<li>Ao salvar: criar registro em <code>consolidacoes</code> (status: rascunho) + <code>consolidacao_motoristas</code></li>
<li>Listar consolidações com filtros (status, período, nome, motorista, route_id)</li>
<li>Wizard Passo 1: Selecionar motorista da lista (com total de entregas no período)</li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 5:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 6 — Validação de Entregas + Toggles + Ações em Massa</h3>
<ol>
<li>Wizard Passo 2: Buscar entregas do período para o motorista selecionado (de <code>db_reem_simplerout_2026</code>)</li>
<li>Toggle switches coloridos grandes (laranja=normal, preto=desconto, branco=bônus, laranja escuro=retirada)</li>
<li>Ações em massa: "Marcar todos como Normal", "Marcar selecionados como..."</li>
<li>Filtro "Mostrar apenas não classificadas"</li>
<li>Barra de progresso "X de Y entregas classificadas"</li>
<li>Resumo lateral com total por tipo (Stimulus para atualização em tempo real)</li>
<li>Wizard Passo 3: Revisar resumo do motorista → "Próximo Motorista" ou "Salvar Rascunho"</li>
<li>Validação: não permitir finalizar sem classificar todas as entregas</li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 6:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 7 — Geração de PDFs (Preview + Relatório + Holerite)</h3>
<ol>
<li>Instalar e configurar Prawn (mais leve que WickedPDF)</li>
<li>Layout de PDF: logo preto/laranja, nome consolidação, período, tabela de entregas, total</li>
<li>Modal de Preview HTML antes de gerar PDF (modal com botão Confirmar/Voltar)</li>
<li>Botão "Gerar Relatório Individual" por motorista (PDF com detalhe das entregas)</li>
<li>Botão "Gerar Holerite" (PDF resumido com valor total a pagar — estilo holerite)</li>
<li>QR code no holerite para login rápido do motorista</li>
<li>Soft delete: consolidações finalizadas vão para status "arquivada", nunca excluídas</li>
<li>Commit e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 7:</strong> Liste todos os arquivos criados/modificados. Pare. Aguarde próxima fase.
</div>
<div class="box phase">
<h3>🟠 FASE 8 — Painel do Motorista + Notificações + Finalização</h3>
<ol>
<li>Tela do motorista após login: 2 cards grandes (Valor Estimado laranja | Valor Consolidado branco)</li>
<li>Lista de consolidações em que ele aparece com link para baixar próprio PDF</li>
<li>Login por PIN ou QR Code (escaneia holerite para acessar)</li>
<li>Notificações: integração Twilio (WhatsApp) ou ActionMailer (email) — ao finalizar consolidação, enviar mensagem automática para motoristas</li>
<li>Ajustes finais de responsividade (mobile)</li>
<li>Revisar todas as permissões (Pundit)</li>
<li>Revisar textos/labels para clareza (público baixo nível tech)</li>
<li>README.md com instruções de deploy Docker e configuração do .env</li>
<li>Teste end-to-end: login → criar consolidação → validar → gerar PDF → notificar → logout → login motorista → ver valores</li>
<li>Commit final e push para o Git privado</li>
</ol>
<strong>CHECKPOINT FASE 8 (FINAL):</strong> Liste todos os arquivos do projeto. Status: ✅ COMPLETO.
</div>
<h2>💡 DICAS PARA O CLAUDE (Instruções de Otimização)</h2>
<div class="box">
<ul>
<li>Use <strong>Rails 7+ com importmap</strong> — evite Webpack para reduzir complexidade</li>
<li>Use <strong>Tailwind via CDN</strong> durante desenvolvimento, depois compile se necessário</li>
<li>Para o banco existente: use <code>self.table_name = 'db_reem_simplerout_2026'</code> e <code>self.primary_key = 'tracking_id'</code> — NUNCA crie migration para essa tabela</li>
<li>Para toggles: use <strong>Stimulus</strong> para atualizar totais em tempo real sem recarregar página</li>
<li>Para o job de 1h: use <strong>Whenever gem</strong> (mais simples que Sidekiq para cron)</li>
<li>Para PDF: <strong>Prawn</strong> é mais leve que WickedPDF (sem dependência de wkhtmltopdf)</li>
<li>Para mobile: use <strong>CSS Grid/Flexbox</strong> com breakpoints Tailwind (<code>md:</code>, <code>lg:</code>)</li>
<li>Para tema escuro: use <code>dark:</code> prefix do Tailwind + <code>class</code> strategy — tema escuro como padrão</li>
<li>Para cores da marca: configure no <code>tailwind.config.js</code> — preto <code>#0a0a0a</code>, laranja <code>#f97316</code>, branco <code>#ffffff</code></li>
<li>Seja verboso nos <strong>CHECKPOINTS</strong> — liste arquivo por arquivo para continuidade</li>
<li>A cada fase: <code>git add . && git commit -m "Fase X completa" && git push</code></li>
<li>Se o banco existente estiver em servidor separado, configure <code>DB_HOST</code> no .env apontando para o IP correto</li>
</ul>
</div>
<h2>📝 EXEMPLO DE COMANDO PARA INICIAR CADA FASE</h2>
<pre>
# 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
</pre>
<div class="box warning">
<strong>⚠️ IMPORTANTE:</strong> 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: <em>"Continue a FASE X a partir deste CHECKPOINT: [cole]"</em>.
</div>
<div class="box security">
<strong>🔐 LEMBRETE FINAL DE SEGURANÇA:</strong><br>
<ul>
<li>NUNCA commit arquivos .env, application.yml, ou qualquer arquivo com senha</li>
<li>NUNCA exponha SECRET_KEY_BASE, senhas de banco, ou tokens de API</li>
<li>Sempre use <code>ENV['VARIAVEL']</code> no código Rails</li>
<li>Configure o repositório Git como PRIVADO no servidor <code>git.xenserver.com.br</code></li>
<li>Use HTTPS com credential helper ou SSH keys para autenticação no Git</li>
<li>NUNCA execute DROP TABLE, TRUNCATE ou DELETE em massa na tabela <code>db_reem_simplerout_2026</code> existente</li>
</ul>
</div>
</body>
</html>

54
setup_fase2_3.sh Normal file
View File

@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"