Initial commit - Fase 1: Setup Rails + Docker
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal 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
61
.gitignore
vendored
Normal 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
29
Dockerfile
Normal 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
55
Gemfile
Normal 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
113
README.md
Normal 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 |
|
||||||
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>
|
||||||
50
config/routes.rb
Normal file
50
config/routes.rb
Normal 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
|
||||||
27
db/migrate/20260101000001_create_users.rb
Normal file
27
db/migrate/20260101000001_create_users.rb
Normal 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
|
||||||
14
db/migrate/20260101000002_create_configuracoes.rb
Normal file
14
db/migrate/20260101000002_create_configuracoes.rb
Normal 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
|
||||||
23
db/migrate/20260101000003_create_consolidacoes.rb
Normal file
23
db/migrate/20260101000003_create_consolidacoes.rb
Normal 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
|
||||||
17
db/migrate/20260101000004_create_consolidacao_motoristas.rb
Normal file
17
db/migrate/20260101000004_create_consolidacao_motoristas.rb
Normal 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
|
||||||
23
db/migrate/20260101000005_create_consolidacao_entregas.rb
Normal file
23
db/migrate/20260101000005_create_consolidacao_entregas.rb
Normal 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
|
||||||
13
db/migrate/20260101000006_create_historico_estimados.rb
Normal file
13
db/migrate/20260101000006_create_historico_estimados.rb
Normal 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
|
||||||
22
db/migrate/20260101000007_create_auditoria_logs.rb
Normal file
22
db/migrate/20260101000007_create_auditoria_logs.rb
Normal 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
47
db/seeds.rb
Normal 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
49
docker-compose.yml
Normal 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
46
setup_git.sh
Executable 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
59
tailwind.config.js
Normal 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'),
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user