Initial commit - Fase 1: Setup Rails + Docker
This commit is contained in:
27
app/controllers/application_controller.rb
Normal file
27
app/controllers/application_controller.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# app/controllers/application_controller.rb
|
||||
class ApplicationController < ActionController::Base
|
||||
include Pundit::Authorization
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_tema
|
||||
|
||||
# Pundit: redireciona se não autorizado
|
||||
rescue_from Pundit::NotAuthorizedError do |e|
|
||||
flash[:alert] = "Você não tem permissão para realizar esta ação."
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tema
|
||||
@tema = current_user&.tema_preferido || 'dark'
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
if resource.motorista?
|
||||
motorista_dashboard_path
|
||||
else
|
||||
dashboard_path
|
||||
end
|
||||
end
|
||||
end
|
||||
46
app/helpers/application_helper.rb
Normal file
46
app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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
|
||||
def moeda(valor)
|
||||
"R$ #{"%.2f" % valor.to_f}".gsub('.', ',')
|
||||
end
|
||||
|
||||
# Badge de 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
|
||||
|
||||
tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do
|
||||
"#{cfg[:icone]} #{cfg[:label]}"
|
||||
end
|
||||
end
|
||||
|
||||
# Barra de progresso laranja
|
||||
def progress_bar(percentual, label: nil)
|
||||
pct = percentual.to_i.clamp(0, 100)
|
||||
tag.div class: "w-full" do
|
||||
concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") {
|
||||
concat tag.span(label || "#{pct}% classificado")
|
||||
concat tag.span("#{pct}%")
|
||||
}
|
||||
concat tag.div(class: "w-full bg-[#2a2a2a] rounded-full h-2") {
|
||||
tag.div style: "width: #{pct}%",
|
||||
class: "h-2 rounded-full bg-orange-500 transition-all duration-500"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/models/auditoria_log.rb
Normal file
29
app/models/auditoria_log.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# app/models/auditoria_log.rb
|
||||
class AuditoriaLog < ApplicationRecord
|
||||
belongs_to :user, optional: true # opcional caso user seja deletado
|
||||
|
||||
ACOES = %w[criar editar finalizar arquivar excluir login logout].freeze
|
||||
|
||||
validates :acao, presence: true
|
||||
validates :entidade, presence: true
|
||||
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :por_user, ->(user_id) { where(user_id: user_id) }
|
||||
scope :por_entidade, ->(entidade) { where(entidade: entidade) }
|
||||
|
||||
def self.registrar(user:, acao:, entidade:, entidade_id: nil,
|
||||
dados_anteriores: {}, dados_novos: {}, request: 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
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}")
|
||||
end
|
||||
end
|
||||
55
app/models/configuracao.rb
Normal file
55
app/models/configuracao.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
|
||||
CHAVES_MOEDA = %w[
|
||||
preco_entrega
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
].freeze
|
||||
|
||||
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
|
||||
validates :valor, presence: true
|
||||
|
||||
# ── Acesso rápido ───────────────────────────────────────────
|
||||
|
||||
def self.valor(chave)
|
||||
find_by(chave: chave)&.valor
|
||||
end
|
||||
|
||||
def self.preco_entrega
|
||||
valor('preco_entrega').to_f
|
||||
end
|
||||
|
||||
def self.preco_retirada
|
||||
valor('preco_retirada').to_f
|
||||
end
|
||||
|
||||
def self.preco_bonus
|
||||
valor('preco_bonus').to_f
|
||||
end
|
||||
|
||||
def self.preco_desconto
|
||||
valor('preco_desconto').to_f
|
||||
end
|
||||
|
||||
def moeda?
|
||||
CHAVES_MOEDA.include?(chave)
|
||||
end
|
||||
|
||||
def valor_formatado
|
||||
return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda?
|
||||
valor
|
||||
end
|
||||
end
|
||||
75
app/models/consolidacao.rb
Normal file
75
app/models/consolidacao.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# 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
|
||||
|
||||
has_many :consolidacao_motoristas, dependent: :destroy
|
||||
has_many :consolidacao_entregas, dependent: :destroy
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────
|
||||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||
|
||||
# ── Soft delete ─────────────────────────────────────────────
|
||||
scope :ativas, -> { where(deleted_at: nil) }
|
||||
scope :arquivadas, -> { where.not(deleted_at: nil) }
|
||||
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
validates :nome, presence: true
|
||||
validates :data_inicio, presence: true
|
||||
validates :data_fim, presence: true
|
||||
validates :created_by, presence: true
|
||||
validate :periodo_valido
|
||||
|
||||
# ── Callbacks ───────────────────────────────────────────────
|
||||
before_save :recalcular_valor_total
|
||||
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) }
|
||||
|
||||
# ── Métodos ─────────────────────────────────────────────────
|
||||
|
||||
def arquivar!(user)
|
||||
update!(deleted_at: Time.current, status: :arquivada)
|
||||
end
|
||||
|
||||
def finalizar!(user)
|
||||
return false unless todas_entregas_classificadas?
|
||||
|
||||
update!(
|
||||
status: :finalizada,
|
||||
finalizado_por: user.id,
|
||||
finalizado_em: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def todas_entregas_classificadas?
|
||||
total_entregas = ConsolidacaoMotorista
|
||||
.where(consolidacao_id: id)
|
||||
.sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) }
|
||||
|
||||
consolidacao_entregas.count >= total_entregas
|
||||
end
|
||||
|
||||
def percentual_classificado
|
||||
return 0 if total_entregas_estimadas.zero?
|
||||
((consolidacao_entregas.count.to_f / total_entregas_estimadas) * 100).round
|
||||
end
|
||||
|
||||
def total_entregas_estimadas
|
||||
@total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm|
|
||||
Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def periodo_valido
|
||||
return unless data_inicio && data_fim
|
||||
errors.add(:data_fim, 'deve ser após a data de início') if data_fim < data_inicio
|
||||
end
|
||||
|
||||
def recalcular_valor_total
|
||||
self.valor_total = consolidacao_motoristas.sum(:valor_total)
|
||||
end
|
||||
end
|
||||
32
app/models/consolidacao_entrega.rb
Normal file
32
app/models/consolidacao_entrega.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# app/models/consolidacao_entrega.rb
|
||||
class ConsolidacaoEntrega < ApplicationRecord
|
||||
belongs_to :consolidacao
|
||||
|
||||
enum tipo: {
|
||||
entrega_normal: 0,
|
||||
retirada: 1,
|
||||
bonus: 2,
|
||||
desconto: 3
|
||||
}
|
||||
|
||||
TIPO_CORES = {
|
||||
'entrega_normal' => { bg: 'bg-orange-500', text: 'text-white', label: 'Entrega Normal' },
|
||||
'retirada' => { bg: 'bg-orange-800', text: 'text-white', label: 'Retirada' },
|
||||
'bonus' => { bg: 'bg-white border border-orange-500', text: 'text-black', label: 'Bônus' },
|
||||
'desconto' => { bg: 'bg-gray-900', text: 'text-white', label: 'Desconto' }
|
||||
}.freeze
|
||||
|
||||
validates :tracking_id, presence: true, uniqueness: { scope: :consolidacao_id }
|
||||
validates :motorista_nome, presence: true
|
||||
validates :tipo, presence: true
|
||||
validates :valor_aplicado, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
# Busca a entrega original (leitura do banco existente)
|
||||
def entrega_original
|
||||
@entrega_original ||= Entrega.find_by(tracking_id: tracking_id)
|
||||
end
|
||||
|
||||
def tipo_cor
|
||||
TIPO_CORES[tipo] || TIPO_CORES['entrega_normal']
|
||||
end
|
||||
end
|
||||
29
app/models/consolidacao_motorista.rb
Normal file
29
app/models/consolidacao_motorista.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# app/models/consolidacao_motorista.rb
|
||||
class ConsolidacaoMotorista < ApplicationRecord
|
||||
belongs_to :consolidacao
|
||||
has_many :consolidacao_entregas,
|
||||
->(cm) { where(motorista_nome: cm.motorista_nome) },
|
||||
foreign_key: :consolidacao_id,
|
||||
primary_key: :consolidacao_id
|
||||
|
||||
validates :motorista_nome, presence: true
|
||||
validates :motorista_nome, uniqueness: { scope: :consolidacao_id }
|
||||
|
||||
before_save :recalcular_valor
|
||||
|
||||
def recalcular_valor
|
||||
cfg = Configuracao
|
||||
self.valor_total = consolidacao_entregas
|
||||
.includes(:consolidacao)
|
||||
.where(motorista_nome: motorista_nome)
|
||||
.sum do |e|
|
||||
case e.tipo
|
||||
when 'entrega_normal' then cfg.preco_entrega
|
||||
when 'retirada' then cfg.preco_retirada
|
||||
when 'bonus' then cfg.preco_bonus
|
||||
when 'desconto' then -cfg.preco_desconto
|
||||
else 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
88
app/models/entrega.rb
Normal file
88
app/models/entrega.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
#
|
||||
class Entrega < ApplicationRecord
|
||||
self.table_name = 'db_reem_simplerout_2026'
|
||||
self.primary_key = 'tracking_id'
|
||||
|
||||
# Apenas leitura — segurança contra mutações acidentais
|
||||
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') }
|
||||
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(planned_date: inicio.to_date..fim.to_date)
|
||||
}
|
||||
|
||||
scope :do_motorista, ->(nome) {
|
||||
where(driver: nome)
|
||||
}
|
||||
|
||||
scope :da_rota, ->(route_id) {
|
||||
where(route_id: route_id)
|
||||
}
|
||||
|
||||
scope :da_conta_gade, -> {
|
||||
where(account_id: ENV.fetch('DB_EXISTING_ACCOUNT_ID', 95907).to_i)
|
||||
}
|
||||
|
||||
# ── Métodos de classe ────────────────────────────────────────
|
||||
|
||||
# Lista motoristas únicos (para selects, consolidações)
|
||||
def self.motoristas_ativos(inicio: nil, fim: nil)
|
||||
base = da_conta_gade
|
||||
base = base.no_periodo(inicio, fim) if inicio && fim
|
||||
base.distinct.order(:driver).pluck(:driver).compact.reject(&:empty?)
|
||||
end
|
||||
|
||||
# Lista rotas únicas (route_id → descrição)
|
||||
def self.rotas_unicas(inicio: nil, fim: nil)
|
||||
base = da_conta_gade
|
||||
base = base.no_periodo(inicio, fim) if inicio && fim
|
||||
base.distinct.pluck(:route_id).compact
|
||||
end
|
||||
|
||||
# Contagem de entregas pagas para cálculo estimado
|
||||
def self.contar_pagas(inicio:, fim:, motorista: nil, route_id: nil)
|
||||
base = pagas.da_conta_gade.no_periodo(inicio, fim)
|
||||
base = base.do_motorista(motorista) if motorista.present?
|
||||
base = base.da_rota(route_id) if route_id.present?
|
||||
base.count
|
||||
end
|
||||
|
||||
# ── Helpers de instância ─────────────────────────────────────
|
||||
|
||||
def numero_nf
|
||||
reference_id
|
||||
end
|
||||
|
||||
def local
|
||||
contact_name.presence || address
|
||||
end
|
||||
|
||||
def concluida?
|
||||
status == 'completed'
|
||||
end
|
||||
|
||||
def checkin_registrado?
|
||||
checkin.present?
|
||||
end
|
||||
|
||||
def elegivel_pagamento?
|
||||
concluida? && checkin_registrado?
|
||||
end
|
||||
|
||||
def atraso_minutos
|
||||
return 0 unless delay.present?
|
||||
delay.to_s.split(':').then { |h, m, _s| h.to_i * 60 + m.to_i }
|
||||
end
|
||||
end
|
||||
32
app/models/historico_estimado.rb
Normal file
32
app/models/historico_estimado.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# app/models/historico_estimado.rb
|
||||
class HistoricoEstimado < ApplicationRecord
|
||||
validates :data_hora, presence: true
|
||||
validates :valor_total_estimado, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :entregas_contadas, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
|
||||
scope :recentes, -> { order(data_hora: :desc) }
|
||||
scope :do_dia, ->(data = Date.current) { where(data_hora: data.all_day) }
|
||||
|
||||
# Chamado pelo job a cada 1 hora (Whenever gem — Fase 4)
|
||||
def self.atualizar!
|
||||
preco = Configuracao.preco_entrega
|
||||
entregas = Entrega.pagas.where(planned_date: Date.current).count
|
||||
total = entregas * preco
|
||||
|
||||
create!(
|
||||
data_hora: Time.current,
|
||||
valor_total_estimado: total,
|
||||
entregas_contadas: entregas
|
||||
)
|
||||
end
|
||||
|
||||
# Cálculo ao vivo para o Dashboard (sem esperar o job)
|
||||
def self.calcular_ao_vivo(inicio: Date.current.beginning_of_month, fim: Date.current)
|
||||
preco = Configuracao.preco_entrega
|
||||
entregas = Entrega.pagas.where(planned_date: inicio..fim).count
|
||||
{
|
||||
valor: entregas * preco,
|
||||
entregas: entregas
|
||||
}
|
||||
end
|
||||
end
|
||||
69
app/models/user.rb
Normal file
69
app/models/user.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
# app/models/user.rb
|
||||
class User < ApplicationRecord
|
||||
devise :database_authenticatable,
|
||||
:registerable,
|
||||
:recoverable,
|
||||
:rememberable,
|
||||
:validatable
|
||||
|
||||
# ── 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 :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_unico_para_motoristas, if: :motorista?
|
||||
|
||||
# Motoristas podem não ter e-mail (login via PIN)
|
||||
def email_required?
|
||||
!motorista?
|
||||
end
|
||||
|
||||
def password_required?
|
||||
!motorista? ? super : false
|
||||
end
|
||||
|
||||
# ── Escopos ─────────────────────────────────────────────────
|
||||
scope :ativos, -> { where(ativo: true) }
|
||||
scope :por_nome, -> { order(:nome) }
|
||||
scope :nao_motoristas, -> { where.not(role: :motorista) }
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────
|
||||
def nome_display
|
||||
nome.presence || email
|
||||
end
|
||||
|
||||
def role_label
|
||||
ROLES_LABEL[role] || role.humanize
|
||||
end
|
||||
|
||||
def pode_ver_config?
|
||||
admin? || gerente?
|
||||
end
|
||||
|
||||
def pode_consolidar?
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pin_unico_para_motoristas
|
||||
return if pin_code.blank?
|
||||
|
||||
conflito = User.motorista.where(pin_code: pin_code).where.not(id: id)
|
||||
errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists?
|
||||
end
|
||||
end
|
||||
30
app/policies/application_policy.rb
Normal file
30
app/policies/application_policy.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# app/policies/application_policy.rb
|
||||
class ApplicationPolicy
|
||||
attr_reader :user, :record
|
||||
|
||||
def initialize(user, record)
|
||||
@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?
|
||||
|
||||
class Scope
|
||||
def initialize(user, scope)
|
||||
@user = user
|
||||
@scope = scope
|
||||
end
|
||||
|
||||
def resolve
|
||||
@scope.all
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :scope
|
||||
end
|
||||
end
|
||||
92
app/views/layouts/_navbar.html.erb
Normal file
92
app/views/layouts/_navbar.html.erb
Normal file
@@ -0,0 +1,92 @@
|
||||
<%# app/views/layouts/_navbar.html.erb %>
|
||||
<%# Sidebar lateral — responsiva (esconde no mobile, hamburguer abre) %>
|
||||
|
||||
<%# Overlay mobile %>
|
||||
<div id="sidebar-overlay"
|
||||
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
|
||||
onclick="toggleSidebar()"></div>
|
||||
|
||||
<%# Sidebar %>
|
||||
<aside id="sidebar"
|
||||
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<%# Logo / Marca %>
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Gade Hospitalar</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Usuário logado %>
|
||||
<div class="px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<p class="text-xs text-gray-500 mb-1">Logado como</p>
|
||||
<p class="text-white font-semibold text-sm truncate"><%= current_user.nome_display %></p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
|
||||
<%= current_user.admin? ? 'bg-orange-500 text-black' :
|
||||
current_user.gerente? ? 'bg-orange-800 text-white' :
|
||||
current_user.operador? ? 'bg-gray-700 text-white' :
|
||||
'bg-gray-900 text-orange-400 border border-orange-500' %>">
|
||||
<%= current_user.role_label %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<%# Navegação %>
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<%# Dashboard — admin/gerente/operador %>
|
||||
<% unless current_user.motorista? %>
|
||||
<%= nav_link_to '📊 Dashboard', dashboard_path %>
|
||||
<%= nav_link_to '📦 Consolidações', consolidacoes_path %>
|
||||
<% end %>
|
||||
|
||||
<%# Motorista %>
|
||||
<% if current_user.motorista? %>
|
||||
<%= nav_link_to '🏠 Meu Painel', motorista_dashboard_path %>
|
||||
<% end %>
|
||||
|
||||
<%# Admin/Gerente — Configurações %>
|
||||
<% if current_user.pode_ver_config? %>
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
<%= nav_link_to '👥 Usuários', admin_usuarios_path %>
|
||||
<%= nav_link_to '⚙️ Configurações', admin_configuracoes_path %>
|
||||
<%= nav_link_to '🔍 Auditoria', admin_auditoria_logs_path %>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<%# Footer da sidebar — sair %>
|
||||
<div class="px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<%= link_to destroy_user_session_path, method: :delete,
|
||||
class: "flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" do %>
|
||||
<span>🚪</span> Sair
|
||||
<% end %>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<%# Topbar mobile %>
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Gade Logística</span>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
<%# Espaço para topbar no mobile %>
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
95
app/views/layouts/application.html.erb
Normal file
95
app/views/layouts/application.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="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>
|
||||
|
||||
<%# 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">
|
||||
|
||||
<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;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
|
||||
<%# ── Navbar ─────────────────────────────────────────── %>
|
||||
<% if user_signed_in? %>
|
||||
<%= render 'layouts/navbar' %>
|
||||
<% end %>
|
||||
|
||||
<%# ── 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 %>
|
||||
</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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# ── Conteúdo principal ──────────────────────────────── %>
|
||||
<main class="<%= user_signed_in? ? 'ml-0 md:ml-64 p-4 md:p-8' : '' %> min-h-screen">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
<%# 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>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user