commit eb812868e5b3c71a6333ab3ba96bcc1b639f35a8729b79f87c0751ee8454f138 Author: victor Date: Wed Jun 10 17:40:07 2026 -0300 Initial commit - Fase 1: Setup Rails + Docker diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49cdcba --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..663548a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fca76c1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e922d05 --- /dev/null +++ b/Gemfile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2317cea --- /dev/null +++ b/README.md @@ -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 | diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..6110295 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..55e1dc5 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/models/auditoria_log.rb b/app/models/auditoria_log.rb new file mode 100644 index 0000000..8e44e43 --- /dev/null +++ b/app/models/auditoria_log.rb @@ -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 diff --git a/app/models/configuracao.rb b/app/models/configuracao.rb new file mode 100644 index 0000000..ccd8756 --- /dev/null +++ b/app/models/configuracao.rb @@ -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 diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb new file mode 100644 index 0000000..dd883a4 --- /dev/null +++ b/app/models/consolidacao.rb @@ -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 diff --git a/app/models/consolidacao_entrega.rb b/app/models/consolidacao_entrega.rb new file mode 100644 index 0000000..6ed511c --- /dev/null +++ b/app/models/consolidacao_entrega.rb @@ -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 diff --git a/app/models/consolidacao_motorista.rb b/app/models/consolidacao_motorista.rb new file mode 100644 index 0000000..5ecc190 --- /dev/null +++ b/app/models/consolidacao_motorista.rb @@ -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 diff --git a/app/models/entrega.rb b/app/models/entrega.rb new file mode 100644 index 0000000..7e18d34 --- /dev/null +++ b/app/models/entrega.rb @@ -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 diff --git a/app/models/historico_estimado.rb b/app/models/historico_estimado.rb new file mode 100644 index 0000000..090b459 --- /dev/null +++ b/app/models/historico_estimado.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..d9bbf6c --- /dev/null +++ b/app/models/user.rb @@ -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 diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..32a7560 --- /dev/null +++ b/app/policies/application_policy.rb @@ -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 diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb new file mode 100644 index 0000000..b77e638 --- /dev/null +++ b/app/views/layouts/_navbar.html.erb @@ -0,0 +1,92 @@ +<%# app/views/layouts/_navbar.html.erb %> +<%# Sidebar lateral — responsiva (esconde no mobile, hamburguer abre) %> + +<%# Overlay mobile %> + + +<%# Sidebar %> + + +<%# Topbar mobile %> +
+ +
+
G
+ Gade Logística +
+
+
+<%# Espaço para topbar no mobile %> +
+ + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..efbbbe8 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,95 @@ + + + + + + + + <%= content_for?(:title) ? "#{yield(:title)} | Gade Logística" : "Gade Logística" %> + + <%# Tailwind via CDN em desenvolvimento — compilar em produção %> + + + + <%# Google Fonts — Inter %> + + + + + + <%= yield :head %> + + + + + <%# ── Navbar ─────────────────────────────────────────── %> + <% if user_signed_in? %> + <%= render 'layouts/navbar' %> + <% end %> + + <%# ── Flash Messages ─────────────────────────────────── %> + <% if notice.present? %> +
+ <%= notice %> +
+ <% end %> + + <% if alert.present? %> +
+ ⚠️ <%= alert %> +
+ <% end %> + + <%# ── Conteúdo principal ──────────────────────────────── %> +
+ <%= yield %> +
+ + <%# Auto-hide flash após 4s (JS simples) %> + + + + diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..648b6c2 --- /dev/null +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260101000001_create_users.rb b/db/migrate/20260101000001_create_users.rb new file mode 100644 index 0000000..92c4697 --- /dev/null +++ b/db/migrate/20260101000001_create_users.rb @@ -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 diff --git a/db/migrate/20260101000002_create_configuracoes.rb b/db/migrate/20260101000002_create_configuracoes.rb new file mode 100644 index 0000000..18f87ef --- /dev/null +++ b/db/migrate/20260101000002_create_configuracoes.rb @@ -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 diff --git a/db/migrate/20260101000003_create_consolidacoes.rb b/db/migrate/20260101000003_create_consolidacoes.rb new file mode 100644 index 0000000..ef426fe --- /dev/null +++ b/db/migrate/20260101000003_create_consolidacoes.rb @@ -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 diff --git a/db/migrate/20260101000004_create_consolidacao_motoristas.rb b/db/migrate/20260101000004_create_consolidacao_motoristas.rb new file mode 100644 index 0000000..0387f7c --- /dev/null +++ b/db/migrate/20260101000004_create_consolidacao_motoristas.rb @@ -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 diff --git a/db/migrate/20260101000005_create_consolidacao_entregas.rb b/db/migrate/20260101000005_create_consolidacao_entregas.rb new file mode 100644 index 0000000..56476d6 --- /dev/null +++ b/db/migrate/20260101000005_create_consolidacao_entregas.rb @@ -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 diff --git a/db/migrate/20260101000006_create_historico_estimados.rb b/db/migrate/20260101000006_create_historico_estimados.rb new file mode 100644 index 0000000..3c97ac8 --- /dev/null +++ b/db/migrate/20260101000006_create_historico_estimados.rb @@ -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 diff --git a/db/migrate/20260101000007_create_auditoria_logs.rb b/db/migrate/20260101000007_create_auditoria_logs.rb new file mode 100644 index 0000000..4f4276e --- /dev/null +++ b/db/migrate/20260101000007_create_auditoria_logs.rb @@ -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 diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..c7d8ab7 --- /dev/null +++ b/db/seeds.rb @@ -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!" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cc45d49 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/setup_git.sh b/setup_git.sh new file mode 100755 index 0000000..418e65a --- /dev/null +++ b/setup_git.sh @@ -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" diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..6d4b567 --- /dev/null +++ b/tailwind.config.js @@ -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'), + ], +}