Initial commit - Fase 1: Setup Rails + Docker

This commit is contained in:
2026-06-10 17:40:07 -03:00
commit eb812868e5
30 changed files with 1390 additions and 0 deletions

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# ============================================================
# .env.example — COPIE PARA .env E PREENCHA OS VALORES REAIS
# NUNCA versione o arquivo .env no Git!
# ============================================================
# Ambiente Rails
RAILS_ENV=development
# Banco de dados principal (novo sistema)
DATABASE_URL=postgresql://postgres:senha_segura@db:5432/logistica_db
DB_HOST=db
DB_PORT=5432
DB_NAME=logistica_db
DB_USER=postgres
DB_PASSWORD=senha_segura
# Banco de dados existente (Gade Hospitalar — APENAS LEITURA)
# Se estiver em servidor separado, altere DB_HOST acima para o IP correto
DB_EXISTING_TABLE=db_reem_simplerout_2026
DB_EXISTING_SCHEMA=public
DB_EXISTING_ACCOUNT_ID=95907
# Rails Security — gere com: bundle exec rails secret
SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui
# Devise — gere com: bundle exec rails secret
DEVISE_SECRET_KEY=gere_com_rails_secret_e_cole_aqui
# Twilio — WhatsApp (opcional, Fase 8)
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886
# E-mail (opcional, Fase 8)
SMTP_ADDRESS=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_DOMAIN=gade.com.br
# App
APP_HOST=localhost:3000
APP_NAME=Gade Logística

61
.gitignore vendored Normal file
View File

