implantação da fase 2 e 3
This commit is contained in:
@@ -4,24 +4,26 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_tema
|
||||
after_action :verify_authorized, except: :index, unless: :skip_pundit?
|
||||
after_action :verify_policy_scoped, only: :index, unless: :skip_pundit?
|
||||
|
||||
# Pundit: redireciona se não autorizado
|
||||
rescue_from Pundit::NotAuthorizedError do |e|
|
||||
flash[:alert] = "Você não tem permissão para realizar esta ação."
|
||||
redirect_to root_path
|
||||
end
|
||||
rescue_from Pundit::NotAuthorizedError, with: :usuario_nao_autorizado
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
private
|
||||
|
||||
def set_tema
|
||||
@tema = current_user&.tema_preferido || 'dark'
|
||||
# Lê o tema da sessão (padrão: escuro)
|
||||
@tema = session[:tema] || 'dark'
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
if resource.motorista?
|
||||
motorista_dashboard_path
|
||||
else
|
||||
dashboard_path
|
||||
end
|
||||
def skip_pundit?
|
||||
devise_controller? || params[:controller] =~ /rails\/|action_mailbox|action_text/
|
||||
end
|
||||
|
||||
def usuario_nao_autorizado
|
||||
flash[:alert] = 'Acesso negado. Você não tem permissão para esta ação.'
|
||||
redirect_back(fallback_location: dashboard_path)
|
||||
end
|
||||
end
|
||||
|
||||
44
app/controllers/configuracoes_controller.rb
Normal file
44
app/controllers/configuracoes_controller.rb
Normal 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
|
||||
77
app/controllers/dashboard_controller.rb
Normal file
77
app/controllers/dashboard_controller.rb
Normal 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
|
||||
42
app/controllers/motorista_controller.rb
Normal file
42
app/controllers/motorista_controller.rb
Normal 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
|
||||
56
app/controllers/users/sessions_controller.rb
Normal file
56
app/controllers/users/sessions_controller.rb
Normal 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
|
||||
88
app/controllers/usuarios_controller.rb
Normal file
88
app/controllers/usuarios_controller.rb
Normal 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
|
||||
@@ -1,46 +1,82 @@
|
||||
# app/helpers/application_helper.rb
|
||||
module ApplicationHelper
|
||||
# Link de navegação com estado ativo
|
||||
def nav_link_to(label, path, **opts)
|
||||
active = current_page?(path)
|
||||
css = [
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px]",
|
||||
active ? "bg-orange-500 text-black" : "text-gray-400 hover:bg-[#1a1a1a] hover:text-white"
|
||||
].join(" ")
|
||||
link_to label, path, class: css
|
||||
end
|
||||
|
||||
# Formata moeda BR
|
||||
# Formata valor monetário → "R$ 1.250,00"
|
||||
def moeda(valor)
|
||||
"R$ #{"%.2f" % valor.to_f}".gsub('.', ',')
|
||||
"R$ #{format('%.2f', valor.to_f).gsub('.', ',')}"
|
||||
end
|
||||
|
||||
# Badge de status de consolidação
|
||||
# Link ativo na sidebar
|
||||
def nav_link_to(nome, path, icon: nil, **opts)
|
||||
ativo = current_page?(path) || request.path.start_with?(path.to_s)
|
||||
classes = [
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all',
|
||||
ativo ? 'bg-[#f97316] text-white shadow-lg shadow-orange-500/20'
|
||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||
].join(' ')
|
||||
|
||||
link_to path, class: classes, **opts do
|
||||
concat content_tag(:span, icon, class: 'text-lg w-5 text-center') if icon
|
||||
concat content_tag(:span, nome)
|
||||
end
|
||||
end
|
||||
|
||||
# Badge colorido por status de consolidação
|
||||
def badge_status(status)
|
||||
cfg = case status.to_s
|
||||
when 'rascunho' then { cor: 'bg-yellow-500 text-black', icone: '📝', label: 'Rascunho' }
|
||||
when 'finalizada' then { cor: 'bg-green-600 text-white', icone: '✅', label: 'Finalizada' }
|
||||
when 'arquivada' then { cor: 'bg-gray-700 text-gray-300', icone: '📁', label: 'Arquivada' }
|
||||
else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.humanize }
|
||||
end
|
||||
config = {
|
||||
'aberta' => { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Aberta' },
|
||||
'em_revisao' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Em Revisão' },
|
||||
'fechada' => { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Fechada' },
|
||||
'aprovada' => { bg: 'bg-[#f97316]/20', text: 'text-[#f97316]', label: 'Aprovada' },
|
||||
'cancelada' => { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Cancelada' }
|
||||
}
|
||||
c = config[status.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: status.to_s.humanize }
|
||||
content_tag(:span, c[:label],
|
||||
class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}")
|
||||
end
|
||||
|
||||
tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do
|
||||
"#{cfg[:icone]} #{cfg[:label]}"
|
||||
# Badge de role do usuário
|
||||
def badge_role(role)
|
||||
config = {
|
||||
'admin' => { bg: 'bg-purple-500/20', text: 'text-purple-400', label: 'Admin' },
|
||||
'gerente' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Gerente' },
|
||||
'operador' => { bg: 'bg-cyan-500/20', text: 'text-cyan-400', label: 'Operador' },
|
||||
'motorista'=> { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Motorista' }
|
||||
}
|
||||
c = config[role.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: role.to_s.humanize }
|
||||
content_tag(:span, c[:label],
|
||||
class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}")
|
||||
end
|
||||
|
||||
# Badge de status ativo/inativo do usuário
|
||||
def badge_status_usuario(ativo)
|
||||
if ativo
|
||||
content_tag(:span, 'Ativo',
|
||||
class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-400')
|
||||
else
|
||||
content_tag(:span, 'Inativo',
|
||||
class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-red-500/20 text-red-400')
|
||||
end
|
||||
end
|
||||
|
||||
# Barra de progresso laranja
|
||||
def progress_bar(percentual, label: nil)
|
||||
pct = percentual.to_i.clamp(0, 100)
|
||||
tag.div class: "w-full" do
|
||||
concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") {
|
||||
concat tag.span(label || "#{pct}% classificado")
|
||||
concat tag.span("#{pct}%")
|
||||
}
|
||||
concat tag.div(class: "w-full bg-[#2a2a2a] rounded-full h-2") {
|
||||
tag.div style: "width: #{pct}%",
|
||||
class: "h-2 rounded-full bg-orange-500 transition-all duration-500"
|
||||
}
|
||||
# Barra de progresso
|
||||
def progress_bar(percentual, color: '#f97316')
|
||||
perc = [[percentual.to_i, 0].max, 100].min
|
||||
content_tag(:div, class: 'w-full bg-white/10 rounded-full h-2') do
|
||||
content_tag(:div, '',
|
||||
class: 'h-2 rounded-full transition-all duration-500',
|
||||
style: "width: #{perc}%; background-color: #{color}")
|
||||
end
|
||||
end
|
||||
|
||||
# Opções de role para select
|
||||
def role_options_for_select
|
||||
User.roles.keys.map { |r| [r.humanize, r] }
|
||||
end
|
||||
|
||||
# Classe base para inputs
|
||||
def input_class
|
||||
'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white ' \
|
||||
'placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 ' \
|
||||
'focus:ring-[#f97316] transition-colors'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
# app/models/auditoria_log.rb
|
||||
class AuditoriaLog < ApplicationRecord
|
||||
belongs_to :user, optional: true # opcional caso user seja deletado
|
||||
belongs_to :user, optional: true
|
||||
|
||||
ACOES = %w[criar editar finalizar arquivar excluir login logout].freeze
|
||||
|
||||
validates :acao, presence: true
|
||||
validates :entidade, presence: true
|
||||
validates :acao, presence: true
|
||||
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :por_user, ->(user_id) { where(user_id: user_id) }
|
||||
scope :por_entidade, ->(entidade) { where(entidade: entidade) }
|
||||
scope :do_usuario, ->(user) { where(user: user) }
|
||||
|
||||
def self.registrar(user:, acao:, entidade:, entidade_id: nil,
|
||||
dados_anteriores: {}, dados_novos: {}, request: nil)
|
||||
# Cria um log de auditoria de forma simples
|
||||
# AuditoriaLog.registrar(current_user, 'criar_consolidacao', ip, detalhes: '...')
|
||||
def self.registrar(user, acao, ip = nil, detalhes: nil)
|
||||
create!(
|
||||
user_id: user&.id,
|
||||
acao: acao.to_s,
|
||||
entidade: entidade.to_s,
|
||||
entidade_id: entidade_id,
|
||||
dados_anteriores: dados_anteriores,
|
||||
dados_novos: dados_novos,
|
||||
ip_address: request&.remote_ip,
|
||||
user_agent: request&.user_agent
|
||||
user: user,
|
||||
acao: acao,
|
||||
ip: ip,
|
||||
detalhes: detalhes
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("AuditoriaLog falhou: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,55 +1,39 @@
|
||||
# app/models/configuracao.rb
|
||||
class Configuracao < ApplicationRecord
|
||||
# Chaves válidas do sistema
|
||||
CHAVES = %w[
|
||||
preco_entrega
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
notificacao_whatsapp
|
||||
notificacao_email
|
||||
empresa_nome
|
||||
empresa_logo
|
||||
].freeze
|
||||
TIPOS = %w[entrega retirada bonus desconto].freeze
|
||||
|
||||
CHAVES_MOEDA = %w[
|
||||
preco_entrega
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
].freeze
|
||||
enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 }
|
||||
|
||||
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
|
||||
validates :valor, presence: true
|
||||
validates :tipo, presence: true, uniqueness: true
|
||||
validates :valor, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
# ── Acesso rápido ───────────────────────────────────────────
|
||||
LABELS = {
|
||||
'entrega' => 'Entrega Normal',
|
||||
'retirada' => 'Retirada',
|
||||
'bonus' => 'Bônus',
|
||||
'desconto' => 'Desconto'
|
||||
}.freeze
|
||||
|
||||
def self.valor(chave)
|
||||
find_by(chave: chave)&.valor
|
||||
ICONES = {
|
||||
'entrega' => '🚚',
|
||||
'retirada' => '📦',
|
||||
'bonus' => '⭐',
|
||||
'desconto' => '🔻'
|
||||
}.freeze
|
||||
|
||||
def tipo_label
|
||||
LABELS[tipo] || tipo.humanize
|
||||
end
|
||||
|
||||
def self.preco_entrega
|
||||
valor('preco_entrega').to_f
|
||||
def icone
|
||||
ICONES[tipo] || '💰'
|
||||
end
|
||||
|
||||
def self.preco_retirada
|
||||
valor('preco_retirada').to_f
|
||||
end
|
||||
|
||||
def self.preco_bonus
|
||||
valor('preco_bonus').to_f
|
||||
end
|
||||
|
||||
def self.preco_desconto
|
||||
valor('preco_desconto').to_f
|
||||
end
|
||||
|
||||
def moeda?
|
||||
CHAVES_MOEDA.include?(chave)
|
||||
end
|
||||
|
||||
def valor_formatado
|
||||
return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda?
|
||||
valor
|
||||
# Retorna um hash { entrega: 10.50, retirada: 8.00, bonus: 5.00, desconto: 2.00 }
|
||||
def self.mapa_de_precos
|
||||
all.each_with_object({}) do |c, h|
|
||||
h[c.tipo.to_sym] = c.valor.to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,75 +1,34 @@
|
||||
# app/models/consolidacao.rb
|
||||
class Consolidacao < ApplicationRecord
|
||||
belongs_to :criador, class_name: 'User', foreign_key: :created_by
|
||||
belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true
|
||||
# Soft delete
|
||||
acts_as_paranoid if respond_to?(:acts_as_paranoid)
|
||||
|
||||
enum status: { aberta: 0, em_revisao: 1, fechada: 2, aprovada: 3, cancelada: 4 }
|
||||
|
||||
belongs_to :criado_por, class_name: 'User', foreign_key: :user_id
|
||||
has_many :consolidacao_motoristas, dependent: :destroy
|
||||
has_many :consolidacao_entregas, dependent: :destroy
|
||||
has_many :consolidacao_entregas, through: :consolidacao_motoristas
|
||||
has_many :motoristas, through: :consolidacao_motoristas, source: :user
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────
|
||||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||
validates :referencia_mes, presence: true
|
||||
validates :status, presence: true
|
||||
|
||||
# ── Soft delete ─────────────────────────────────────────────
|
||||
scope :ativas, -> { where(deleted_at: nil) }
|
||||
scope :arquivadas, -> { where.not(deleted_at: nil) }
|
||||
scope :do_mes, ->(data = Date.today) {
|
||||
where(referencia_mes: data.beginning_of_month..data.end_of_month)
|
||||
}
|
||||
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
validates :nome, presence: true
|
||||
validates :data_inicio, presence: true
|
||||
validates :data_fim, presence: true
|
||||
validates :created_by, presence: true
|
||||
validate :periodo_valido
|
||||
scope :abertas, -> { where(status: :aberta) }
|
||||
scope :fechadas, -> { where(status: [:fechada, :aprovada]) }
|
||||
|
||||
# ── Callbacks ───────────────────────────────────────────────
|
||||
before_save :recalcular_valor_total
|
||||
def progresso_percentual
|
||||
return 0 if consolidacao_motoristas.none?
|
||||
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) }
|
||||
|
||||
# ── Métodos ─────────────────────────────────────────────────
|
||||
|
||||
def arquivar!(user)
|
||||
update!(deleted_at: Time.current, status: :arquivada)
|
||||
finalizados = consolidacao_motoristas.where(finalizado: true).count
|
||||
total = consolidacao_motoristas.count
|
||||
((finalizados.to_f / total) * 100).round
|
||||
end
|
||||
|
||||
def finalizar!(user)
|
||||
return false unless todas_entregas_classificadas?
|
||||
|
||||
update!(
|
||||
status: :finalizada,
|
||||
finalizado_por: user.id,
|
||||
finalizado_em: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def todas_entregas_classificadas?
|
||||
total_entregas = ConsolidacaoMotorista
|
||||
.where(consolidacao_id: id)
|
||||
.sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) }
|
||||
|
||||
consolidacao_entregas.count >= total_entregas
|
||||
end
|
||||
|
||||
def percentual_classificado
|
||||
return 0 if total_entregas_estimadas.zero?
|
||||
((consolidacao_entregas.count.to_f / total_entregas_estimadas) * 100).round
|
||||
end
|
||||
|
||||
def total_entregas_estimadas
|
||||
@total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm|
|
||||
Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def periodo_valido
|
||||
return unless data_inicio && data_fim
|
||||
errors.add(:data_fim, 'deve ser após a data de início') if data_fim < data_inicio
|
||||
end
|
||||
|
||||
def recalcular_valor_total
|
||||
self.valor_total = consolidacao_motoristas.sum(:valor_total)
|
||||
def mes_label
|
||||
referencia_mes&.strftime('%B/%Y')&.capitalize
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,88 +1,39 @@
|
||||
# app/models/entrega.rb
|
||||
#
|
||||
# Model de LEITURA para a tabela existente da Gade Hospitalar.
|
||||
# NUNCA criar migration para esta tabela.
|
||||
# NUNCA executar INSERT, UPDATE, DELETE ou DROP nesta tabela.
|
||||
#
|
||||
# ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026
|
||||
# NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa tabela.
|
||||
class Entrega < ApplicationRecord
|
||||
self.table_name = 'db_reem_simplerout_2026'
|
||||
self.table_name = 'db_reem_simplerout_2026'
|
||||
self.primary_key = 'tracking_id'
|
||||
|
||||
# Apenas leitura — segurança contra mutações acidentais
|
||||
# Apenas leitura
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
|
||||
# ── Scopes ──────────────────────────────────────────────────
|
||||
scope :concluidas, -> { where(status: 'completed') }
|
||||
scope :com_checkin, -> { where.not(checkin: nil) }
|
||||
scope :pagas, -> { concluidas.com_checkin }
|
||||
scope :pendentes, -> { where.not(status: 'completed') }
|
||||
# Scopes
|
||||
scope :do_account, -> { where(account_id: 95907) }
|
||||
scope :pagas, -> { where(status: 'completed').where.not(checkin: nil) }
|
||||
scope :pendentes, -> { where.not(status: 'completed').or(where(checkin: nil)) }
|
||||
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(planned_date: inicio.to_date..fim.to_date)
|
||||
scope :do_mes, ->(data = Date.today) {
|
||||
do_account
|
||||
.where(planned_date: data.beginning_of_month..data.end_of_month)
|
||||
}
|
||||
|
||||
scope :do_dia, ->(data = Date.today) {
|
||||
do_account.where(planned_date: data)
|
||||
}
|
||||
|
||||
scope :do_motorista, ->(nome) {
|
||||
where(driver: nome)
|
||||
where('LOWER(driver) = ?', nome.to_s.downcase)
|
||||
}
|
||||
|
||||
scope :da_rota, ->(route_id) {
|
||||
where(route_id: route_id)
|
||||
}
|
||||
|
||||
scope :da_conta_gade, -> {
|
||||
where(account_id: ENV.fetch('DB_EXISTING_ACCOUNT_ID', 95907).to_i)
|
||||
}
|
||||
|
||||
# ── Métodos de classe ────────────────────────────────────────
|
||||
|
||||
# Lista motoristas únicos (para selects, consolidações)
|
||||
def self.motoristas_ativos(inicio: nil, fim: nil)
|
||||
base = da_conta_gade
|
||||
base = base.no_periodo(inicio, fim) if inicio && fim
|
||||
base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?)
|
||||
# Helpers de instância
|
||||
def paga?
|
||||
status == 'completed' && checkin.present?
|
||||
end
|
||||
|
||||
# Lista rotas únicas (route_id → descrição)
|
||||
def self.rotas_unicas(inicio: nil, fim: nil)
|
||||
base = da_conta_gade
|
||||
base = base.no_periodo(inicio, fim) if inicio && fim
|
||||
base.distinct.pluck(:route_id).compact
|
||||
end
|
||||
|
||||
# Contagem de entregas pagas para cálculo estimado
|
||||
def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil)
|
||||
base = pagas.da_conta_gade.no_periodo(inicio, fim)
|
||||
base = base.do_motorista(motorista) if motorista.present?
|
||||
base = base.da_rota(route_id) if route_id.present?
|
||||
base.count
|
||||
end
|
||||
|
||||
# ── Helpers de instância ─────────────────────────────────────
|
||||
|
||||
def numero_nf
|
||||
reference_id
|
||||
end
|
||||
|
||||
def local
|
||||
contact_name.presence || address
|
||||
end
|
||||
|
||||
def concluida?
|
||||
status == 'completed'
|
||||
end
|
||||
|
||||
def checkin_registrado?
|
||||
checkin.present?
|
||||
end
|
||||
|
||||
def elegivel_pagamento?
|
||||
concluida? && checkin_registrado?
|
||||
end
|
||||
|
||||
def atraso_minutos
|
||||
return 0 unless delay.present?
|
||||
delay.to_s.split(':').then { |h, m, _s| h.to_i * 60 + m.to_i }
|
||||
def data_formatada
|
||||
planned_date&.strftime('%d/%m/%Y')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,69 +1,59 @@
|
||||
# app/models/user.rb
|
||||
class User < ApplicationRecord
|
||||
devise :database_authenticatable,
|
||||
:registerable,
|
||||
:recoverable,
|
||||
:rememberable,
|
||||
:validatable
|
||||
# Devise modules
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable,
|
||||
:trackable, :lockable
|
||||
|
||||
# ── Roles ──────────────────────────────────────────────────
|
||||
# Roles
|
||||
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
|
||||
|
||||
ROLES_LABEL = {
|
||||
'admin' => 'Administrador',
|
||||
'gerente' => 'Gerente',
|
||||
'operador' => 'Operador',
|
||||
'motorista' => 'Motorista'
|
||||
}.freeze
|
||||
# Validações
|
||||
validates :name, presence: true
|
||||
validates :role, presence: true
|
||||
validates :pin_acesso,
|
||||
uniqueness: { allow_blank: true },
|
||||
length: { is: 4, allow_blank: true },
|
||||
format: { with: /\A\d{4}\z/, allow_blank: true, message: 'deve ter exatamente 4 dígitos numéricos' }
|
||||
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
validates :nome, presence: true, length: { minimum: 3 }
|
||||
validates :role, presence: true
|
||||
validates :pin_code,
|
||||
length: { is: 4 },
|
||||
numericality: { only_integer: true },
|
||||
allow_nil: true,
|
||||
if: :motorista?
|
||||
validate :pin_obrigatorio_para_motorista
|
||||
|
||||
validate :pin_unico_para_motoristas, if: :motorista?
|
||||
# Callbacks
|
||||
before_validation :normalizar_pin
|
||||
|
||||
# Motoristas podem não ter e-mail (login via PIN)
|
||||
def email_required?
|
||||
!motorista?
|
||||
end
|
||||
# Associações
|
||||
has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify
|
||||
has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error
|
||||
|
||||
def password_required?
|
||||
!motorista? ? super : false
|
||||
end
|
||||
# Scopes
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :motoristas, -> { where(role: :motorista) }
|
||||
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :por_nome, -> { order(:nome) }
|
||||
scope :nao_motoristas, -> { where.not(role: :motorista) }
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
def nome_display
|
||||
nome.presence || email
|
||||
name.split.first.capitalize
|
||||
end
|
||||
|
||||
def role_label
|
||||
ROLES_LABEL[role] || role.humanize
|
||||
def active?
|
||||
ativo?
|
||||
end
|
||||
|
||||
def pode_ver_config?
|
||||
def pode_acessar_admin?
|
||||
admin? || gerente?
|
||||
end
|
||||
|
||||
def pode_consolidar?
|
||||
def pode_criar_consolidacao?
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pin_unico_para_motoristas
|
||||
return if pin_code.blank?
|
||||
def normalizar_pin
|
||||
self.pin_acesso = pin_acesso.to_s.strip.presence
|
||||
end
|
||||
|
||||
conflito = User.motorista.where(pin_code: pin_code).where.not(id: id)
|
||||
errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists?
|
||||
def pin_obrigatorio_para_motorista
|
||||
if motorista? && pin_acesso.blank?
|
||||
errors.add(:pin_acesso, 'é obrigatório para motoristas')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,15 +3,44 @@ class ApplicationPolicy
|
||||
attr_reader :user, :record
|
||||
|
||||
def initialize(user, record)
|
||||
raise Pundit::NotAuthorizedError, 'Você precisa estar logado.' unless user
|
||||
@user = user
|
||||
@record = record
|
||||
end
|
||||
|
||||
def index? = user.pode_consolidar? || user.admin?
|
||||
def show? = user.pode_consolidar? || user.admin?
|
||||
def create? = user.pode_consolidar? || user.admin?
|
||||
def update? = user.pode_consolidar? || user.admin?
|
||||
def destroy? = user.admin?
|
||||
def index? = false
|
||||
def show? = false
|
||||
def create? = false
|
||||
def new? = create?
|
||||
def update? = false
|
||||
def edit? = update?
|
||||
def destroy? = false
|
||||
|
||||
protected
|
||||
|
||||
def admin?
|
||||
user.admin?
|
||||
end
|
||||
|
||||
def gerente?
|
||||
user.gerente?
|
||||
end
|
||||
|
||||
def operador?
|
||||
user.operador?
|
||||
end
|
||||
|
||||
def motorista?
|
||||
user.motorista?
|
||||
end
|
||||
|
||||
def admin_ou_gerente?
|
||||
admin? || gerente?
|
||||
end
|
||||
|
||||
def admin_gerente_ou_operador?
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
class Scope
|
||||
def initialize(user, scope)
|
||||
@@ -20,7 +49,7 @@ class ApplicationPolicy
|
||||
end
|
||||
|
||||
def resolve
|
||||
@scope.all
|
||||
raise NotImplementedError, "#{self.class}#resolve não implementado"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
20
app/policies/configuracao_policy.rb
Normal file
20
app/policies/configuracao_policy.rb
Normal 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
|
||||
46
app/policies/user_policy.rb
Normal file
46
app/policies/user_policy.rb
Normal 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
|
||||
58
app/views/configuracoes/edit.html.erb
Normal file
58
app/views/configuracoes/edit.html.erb
Normal 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>
|
||||
64
app/views/configuracoes/index.html.erb
Normal file
64
app/views/configuracoes/index.html.erb
Normal 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>
|
||||
234
app/views/dashboard/index.html.erb
Normal file
234
app/views/dashboard/index.html.erb
Normal 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>
|
||||
231
app/views/devise/sessions/new.html.erb
Normal file
231
app/views/devise/sessions/new.html.erb
Normal 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>
|
||||
64
app/views/layouts/_sidebar.html.erb
Normal file
64
app/views/layouts/_sidebar.html.erb
Normal 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>
|
||||
@@ -1,95 +1,169 @@
|
||||
<%# app/views/layouts/application.html.erb %>
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="dark">
|
||||
<html lang="pt-BR" class="<%= @tema == 'light' ? '' : 'dark' %>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 %>">
|
||||
<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 %>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<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">
|
||||
<%= stylesheet_link_tag 'application', data: { turbo_track: 'reload' } %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||
/* Loading spinner laranja */
|
||||
.spinner {
|
||||
border: 3px solid #1a1a1a;
|
||||
border-top-color: #f97316;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
:root {
|
||||
--bg-main: #0a0a0a;
|
||||
--bg-card: #1a1a1a;
|
||||
--accent: #f97316;
|
||||
--text-main: #ffffff;
|
||||
--text-muted:#9ca3af;
|
||||
--border: rgba(255,255,255,0.05);
|
||||
}
|
||||
@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>
|
||||
|
||||
<%= yield :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? %>
|
||||
<%= render 'layouts/navbar' %>
|
||||
<% end %>
|
||||
<% if user_signed_in? && !current_user.motorista? %>
|
||||
<%# Layout principal com sidebar %>
|
||||
<div class="flex min-h-screen">
|
||||
|
||||
<%# ── Flash Messages ─────────────────────────────────── %>
|
||||
<% if notice.present? %>
|
||||
<div id="flash-notice"
|
||||
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"
|
||||
data-controller="flash">
|
||||
<span>✅</span> <%= notice %>
|
||||
<%# Sidebar Desktop %>
|
||||
<aside id="sidebar"
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 flex-col hidden lg:flex
|
||||
border-r border-white/5"
|
||||
style="background-color: var(--bg-card)">
|
||||
<%= 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>
|
||||
|
||||
<%# 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 %>
|
||||
|
||||
<%# Avatar / menu usuário %>
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-white/10 transition-colors">
|
||||
<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">
|
||||
<%= 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>
|
||||
|
||||
<%# Logout %>
|
||||
<%= button_to destroy_user_session_path, method: :delete,
|
||||
class: 'px-3 py-2 text-gray-500 hover:text-red-400 text-sm
|
||||
hover:bg-red-900/20 rounded-lg transition-colors cursor-pointer' do %>
|
||||
Sair
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<%# Conteúdo principal %>
|
||||
<main class="flex-1 p-4 lg:p-6">
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if alert.present? %>
|
||||
<div id="flash-alert"
|
||||
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"
|
||||
data-controller="flash">
|
||||
<span>⚠️</span> <%= alert %>
|
||||
<% 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>
|
||||
<% end %>
|
||||
|
||||
<%# ── Conteúdo principal ──────────────────────────────── %>
|
||||
<main class="<%= user_signed_in? ? 'ml-0 md:ml-64 p-4 md:p-8' : '' %> min-h-screen">
|
||||
<% else %>
|
||||
<%# Layout sem sidebar (login, etc) %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<% end %>
|
||||
|
||||
<%# Auto-hide flash após 4s (JS simples) %>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
['flash-notice', 'flash-alert'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||
setTimeout(() => el.remove(), 500);
|
||||
});
|
||||
}, 4000);
|
||||
</script>
|
||||
function toggleSidebar() {
|
||||
const mobile = document.getElementById('sidebar-mobile');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const isOpen = !mobile.classList.contains('-translate-x-full');
|
||||
|
||||
if (isOpen) {
|
||||
mobile.classList.add('-translate-x-full');
|
||||
overlay.classList.add('hidden');
|
||||
} else {
|
||||
mobile.classList.remove('-translate-x-full');
|
||||
overlay.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fecha sidebar ao pressionar Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const mobile = document.getElementById('sidebar-mobile');
|
||||
if (mobile && !mobile.classList.contains('-translate-x-full')) toggleSidebar();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
96
app/views/motorista/index.html.erb
Normal file
96
app/views/motorista/index.html.erb
Normal 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>
|
||||
18
app/views/shared/_flash.html.erb
Normal file
18
app/views/shared/_flash.html.erb
Normal 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 %>
|
||||
153
app/views/usuarios/_form.html.erb
Normal file
153
app/views/usuarios/_form.html.erb
Normal 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>
|
||||
2
app/views/usuarios/edit.html.erb
Normal file
2
app/views/usuarios/edit.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<%# app/views/usuarios/edit.html.erb %>
|
||||
<%= render 'form', usuario: @usuario %>
|
||||
120
app/views/usuarios/index.html.erb
Normal file
120
app/views/usuarios/index.html.erb
Normal 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>
|
||||
2
app/views/usuarios/new.html.erb
Normal file
2
app/views/usuarios/new.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<%# app/views/usuarios/new.html.erb %>
|
||||
<%= render 'form', usuario: @usuario %>
|
||||
Reference in New Issue
Block a user