@@ -0,0 +1,61 @@
# ============================
# CREDENCIAIS — NUNCA versionar
# ============================
.env
.env.local
.env.*.local
config/application.yml
config/database.yml
docker-compose.override.yml
*.pem
*.key
*.p12
config/master.key
config/credentials/*.key
# Rails
/log/*
/tmp/*
!/log/.keep
!/tmp/.keep
/storage/*
!/storage/.keep
/public/assets
/public/packs
/public/packs-test
/.bundle
/vendor/bundle
# Node / NPM
/node_modules
/yarn-error.log
yarn-debug.log*
.yarn-integrity
package-lock.json
# Sprockets / Assets
/public/assets
# Docker
.env.docker
# IDEs
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Byebug
.byebug_history
# RSpec
/spec/reports
/spec/examples.txt
/coverage
# Misc
*.orig
*.bak

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM ruby:3.2.2-slim
# Dependências do sistema
RUN apt-get update -qq && apt-get install -y \
build-essential \
libpq-dev \
nodejs \
npm \
curl \
git \
libvips \
&& rm -rf /var/lib/apt/lists/*
# Diretório da app
WORKDIR /app
# Instala gems
COPY Gemfile Gemfile.lock* ./
RUN bundle install --jobs 4 --retry 3
# Copia o restante do código
COPY . .
# Pré-compila assets (em produção)
# RUN bundle exec rails assets:precompile
EXPOSE 3000
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

55
Gemfile Normal file
View File

@@ -0,0 +1,55 @@
source "https://rubygems.org"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby "3.2.2"
# Core Rails
gem "rails", "~> 7.1.0"
gem "pg", "~> 1.1"
gem "puma", "~> 6.0"
# Frontend
gem "importmap-rails"
gem "turbo-rails"
gem "stimulus-rails"
gem "tailwindcss-rails"
gem "jbuilder"
# Auth
gem "devise"
gem "pundit"
# Config / ENV
gem "dotenv-rails"
# PDF
gem "prawn"
gem "prawn-table"
# Jobs / Cron
gem "whenever", require: false
# Notificações (Fase 8 — opcional)
# gem "twilio-ruby"
# Utilitários
gem "pagy" # paginação
gem "image_processing", ">= 1.2"
group :development, :test do
gem "debug", platforms: %i[mri mingw x64_mingw]
gem "rspec-rails"
gem "factory_bot_rails"
gem "faker"
end
group :development do
gem "web-console"
gem "rack-mini-profiler"
gem "annotate"
end
group :test do
gem "capybara"
gem "selenium-webdriver"
end

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
# 🚛 Gade Logística — Sistema de Controle de Custos
Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**.
**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire
---
## 🚀 Setup rápido (Docker)
### 1. Clone o repositório
```bash
git clone https://git.xenserver.com.br/[USUARIO]/logistica-controle-custos.git
cd logistica-controle-custos
```
### 2. Configure o ambiente
```bash
cp .env.example .env
# Edite .env com suas credenciais reais
nano .env
```
### 3. Gere o SECRET_KEY_BASE
```bash
docker run --rm ruby:3.2.2-slim bundle exec rails secret
# Cole o valor gerado no .env → SECRET_KEY_BASE=...
```
### 4. Suba os containers
```bash
docker-compose up -d
```
### 5. Crie o banco e rode as migrations
```bash
docker-compose exec app bundle exec rails db:create db:migrate db:seed
```
### 6. Acesse
```
http://localhost:3000
Login: admin@gade.com
Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO
```
---
## ⚠️ Banco de dados existente
A tabela `public.db_reem_simplerout_2026` **já existe** no PostgreSQL da Gade Hospitalar
e é atualizada automaticamente a cada 1 hora por outro sistema.
**NUNCA execute:** `DROP TABLE`, `TRUNCATE`, `DELETE` ou migrations nessa tabela.
Se o banco existente estiver em servidor separado, configure `DB_HOST` no `.env`
apontando para o IP/hostname correto.
---
## 🔧 Comandos úteis
```bash
# Rodar migrations
docker-compose exec app bundle exec rails db:migrate
# Rodar seeds
docker-compose exec app bundle exec rails db:seed
# Console Rails
docker-compose exec app bundle exec rails console
# Logs da app
docker-compose logs -f app
# Parar containers
docker-compose down
```
---
## 🏗️ Fases de implementação
| Fase | Status | Descrição |
|------|--------|-----------|
| 1 | ✅ | Setup + Docker + Git + Models + Migrations |
| 2 | ⏳ | Auth (Devise/Pundit) + Perfis + Configurações |
| 3 | ⏳ | Dashboard com métricas e gráficos |
| 4 | ⏳ | Job de atualização automática (1h) + Auditoria |
| 5 | ⏳ | Consolidação — wizard Passo 1 |
| 6 | ⏳ | Validação de entregas + toggles + ações em massa |
| 7 | ⏳ | Geração de PDFs (relatório + holerite) |
| 8 | ⏳ | Painel motorista + notificações + finalização |
---
## 🔐 Segurança
- **NUNCA** versione o arquivo `.env`
- **NUNCA** commite `config/master.key` ou credenciais
- Repositório deve ser marcado como **PRIVADO**
- Use `ENV['VARIAVEL']` no código — nunca hardcode senhas
---
## 👥 Perfis de acesso
| Perfil | Acesso |
|--------|--------|
| Admin | Tudo |
| Gerente | Dashboard + Consolidações + PDFs |
| Operador | Consolidações (criar + validar) |
| Motorista | Painel pessoal + baixar próprio PDF |

View 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

View 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

View 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

View 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

View 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

View 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

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

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

View 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

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

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

50
config/routes.rb Normal file
View File

@@ -0,0 +1,50 @@
# config/routes.rb
Rails.application.routes.draw do
# Devise — login padrão para admin/gerente/operador
devise_for :users, path: 'auth', path_names: {
sign_in: 'login',
sign_out: 'logout',
password: 'senha'
}
# Root — redireciona por role
root 'dashboard#index'
# Dashboard principal (admin/gerente/operador)
get '/dashboard', to: 'dashboard#index', as: :dashboard
# Painel motorista
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
# Consolidações
resources :consolidacoes do
member do
post :finalizar
post :arquivar
get :preview_holerite
get :gerar_pdf_relatorio
get :gerar_pdf_holerite
end
resources :consolidacao_motoristas, only: [:index, :show] do
resources :consolidacao_entregas, only: [:index, :create, :update]
end
end
# Configurações (apenas admin/gerente)
namespace :admin do
resources :usuarios, except: [:show]
resources :configuracoes, only: [:index, :update]
resources :auditoria_logs, only: [:index]
end
# API interna — Dashboard métricas
namespace :api do
namespace :v1 do
get 'dashboard/metricas', to: 'dashboard#metricas'
end
end
# Health check (Docker)
get '/health', to: proc { [200, {}, ['OK']] }
end

View File

@@ -0,0 +1,27 @@
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
# Devise campos padrão
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
t.string :reset_password_token
t.datetime :reset_password_sent_at
t.datetime :remember_created_at
# Campos customizados
t.string :nome, null: false
t.string :telefone
t.string :pin_code, limit: 4 # apenas motoristas
t.integer :role, null: false, default: 3 # 0=admin,1=gerente,2=operador,3=motorista
t.boolean :ativo, null: false, default: true
t.string :tema_preferido, default: 'dark' # dark | light
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
add_index :users, :pin_code
add_index :users, :role
end
end

View File

@@ -0,0 +1,14 @@
class CreateConfiguracoes < ActiveRecord::Migration[7.1]
def change
create_table :configuracoes do |t|
t.string :chave, null: false
t.string :valor, null: false
t.string :descricao
t.integer :updated_by # user_id de quem editou por último
t.timestamps
end
add_index :configuracoes, :chave, unique: true
end
end

View File

@@ -0,0 +1,23 @@
class CreateConsolidacoes < ActiveRecord::Migration[7.1]
def change
create_table :consolidacoes do |t|
t.string :nome, null: false
t.date :data_inicio, null: false
t.date :data_fim, null: false
t.integer :status, null: false, default: 0 # 0=rascunho,1=finalizada,2=arquivada
t.decimal :valor_total, precision: 10, scale: 2, default: 0
t.integer :created_by, null: false # user_id
t.integer :finalizado_por # user_id
t.datetime :finalizado_em
t.datetime :deleted_at # soft delete
t.timestamps
end
add_index :consolidacoes, :status
add_index :consolidacoes, :data_inicio
add_index :consolidacoes, :data_fim
add_index :consolidacoes, :deleted_at
add_index :consolidacoes, :created_by
end
end

View File

@@ -0,0 +1,17 @@
class CreateConsolidacaoMotoristas < ActiveRecord::Migration[7.1]
def change
create_table :consolidacao_motoristas do |t|
t.references :consolidacao, null: false, foreign_key: true
t.string :motorista_nome, null: false # driver da tabela existente
t.decimal :valor_total, precision: 10, scale: 2, default: 0
t.timestamps
end
add_index :consolidacao_motoristas, :motorista_nome
add_index :consolidacao_motoristas,
[:consolidacao_id, :motorista_nome],
unique: true,
name: 'idx_consolidacao_motorista_unique'
end
end

View File

@@ -0,0 +1,23 @@
class CreateConsolidacaoEntregas < ActiveRecord::Migration[7.1]
def change
create_table :consolidacao_entregas do |t|
t.references :consolidacao, null: false, foreign_key: true
t.string :tracking_id, null: false # FK lógico para db_reem_simplerout_2026
t.string :motorista_nome, null: false
t.integer :tipo, null: false, default: 0
# 0=entrega_normal, 1=retirada, 2=bonus, 3=desconto
t.decimal :valor_aplicado, precision: 10, scale: 2, null: false
t.integer :created_by # user_id de quem classificou
t.timestamps
end
add_index :consolidacao_entregas, :tracking_id
add_index :consolidacao_entregas, :motorista_nome
add_index :consolidacao_entregas, :tipo
add_index :consolidacao_entregas,
[:consolidacao_id, :tracking_id],
unique: true,
name: 'idx_consolidacao_entrega_unique'
end
end

View File

@@ -0,0 +1,13 @@
class CreateHistoricoEstimados < ActiveRecord::Migration[7.1]
def change
create_table :historico_estimados do |t|
t.datetime :data_hora, null: false
t.decimal :valor_total_estimado, precision: 10, scale: 2, null: false
t.integer :entregas_contadas, null: false, default: 0
t.timestamps
end
add_index :historico_estimados, :data_hora
end
end

View File

@@ -0,0 +1,22 @@
class CreateAuditoriaLogs < ActiveRecord::Migration[7.1]
def change
create_table :auditoria_logs do |t|
t.integer :user_id, null: false
t.string :acao, null: false # criar, editar, finalizar, arquivar, etc.
t.string :entidade, null: false # Consolidacao, ConsolidacaoEntrega, etc.
t.integer :entidade_id
t.jsonb :dados_anteriores, default: {}
t.jsonb :dados_novos, default: {}
t.string :ip_address
t.string :user_agent
t.timestamps
end
add_index :auditoria_logs, :user_id
add_index :auditoria_logs, :acao
add_index :auditoria_logs, :entidade
add_index :auditoria_logs, [:entidade, :entidade_id]
add_index :auditoria_logs, :created_at
end
end

47
db/seeds.rb Normal file
View File

@@ -0,0 +1,47 @@
# db/seeds.rb
# Dados iniciais do sistema Gade Hospitalar
# Execute com: bundle exec rails db:seed
puts "🌱 Criando dados iniciais..."
# ── Usuário Admin padrão ─────────────────────────────────────
admin = User.find_or_create_by!(email: 'admin@gade.com') do |u|
u.nome = 'Administrador Gade'
u.password = 'Gade@2026!'
u.role = :admin
u.ativo = true
end
puts " ✅ Admin criado: #{admin.email} / senha: Gade@2026!"
# ── Usuário Gerente de exemplo ───────────────────────────────
gerente = User.find_or_create_by!(email: 'gerente@gade.com') do |u|
u.nome = 'Gerente Gade'
u.password = 'Gade@2026!'
u.role = :gerente
u.ativo = true
end
puts " ✅ Gerente criado: #{gerente.email}"
# ── Configurações de preço padrão ────────────────────────────
configs = [
{ chave: 'preco_entrega', valor: '15.00', descricao: 'Valor pago por entrega bem-sucedida (R$)' },
{ chave: 'preco_retirada', valor: '20.00', descricao: 'Valor pago por retirada de equipamento (R$)' },
{ chave: 'preco_bonus', valor: '10.00', descricao: 'Valor de bônus por meta/critério (R$)' },
{ chave: 'preco_desconto', valor: '15.00', descricao: 'Valor descontado por problema (R$)' },
{ chave: 'notificacao_whatsapp', valor: 'false', descricao: 'Notificações via WhatsApp (true/false)' },
{ chave: 'notificacao_email', valor: 'false', descricao: 'Notificações via e-mail (true/false)' },
{ chave: 'empresa_nome', valor: 'Gade Hospitalar', descricao: 'Nome da empresa exibido no sistema' },
]
configs.each do |cfg|
Configuracao.find_or_create_by!(chave: cfg[:chave]) do |c|
c.valor = cfg[:valor]
c.descricao = cfg[:descricao]
end
puts " ✅ Config: #{cfg[:chave]} = #{cfg[:valor]}"
end
puts ""
puts "✅ Seeds concluídos!"
puts " Admin: admin@gade.com / Gade@2026!"
puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!"

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_NAME: ${DB_NAME}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
RAILS_ENV: ${RAILS_ENV:-development}
volumes:
- ".:/app"
- "bundle_cache:/usr/local/bundle"
depends_on:
db:
condition: service_healthy
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -b 0.0.0.0"
db:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- "pg_data:/var/lib/postgresql/data"
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
timeout: 5s
retries: 5
# NOTA: Se o banco existente (db_reem_simplerout_2026) estiver em servidor
# separado, remova este serviço e configure DB_HOST no .env apontando para
# o IP/hostname correto do servidor PostgreSQL existente.
volumes:
pg_data:
bundle_cache:

46
setup_git.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# setup_git.sh — Execute UMA VEZ para configurar o repositório Git
# Usuário configurado: Cludio-code
# ATENÇÃO: A SENHA NUNCA fica salva neste arquivo — será solicitada no terminal.
set -e
USUARIO="Cludio-code"
REPO="logistica-controle-custos"
REMOTE="https://git.xenserver.com.br/${USUARIO}/${REPO}.git"
echo "🔧 Configurando repositório Git..."
echo " Servidor: git.xenserver.com.br"
echo " Usuário: ${USUARIO}"
echo " Repo: ${REPO}"
echo ""
# Configura identidade Git (nome e email aparecem nos commits)
git config user.name "Cludio-code"
git config user.email "claudio@gade.com.br"
# Inicializa o repositório
git init -b main
git add .
git commit -m "🚀 Fase 1 — Setup inicial: Rails + Docker + Models + Migrations + Seeds"
# Configura remote SEM credenciais na URL
git remote add origin "${REMOTE}" 2>/dev/null || git remote set-url origin "${REMOTE}"
# Cache de credenciais por 4 horas (digita uma vez, fica salvo)
git config credential.helper 'cache --timeout=14400'
echo ""
echo "✅ Repositório configurado!"
echo ""
echo "▶️ Agora execute para subir o código:"
echo " git push -u origin main"
echo ""
echo " Quando solicitado, digite:"
echo " Username: Cludio-code"
echo " Password: (sua senha — não será exibida na tela)"
echo ""
echo "⚠️ LEMBRETES DE SEGURANÇA:"
echo " ✅ Marque o repositório como PRIVADO no git.xenserver.com.br"
echo " ✅ Nunca commite o arquivo .env"
echo " ✅ A senha NUNCA deve aparecer em nenhum arquivo do projeto"

59
tailwind.config.js Normal file
View File

@@ -0,0 +1,59 @@
// tailwind.config.js
module.exports = {
// Tema escuro como padrão (class strategy)
darkMode: 'class',
content: [
'./app/views/**/*.{html,html.erb,erb}',
'./app/helpers/**/*.rb',
'./app/javascript/**/*.js',
'./app/components/**/*.{rb,html,html.erb,erb}',
],
theme: {
extend: {
colors: {
// Paleta da marca Gade Hospitalar
brand: {
preto: '#0a0a0a',
'preto-card': '#1a1a1a',
'preto-borda': '#2a2a2a',
laranja: '#f97316',
'laranja-escuro': '#ea580c',
'laranja-claro': '#fb923c',
branco: '#ffffff',
cinza: '#6b7280',
'cinza-escuro': '#374151',
},
// Semânticas
sucesso: '#22c55e',
erro: '#ef4444',
alerta: '#fbbf24',
info: '#38bdf8',
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
fontSize: {
// Garante mínimo 16px (desktop) / 18px (mobile implícito)
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base':['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
},
minHeight: {
'touch': '48px', // botões mínimo 48px (acessibilidade touch)
},
borderRadius: {
'DEFAULT': '0.5rem',
'lg': '0.75rem',
'xl': '1rem',
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
}