Fases 4, 5 e 6 — Job 1h + Auditoria + Consolidação + Wizard de validação
This commit is contained in:
236
README.md
236
README.md
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**.
|
Sistema web para controle de custos de entregas hospitalares da **Gade Hospitalar**.
|
||||||
|
|
||||||
**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus)
|
**Stack:** Ruby on Rails 7+ · PostgreSQL 15 · Docker · Tailwind CSS · Hotwire (Turbo + Stimulus)
|
||||||
**Repositório:** <https://git.xenserver.com.br/victor/Reem-Notas>
|
**Repositório:** https://git.xenserver.com.br/Cludio-code/logistica-controle-custos
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ A **Gade Hospitalar** precisa controlar o custo de cada operação de entrega
|
|||||||
(UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).
|
(UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).
|
||||||
|
|
||||||
O banco **já existe** e é atualizado a cada 1 hora por outro sistema externo:
|
O banco **já existe** e é atualizado a cada 1 hora por outro sistema externo:
|
||||||
|
|
||||||
- Tabela: `public.db_reem_simplerout_2026`
|
- Tabela: `public.db_reem_simplerout_2026`
|
||||||
- Account: Gade Hospitalar (account_id: 95907)
|
- Account: Gade Hospitalar (account_id: 95907)
|
||||||
- **NUNCA** fazer `DROP TABLE`, `TRUNCATE`, `DELETE` ou migration nessa tabela.
|
- **NUNCA** fazer `DROP TABLE`, `TRUNCATE`, `DELETE` ou migration nessa tabela.
|
||||||
@@ -23,13 +22,13 @@ e lê a tabela existente apenas para exibir e consolidar entregas.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Estado atual — O que já foi feito
|
## 🏗️ Estado atual — O que já foi feito (Fase 1 ✅)
|
||||||
|
|
||||||
```text
|
```
|
||||||
logistica-controle-custos/
|
logistica-controle-custos/
|
||||||
├── Gemfile # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…)
|
├── Gemfile # Todas as gems (Devise, Pundit, Prawn, Whenever, Tailwind…)
|
||||||
├── Dockerfile # Ruby 3.2.2-slim
|
├── Dockerfile # Ruby 3.2.2-slim
|
||||||
├── docker-compose.yml # app (postgres externo via .env)
|
├── docker-compose.yml # app + db (postgres:15)
|
||||||
├── tailwind.config.js # Paleta preto #0a0a0a / laranja #f97316 / branco
|
├── tailwind.config.js # Paleta preto #0a0a0a / laranja #f97316 / branco
|
||||||
├── .env.example # Template de variáveis — copiar para .env
|
├── .env.example # Template de variáveis — copiar para .env
|
||||||
├── .gitignore # Protege .env, master.key, credenciais
|
├── .gitignore # Protege .env, master.key, credenciais
|
||||||
@@ -40,44 +39,27 @@ logistica-controle-custos/
|
|||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── models/
|
│ ├── models/
|
||||||
│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela db_reem_simplerout_2026
|
│ │ ├── entrega.rb # ⚠️ READ-ONLY — tabela existente db_reem_simplerout_2026
|
||||||
│ │ ├── user.rb # Devise + roles (admin/gerente/operador/motorista) + PIN
|
│ │ ├── user.rb # Devise + roles (admin/gerente/operador/motorista) + PIN
|
||||||
│ │ ├── configuracao.rb # Preços configuráveis + mapa_de_precos()
|
│ │ ├── configuracao.rb # Preços configuráveis (entrega, retirada, bonus, desconto)
|
||||||
│ │ ├── consolidacao.rb # Soft delete, enum status, cálculo de progresso
|
│ │ ├── consolidacao.rb # Soft delete, enum status, cálculo de progresso
|
||||||
│ │ ├── consolidacao_motorista.rb
|
│ │ ├── consolidacao_motorista.rb
|
||||||
│ │ ├── consolidacao_entrega.rb # Enum tipo: normal/retirada/bonus/desconto
|
│ │ ├── consolidacao_entrega.rb # Enum tipo: normal/retirada/bonus/desconto
|
||||||
│ │ ├── historico_estimado.rb # Job 1h + cálculo ao vivo
|
│ │ ├── historico_estimado.rb # Job 1h + cálculo ao vivo
|
||||||
│ │ └── auditoria_log.rb # Log de ações críticas + .registrar()
|
│ │ └── auditoria_log.rb # Log de ações críticas
|
||||||
│ │
|
│ │
|
||||||
│ ├── controllers/
|
│ ├── controllers/
|
||||||
│ │ ├── application_controller.rb # Auth + Pundit + tema
|
│ │ └── application_controller.rb # Auth + Pundit + tema
|
||||||
│ │ ├── users/sessions_controller.rb # PIN login + redirect por role
|
|
||||||
│ │ ├── dashboard_controller.rb # Cálculos ao vivo (Fase 3)
|
|
||||||
│ │ ├── usuarios_controller.rb # CRUD usuários
|
|
||||||
│ │ ├── configuracoes_controller.rb # Preços + toggle tema
|
|
||||||
│ │ └── motorista_controller.rb # Painel do motorista
|
|
||||||
│ │
|
|
||||||
│ ├── policies/
|
│ ├── policies/
|
||||||
│ │ ├── application_policy.rb # Base Pundit + helpers admin?/gerente?/operador?
|
│ │ └── application_policy.rb # Base Pundit
|
||||||
│ │ ├── user_policy.rb # Admin vê tudo; gerente não vê admins
|
|
||||||
│ │ └── configuracao_policy.rb # Apenas admin edita preços
|
|
||||||
│ │
|
|
||||||
│ ├── helpers/
|
│ ├── helpers/
|
||||||
│ │ └── application_helper.rb # moeda(), nav_link_to(), badge_status(), badge_role(), progress_bar()
|
│ │ └── application_helper.rb # nav_link_to, moeda(), badge_status(), progress_bar()
|
||||||
│ │
|
│ └── views/layouts/
|
||||||
│ └── views/
|
│ ├── application.html.erb # Layout base dark theme laranja/preto/branco
|
||||||
│ ├── devise/sessions/new.html.erb # Login com teclado PIN + abas E-mail/PIN
|
│ └── _navbar.html.erb # Sidebar responsiva com hamburguer mobile
|
||||||
│ ├── layouts/
|
|
||||||
│ │ ├── application.html.erb # Layout dark/light + sidebar responsiva
|
|
||||||
│ │ └── _sidebar.html.erb # Navegação condicional por role
|
|
||||||
│ ├── dashboard/index.html.erb # Cards grandes + Chart.js + ranking
|
|
||||||
│ ├── usuarios/ # index, new, edit, _form
|
|
||||||
│ ├── configuracoes/ # index (cards de preço), edit
|
|
||||||
│ ├── motorista/index.html.erb # Painel simplificado do motorista
|
|
||||||
│ └── shared/_flash.html.erb
|
|
||||||
│
|
│
|
||||||
└── db/
|
└── db/
|
||||||
├── seeds.rb # Admin/Gerente/Operador + 3 motoristas com PIN
|
├── seeds.rb # Admin: admin@gade.com / Gade@2026! + configs de preço
|
||||||
└── migrate/
|
└── migrate/
|
||||||
├── ..._create_users.rb
|
├── ..._create_users.rb
|
||||||
├── ..._create_configuracoes.rb
|
├── ..._create_configuracoes.rb
|
||||||
@@ -85,8 +67,7 @@ logistica-controle-custos/
|
|||||||
├── ..._create_consolidacao_motoristas.rb
|
├── ..._create_consolidacao_motoristas.rb
|
||||||
├── ..._create_consolidacao_entregas.rb
|
├── ..._create_consolidacao_entregas.rb
|
||||||
├── ..._create_historico_estimados.rb
|
├── ..._create_historico_estimados.rb
|
||||||
├── ..._create_auditoria_logs.rb
|
└── ..._create_auditoria_logs.rb
|
||||||
└── ..._add_fase2_fields_to_users.rb # PIN + ativo + trackable + lockable
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -94,41 +75,35 @@ logistica-controle-custos/
|
|||||||
## 🚀 Setup do zero (próximo dev)
|
## 🚀 Setup do zero (próximo dev)
|
||||||
|
|
||||||
### 1. Clone
|
### 1. Clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.xenserver.com.br/victor/Reem-Notas.git
|
git clone https://git.xenserver.com.br/Cludio-code/logistica-controle-custos.git
|
||||||
cd Reem-Notas
|
cd logistica-controle-custos
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure o .env
|
### 2. Configure o .env
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
nano .env # preencha DB_HOST, DB_PASSWORD, etc.
|
nano .env # preencha DB_HOST, DB_PASSWORD, etc.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Gere o SECRET_KEY_BASE
|
### 3. Gere o SECRET_KEY_BASE
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm ruby:3.2.2-slim bash -c "gem install rails --no-doc -q && rails secret"
|
docker run --rm ruby:3.2.2-slim bash -c "gem install rails --no-doc -q && rails secret"
|
||||||
# Cole o resultado no .env → SECRET_KEY_BASE=...
|
# Cole o resultado no .env → SECRET_KEY_BASE=...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Suba os containers
|
### 4. Suba os containers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Banco + seeds
|
### 5. Banco + seeds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Acesse
|
### 6. Acesse
|
||||||
|
```
|
||||||
```text
|
|
||||||
http://localhost:3000
|
http://localhost:3000
|
||||||
Login: admin@gade.com
|
Login: admin@gade.com
|
||||||
Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO
|
Senha: Gade@2026! ← ALTERE NO PRIMEIRO ACESSO
|
||||||
@@ -149,98 +124,99 @@ docker-compose down # parar tudo
|
|||||||
|
|
||||||
## 🗺️ Fases de implementação
|
## 🗺️ Fases de implementação
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
| Fase | Status | O que fazer |
|
| Fase | Status | O que fazer |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base |
|
| **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base |
|
||||||
| **2** | ✅ **CONCLUÍDA** | Login por role + PIN motorista + CRUD usuários + pilares de preço + sidebar |
|
| **2** | ⏳ Pendente | Login por role + PIN motorista + CRUD usuários + pilares de preço + sidebar |
|
||||||
| **3** | ✅ **CONCLUÍDA** | Dashboard: cards grandes laranja/preto/branco + gráfico Chart.js + cálculo ao vivo |
|
| **3** | ⏳ Pendente | Dashboard: cards grandes laranja/preto/branco + gráfico Chart.js + cálculo ao vivo |
|
||||||
| **4** | ⏳ Próxima | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog |
|
| **4** | ✅ **CONCLUÍDA** | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog + API métricas |
|
||||||
| **5** | ⏳ | Nova Consolidação + Wizard Passo 1 (selecionar motorista) |
|
| **5** | ✅ **CONCLUÍDA** | Nova Consolidação + filtros + Wizard Passo 1 (selecionar motorista) |
|
||||||
| **6** | ⏳ | Toggle switches coloridos + ações em massa + barra de progresso (Stimulus) |
|
| **6** | ✅ **CONCLUÍDA** | Toggles coloridos + ações em massa + barra progresso + Wizard Passos 2 e 3 (Stimulus) |
|
||||||
| **7** | ⏳ | PDFs com Prawn: relatório individual + holerite com QR code |
|
| **7** | ⏳ Pendente | PDFs com Prawn: relatório individual + holerite com QR code |
|
||||||
| **8** | ⏳ | Painel motorista + notificações WhatsApp/email + responsividade final |
|
| **8** | ⏳ Pendente | Painel motorista + notificações WhatsApp/email + responsividade final |
|
||||||
=======
|
|
||||||
| Fase | Status | O que foi feito / fazer |
|
|
||||||
| ----- | ---------------- | ------------------------------------------------------------------------------------------- |
|
|
||||||
| **1** | ✅ **CONCLUÍDA** | Setup + Docker + Models + Migrations + Seeds + Layout base |
|
|
||||||
| **2** | ✅ **CONCLUÍDA** | Login por role + PIN motorista + CRUD usuários + pilares de preço + toggle tema + Pundit |
|
|
||||||
| **3** | ✅ **CONCLUÍDA** | Dashboard: cards laranja/preto/branco + gráfico Chart.js + ranking motoristas + cálculo ao vivo |
|
|
||||||
| **4** | ⏳ Próxima | Job Whenever (1h) + HistoricoEstimado + UI de AuditoriaLog |
|
|
||||||
| **5** | ⏳ | Nova Consolidação + Wizard Passo 1 (selecionar motorista) |
|
|
||||||
| **6** | ⏳ | Toggle switches coloridos + ações em massa + barra de progresso (Stimulus) |
|
|
||||||
| **7** | ⏳ | PDFs com Prawn: relatório individual + holerite com QR code |
|
|
||||||
| **8** | ⏳ | Painel motorista + notificações WhatsApp/email + responsividade final |
|
|
||||||
>>>>>>> 63b7f4d (Atualização README - Fase 2 e 3)
|
|
||||||
|
|
||||||
---
|
### 📂 Arquivos das Fases 4, 5 e 6
|
||||||
|
|
||||||
## 👥 Perfis de acesso
|
**Fase 4 — Job 1h + Auditoria:**
|
||||||
|
```
|
||||||
|
config/schedule.rb # Whenever — roda a cada 1h
|
||||||
|
lib/tasks/historico.rake # rake historico:atualizar
|
||||||
|
app/jobs/application_job.rb
|
||||||
|
app/jobs/atualizar_historico_estimado_job.rb # conta entregas pagas × preco_entrega
|
||||||
|
app/controllers/api/v1/dashboard_controller.rb # GET /api/v1/dashboard/metricas
|
||||||
|
app/controllers/concerns/auditavel.rb # auditar!(:acao, registro) nos controllers
|
||||||
|
app/policies/dashboard_policy.rb
|
||||||
|
```
|
||||||
|
Ativar o cron no servidor: `bundle exec whenever --update-crontab`
|
||||||
|
Testar manualmente: `docker-compose exec app bundle exec rake historico:atualizar`
|
||||||
|
|
||||||
| Perfil | Login | Acesso | Vai para |
|
**Fase 5 — Consolidação + Wizard Passo 1:**
|
||||||
| --------- | ------------------- | ----------------------------------- | --------------- |
|
```
|
||||||
| Admin | email + senha | Tudo | `/dashboard` |
|
db/migrate/20260101000008_add_route_ids_to_consolidacoes.rb # rodar db:migrate!
|
||||||
| Gerente | email + senha | Dashboard + Consolidações + PDFs | `/dashboard` |
|
app/controllers/consolidacoes_controller.rb # index/new/create/show/wizard/finalizar/arquivar
|
||||||
| Operador | email + senha | Consolidações (criar + validar) | `/dashboard` |
|
app/policies/consolidacao_policy.rb
|
||||||
| Motorista | PIN 4 dígitos | Painel pessoal + baixar próprio PDF | `/motorista` |
|
app/views/consolidacoes/index.html.erb # lista com filtros (status/nome/período/motorista/rota)
|
||||||
|
app/views/consolidacoes/new.html.erb # form: nome + 2 date pickers + multi-select motoristas/rotas
|
||||||
|
app/views/consolidacoes/wizard.html.erb # Passo 1: lista motoristas com progresso
|
||||||
|
```
|
||||||
|
|
||||||
### Credenciais de teste (seeds)
|
**Fase 6 — Validação + Toggles + Massa:**
|
||||||
|
```
|
||||||
|
app/controllers/consolidacao_entregas_controller.rb # validar/classificar/classificar_em_massa/revisar
|
||||||
|
app/views/consolidacao_entregas/validar.html.erb # Passo 2: toggles coloridos + checkbox massa + resumo lateral
|
||||||
|
app/views/consolidacao_entregas/revisar.html.erb # Passo 3: resumo + próximo motorista / salvar rascunho
|
||||||
|
app/javascript/controllers/validacao_controller.js # Stimulus: tempo real (progresso, resumo, toggles)
|
||||||
|
config/routes.rb # ATUALIZADO — rotas do wizard
|
||||||
|
```
|
||||||
|
|
||||||
| Usuário | Login | Senha / PIN |
|
**Cores dos toggles (padrão do projeto):**
|
||||||
| ---------------- | ------------------ | ------------ |
|
🟧 Laranja = Entrega Normal · 🟫 Laranja escuro = Retirada · ⬜ Branco = Bônus · ⬛ Preto = Desconto
|
||||||
| Admin | admin@gade.com | Gade@2026! |
|
|
||||||
| Gerente | gerente@gade.com | Gade@2026! |
|
|
||||||
| Operador | operador@gade.com | Gade@2026! |
|
|
||||||
| Motorista João | PIN | 1234 |
|
|
||||||
| Motorista Maria | PIN | 5678 |
|
|
||||||
| Motorista Carlos | PIN | 9012 |
|
|
||||||
|
|
||||||
> ⚠️ **Altere todas as senhas no primeiro acesso em produção.**
|
**Regras implementadas:**
|
||||||
|
- Entregas elegíveis: `status='completed'` AND `checkin IS NOT NULL` (somente leitura da tabela existente)
|
||||||
|
- Não permite finalizar consolidação com entregas não classificadas (botão desabilitado + validação server-side)
|
||||||
|
- Consolidação finalizada não pode ser editada por operadores (Pundit)
|
||||||
|
- Todas ações críticas registradas em `auditoria_logs`
|
||||||
|
|
||||||
---
|
### ▶️ Para continuar (Fases 2, 3, 7 ou 8)
|
||||||
|
|
||||||
### ▶️ Para continuar da Fase 4
|
|
||||||
|
|
||||||
Cole este contexto no início da conversa com o Claude:
|
Cole este contexto no início da conversa com o Claude:
|
||||||
|
|
||||||
```text
|
```
|
||||||
Execute a FASE 4 do Sistema de Controle de Custos Logística da Gade Hospitalar.
|
Continue o Sistema de Controle de Custos Logística da Gade Hospitalar.
|
||||||
|
|
||||||
CHECKPOINT FASES 1-3 (concluídas):
|
CHECKPOINT — FASES 1, 4, 5 e 6 CONCLUÍDAS:
|
||||||
- Rails 7+ com Docker, PostgreSQL 15, Tailwind (preto #0a0a0a / laranja #f97316)
|
- Rails 7+ com Docker, PostgreSQL externo, Tailwind (preto #0a0a0a / laranja #f97316)
|
||||||
- Models: Entrega (read-only), User (Devise + roles + PIN), Configuracao,
|
- Models: Entrega (read-only db_reem_simplerout_2026), User (Devise+roles),
|
||||||
Consolidacao, ConsolidacaoMotorista, ConsolidacaoEntrega,
|
Configuracao, Consolidacao (+route_ids jsonb), ConsolidacaoMotorista,
|
||||||
HistoricoEstimado, AuditoriaLog
|
ConsolidacaoEntrega, HistoricoEstimado, AuditoriaLog
|
||||||
- Devise com login por e-mail (admin/gerente/operador) e PIN 4 dígitos (motorista)
|
- 8 migrations criadas
|
||||||
- Pundit: UserPolicy, ConfiguracaoPolicy, ApplicationPolicy
|
- Fase 4: Whenever (1h) + AtualizarHistoricoEstimadoJob + rake historico:atualizar
|
||||||
- Dashboard com Chart.js: cards KPI, gráfico diário, ranking motoristas, grid locais
|
+ API GET /api/v1/dashboard/metricas + concern Auditavel
|
||||||
- CRUD de usuários com toggle ativo/inativo e formulário dinâmico por role
|
- Fase 5: ConsolidacoesController completo (filtros, multi-select motoristas/rotas)
|
||||||
- Configurações de preço (entrega, retirada, bonus, desconto) com cards editáveis
|
+ Wizard Passo 1 (wizard.html.erb)
|
||||||
- Toggle tema claro/escuro salvo na sessão
|
- Fase 6: ConsolidacaoEntregasController (validar/classificar/classificar_em_massa/revisar)
|
||||||
- Sidebar responsiva (desktop fixa + drawer mobile) com navegação por role
|
+ toggles coloridos + ações em massa + Stimulus validacao_controller.js
|
||||||
- Seeds: admin/gerente/operador com e-mail + 3 motoristas com PIN
|
+ validação de finalização
|
||||||
|
|
||||||
FASE 4 deve implementar:
|
FALTAM: Fase 2 (auth/PIN/CRUD usuários/configurações), Fase 3 (Dashboard com
|
||||||
1. Job com Whenever: rodar a cada 1h para calcular e salvar HistoricoEstimado
|
gráficos), Fase 7 (PDFs Prawn + holerite + QR code), Fase 8 (painel motorista
|
||||||
2. Modelo HistoricoEstimado: snapshot do valor estimado por motorista por hora
|
+ notificações). Consulte o README.md do repositório para detalhes.
|
||||||
3. Controller + view de AuditoriaLog: tabela paginada com filtro por usuário/ação
|
|
||||||
4. Indicador visual no Dashboard mostrando "última atualização" do histórico
|
|
||||||
5. Rake task manual: rails historico:calcular (para rodar fora do cron)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🗄️ Banco existente — referência rápida
|
## 🗄️ Banco existente — referência rápida
|
||||||
|
|
||||||
| Campo | Uso |
|
| Campo | Uso |
|
||||||
| -------------- | ------------------------------------------ |
|
|-------|-----|
|
||||||
| `tracking_id` | PK lógico da entrega |
|
| `tracking_id` | PK lógico da entrega |
|
||||||
| `reference_id` | Número da NF |
|
| `reference_id` | Número da NF |
|
||||||
| `planned_date` | Data da entrega (filtro principal) |
|
| `planned_date` | Data da entrega (filtro principal) |
|
||||||
| `driver` | Nome do motorista |
|
| `driver` | Nome do motorista |
|
||||||
| `status` | `'completed'` = entrega paga |
|
| `status` | `'completed'` = entrega paga |
|
||||||
| `checkin` | NULL = motorista não registrou chegada |
|
| `checkin` | NULL = motorista não registrou chegada |
|
||||||
| `route_id` | UUID da operação (UBS SUL, EMAD, etc.) |
|
| `route_id` | UUID da operação (UBS SUL, EMAD, etc.) |
|
||||||
| `contact_name` | Nome do local (UBS SACOMA, EMAD VELEIROS…) |
|
| `contact_name` | Nome do local (UBS SACOMA, EMAD VELEIROS…) |
|
||||||
|
|
||||||
**Regra de pagamento:** `status = 'completed'` AND `checkin IS NOT NULL`
|
**Regra de pagamento:** `status = 'completed'` AND `checkin IS NOT NULL`
|
||||||
@@ -249,18 +225,29 @@ FASE 4 deve implementar:
|
|||||||
|
|
||||||
## 🎨 Design — padrão da marca
|
## 🎨 Design — padrão da marca
|
||||||
|
|
||||||
| Elemento | Cor | Hex |
|
| Elemento | Cor | Hex |
|
||||||
| ----------------- | ------------ | --------- |
|
|----------|-----|-----|
|
||||||
| Fundo | Preto | `#0a0a0a` |
|
| Fundo | Preto | `#0a0a0a` |
|
||||||
| Cards | Cinza escuro | `#1a1a1a` |
|
| Cards | Cinza escuro | `#1a1a1a` |
|
||||||
| Destaque / botões | Laranja | `#f97316` |
|
| Destaque / botões | Laranja | `#f97316` |
|
||||||
| Textos | Branco | `#ffffff` |
|
| Textos | Branco | `#ffffff` |
|
||||||
| Sucesso | Verde | `#22c55e` |
|
| Sucesso | Verde | `#22c55e` |
|
||||||
| Erro/desconto | Vermelho | `#ef4444` |
|
| Erro/desconto | Vermelho | `#ef4444` |
|
||||||
|
|
||||||
- Fonte mínima 16px desktop / 18px mobile
|
- Fonte mínima 16px desktop / 18px mobile
|
||||||
- Botões mínimo 48px altura (acessibilidade touch)
|
- Botões mínimo 48px altura (acessibilidade touch)
|
||||||
- Tema escuro como padrão (toggle disponível no header)
|
- Tema escuro como padrão
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 Perfis de acesso
|
||||||
|
|
||||||
|
| Perfil | Login | Acesso |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| Admin | email + senha | Tudo |
|
||||||
|
| Gerente | email + senha | Dashboard + Consolidações + PDFs |
|
||||||
|
| Operador | email + senha | Consolidações (criar + validar) |
|
||||||
|
| Motorista | PIN 4 dígitos ou QR | Painel pessoal + baixar próprio PDF |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -269,4 +256,3 @@ FASE 4 deve implementar:
|
|||||||
- **NUNCA** versione `.env`, `config/master.key` ou qualquer arquivo com senha
|
- **NUNCA** versione `.env`, `config/master.key` ou qualquer arquivo com senha
|
||||||
- Repositório **PRIVADO** em git.xenserver.com.br
|
- Repositório **PRIVADO** em git.xenserver.com.br
|
||||||
- Use sempre `ENV['VARIAVEL']` no código Ruby
|
- Use sempre `ENV['VARIAVEL']` no código Ruby
|
||||||
- Todas as actions protegidas por Pundit (`authorize` obrigatório)
|
|
||||||
|
|||||||
30
app/controllers/api/v1/dashboard_controller.rb
Normal file
30
app/controllers/api/v1/dashboard_controller.rb
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# app/controllers/api/v1/dashboard_controller.rb
|
||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class DashboardController < ApplicationController
|
||||||
|
# GET /api/v1/dashboard/metricas
|
||||||
|
def metricas
|
||||||
|
authorize :dashboard, :metricas?
|
||||||
|
|
||||||
|
ultimo = HistoricoEstimado.recentes.first
|
||||||
|
vivo = HistoricoEstimado.calcular_ao_vivo
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
ultimo_registro: ultimo && {
|
||||||
|
data_hora: ultimo.data_hora,
|
||||||
|
valor_estimado: ultimo.valor_total_estimado.to_f,
|
||||||
|
entregas: ultimo.entregas_contadas
|
||||||
|
},
|
||||||
|
ao_vivo: {
|
||||||
|
valor_mes: vivo[:valor],
|
||||||
|
entregas_mes: vivo[:entregas]
|
||||||
|
},
|
||||||
|
historico_24h: HistoricoEstimado
|
||||||
|
.where(data_hora: 24.hours.ago..Time.current)
|
||||||
|
.order(:data_hora)
|
||||||
|
.map { |h| { hora: h.data_hora.strftime('%H:%M'), valor: h.valor_total_estimado.to_f } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,26 +4,24 @@ class ApplicationController < ActionController::Base
|
|||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :set_tema
|
before_action :set_tema
|
||||||
after_action :verify_authorized, except: :index, unless: :skip_pundit?
|
|
||||||
after_action :verify_policy_scoped, only: :index, unless: :skip_pundit?
|
|
||||||
|
|
||||||
rescue_from Pundit::NotAuthorizedError, with: :usuario_nao_autorizado
|
# Pundit: redireciona se não autorizado
|
||||||
|
rescue_from Pundit::NotAuthorizedError do |e|
|
||||||
protect_from_forgery with: :exception
|
flash[:alert] = "Você não tem permissão para realizar esta ação."
|
||||||
|
redirect_to root_path
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_tema
|
def set_tema
|
||||||
# Lê o tema da sessão (padrão: escuro)
|
@tema = current_user&.tema_preferido || 'dark'
|
||||||
@tema = session[:tema] || 'dark'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def skip_pundit?
|
def after_sign_in_path_for(resource)
|
||||||
devise_controller? || params[:controller] =~ /rails\/|action_mailbox|action_text/
|
if resource.motorista?
|
||||||
end
|
motorista_dashboard_path
|
||||||
|
else
|
||||||
def usuario_nao_autorizado
|
dashboard_path
|
||||||
flash[:alert] = 'Acesso negado. Você não tem permissão para esta ação.'
|
end
|
||||||
redirect_back(fallback_location: dashboard_path)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
22
app/controllers/concerns/auditavel.rb
Normal file
22
app/controllers/concerns/auditavel.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# app/controllers/concerns/auditavel.rb
|
||||||
|
#
|
||||||
|
# Concern para registrar ações críticas na AuditoriaLog.
|
||||||
|
# Uso no controller:
|
||||||
|
# include Auditavel
|
||||||
|
# auditar!(:criar, @consolidacao, dados_novos: @consolidacao.attributes)
|
||||||
|
#
|
||||||
|
module Auditavel
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def auditar!(acao, registro, dados_anteriores: {}, dados_novos: {})
|
||||||
|
AuditoriaLog.registrar(
|
||||||
|
user: current_user,
|
||||||
|
acao: acao,
|
||||||
|
entidade: registro.class.name,
|
||||||
|
entidade_id: registro.id,
|
||||||
|
dados_anteriores: dados_anteriores,
|
||||||
|
dados_novos: dados_novos,
|
||||||
|
request: request
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
168
app/controllers/consolidacao_entregas_controller.rb
Normal file
168
app/controllers/consolidacao_entregas_controller.rb
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# app/controllers/consolidacao_entregas_controller.rb
|
||||||
|
class ConsolidacaoEntregasController < ApplicationController
|
||||||
|
include Auditavel
|
||||||
|
|
||||||
|
before_action :set_consolidacao
|
||||||
|
before_action :bloquear_se_finalizada, except: %i[validar revisar]
|
||||||
|
|
||||||
|
# GET /consolidacoes/:consolidacao_id/consolidacao_entregas/validar?motorista=X
|
||||||
|
# WIZARD PASSO 2 — tela de validação com toggles
|
||||||
|
def validar
|
||||||
|
authorize @consolidacao, :show?
|
||||||
|
@motorista = params[:motorista]
|
||||||
|
|
||||||
|
# Entregas pagas do período no banco existente (read-only)
|
||||||
|
@entregas = Entrega.pagas
|
||||||
|
.da_conta_gade
|
||||||
|
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||||
|
.do_motorista(@motorista)
|
||||||
|
@entregas = @entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present?
|
||||||
|
@entregas = @entregas.order(:planned_date)
|
||||||
|
|
||||||
|
# Classificações já feitas — indexadas por tracking_id
|
||||||
|
@classificacoes = @consolidacao.consolidacao_entregas
|
||||||
|
.where(motorista_nome: @motorista)
|
||||||
|
.index_by(&:tracking_id)
|
||||||
|
|
||||||
|
# Filtro "apenas não classificadas"
|
||||||
|
if params[:apenas_pendentes] == '1'
|
||||||
|
@entregas = @entregas.reject { |e| @classificacoes.key?(e.tracking_id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
@precos = {
|
||||||
|
entrega_normal: Configuracao.preco_entrega,
|
||||||
|
retirada: Configuracao.preco_retirada,
|
||||||
|
bonus: Configuracao.preco_bonus,
|
||||||
|
desconto: Configuracao.preco_desconto
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar
|
||||||
|
# Classifica UMA entrega (chamado pelo toggle via Turbo/fetch)
|
||||||
|
def classificar
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
|
||||||
|
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: params[:tracking_id])
|
||||||
|
ce.motorista_nome = params[:motorista]
|
||||||
|
ce.tipo = params[:tipo]
|
||||||
|
ce.valor_aplicado = valor_para_tipo(params[:tipo])
|
||||||
|
ce.created_by = current_user.id
|
||||||
|
ce.save!
|
||||||
|
|
||||||
|
recalcular_motorista(params[:motorista])
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { render json: resumo_json(params[:motorista]) }
|
||||||
|
format.html { redirect_back fallback_location: wizard_consolidacao_path(@consolidacao) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/classificar_em_massa
|
||||||
|
# Ações em massa: marcar todos / marcar selecionados
|
||||||
|
def classificar_em_massa
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
|
||||||
|
tipo = params[:tipo]
|
||||||
|
motorista = params[:motorista]
|
||||||
|
tracking_ids = Array(params[:tracking_ids]).reject(&:blank?)
|
||||||
|
|
||||||
|
# "Marcar todos como Normal" → sem tracking_ids = todas do período
|
||||||
|
if tracking_ids.empty?
|
||||||
|
entregas = Entrega.pagas.da_conta_gade
|
||||||
|
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||||
|
.do_motorista(motorista)
|
||||||
|
entregas = entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present?
|
||||||
|
tracking_ids = entregas.pluck(:tracking_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
valor = valor_para_tipo(tipo)
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
tracking_ids.each do |tid|
|
||||||
|
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid)
|
||||||
|
ce.assign_attributes(motorista_nome: motorista, tipo: tipo,
|
||||||
|
valor_aplicado: valor, created_by: current_user.id)
|
||||||
|
ce.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
recalcular_motorista(motorista)
|
||||||
|
auditar!(:editar, @consolidacao,
|
||||||
|
dados_novos: { acao_massa: tipo, qtd: tracking_ids.size, motorista: motorista })
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.json { render json: resumo_json(motorista) }
|
||||||
|
format.html do
|
||||||
|
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
|
||||||
|
notice: "#{tracking_ids.size} entregas marcadas como #{tipo.humanize}."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /consolidacoes/:consolidacao_id/consolidacao_entregas/revisar?motorista=X
|
||||||
|
# WIZARD PASSO 3 — revisão do motorista
|
||||||
|
def revisar
|
||||||
|
authorize @consolidacao, :show?
|
||||||
|
@motorista = params[:motorista]
|
||||||
|
|
||||||
|
@classificacoes = @consolidacao.consolidacao_entregas
|
||||||
|
.where(motorista_nome: @motorista)
|
||||||
|
.order(:created_at)
|
||||||
|
|
||||||
|
@resumo = @classificacoes.group(:tipo).count
|
||||||
|
@total = @classificacoes.sum do |c|
|
||||||
|
c.tipo == 'desconto' ? -c.valor_aplicado : c.valor_aplicado
|
||||||
|
end
|
||||||
|
|
||||||
|
# Próximo motorista pendente (para botão "Próximo Motorista")
|
||||||
|
nomes_completos = @consolidacao.consolidacao_motoristas.map(&:motorista_nome)
|
||||||
|
@proximo = nomes_completos.find do |nome|
|
||||||
|
next false if nome == @motorista
|
||||||
|
total = Entrega.contar_pagas(inicio: @consolidacao.data_inicio,
|
||||||
|
fim: @consolidacao.data_fim, motorista: nome)
|
||||||
|
@consolidacao.consolidacao_entregas.where(motorista_nome: nome).count < total
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_consolidacao
|
||||||
|
@consolidacao = Consolidacao.find(params[:consolidacao_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def bloquear_se_finalizada
|
||||||
|
return unless @consolidacao.finalizada? && current_user.operador?
|
||||||
|
redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def valor_para_tipo(tipo)
|
||||||
|
case tipo.to_s
|
||||||
|
when 'entrega_normal' then Configuracao.preco_entrega
|
||||||
|
when 'retirada' then Configuracao.preco_retirada
|
||||||
|
when 'bonus' then Configuracao.preco_bonus
|
||||||
|
when 'desconto' then Configuracao.preco_desconto
|
||||||
|
else 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def recalcular_motorista(nome)
|
||||||
|
cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: nome)
|
||||||
|
return unless cm
|
||||||
|
|
||||||
|
total = @consolidacao.consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
||||||
|
e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado
|
||||||
|
end
|
||||||
|
cm.update_column(:valor_total, total)
|
||||||
|
@consolidacao.update_column(:valor_total, @consolidacao.consolidacao_motoristas.sum(:valor_total))
|
||||||
|
end
|
||||||
|
|
||||||
|
def resumo_json(motorista)
|
||||||
|
classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
classificadas: classificacoes.count,
|
||||||
|
por_tipo: classificacoes.group(:tipo).count,
|
||||||
|
valor_total: classificacoes.sum { |e| e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado }.to_f
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
131
app/controllers/consolidacoes_controller.rb
Normal file
131
app/controllers/consolidacoes_controller.rb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# app/controllers/consolidacoes_controller.rb
|
||||||
|
class ConsolidacoesController < ApplicationController
|
||||||
|
include Auditavel
|
||||||
|
|
||||||
|
before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar wizard]
|
||||||
|
|
||||||
|
# GET /consolidacoes — lista com filtros
|
||||||
|
def index
|
||||||
|
authorize Consolidacao
|
||||||
|
|
||||||
|
@consolidacoes = Consolidacao.ativas.recentes.includes(:criador)
|
||||||
|
|
||||||
|
# Filtros
|
||||||
|
@consolidacoes = @consolidacoes.where(status: params[:status]) if params[:status].present?
|
||||||
|
@consolidacoes = @consolidacoes.where('nome ILIKE ?', "%#{params[:nome]}%") if params[:nome].present?
|
||||||
|
@consolidacoes = @consolidacoes.where('data_inicio >= ?', params[:inicio]) if params[:inicio].present?
|
||||||
|
@consolidacoes = @consolidacoes.where('data_fim <= ?', params[:fim]) if params[:fim].present?
|
||||||
|
|
||||||
|
if params[:motorista].present?
|
||||||
|
ids = ConsolidacaoMotorista.where('motorista_nome ILIKE ?', "%#{params[:motorista]}%")
|
||||||
|
.pluck(:consolidacao_id)
|
||||||
|
@consolidacoes = @consolidacoes.where(id: ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:route_id].present?
|
||||||
|
@consolidacoes = @consolidacoes.where('route_ids @> ?', [params[:route_id]].to_json)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /consolidacoes/new
|
||||||
|
def new
|
||||||
|
authorize Consolidacao
|
||||||
|
@consolidacao = Consolidacao.new(
|
||||||
|
data_inicio: Date.current.beginning_of_month,
|
||||||
|
data_fim: Date.current
|
||||||
|
)
|
||||||
|
@motoristas = Entrega.motoristas_ativos
|
||||||
|
@rotas = Entrega.rotas_unicas
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes
|
||||||
|
def create
|
||||||
|
authorize Consolidacao
|
||||||
|
@consolidacao = Consolidacao.new(consolidacao_params.merge(created_by: current_user.id))
|
||||||
|
|
||||||
|
motoristas = Array(params[:motoristas]).reject(&:blank?)
|
||||||
|
|
||||||
|
if motoristas.empty?
|
||||||
|
@consolidacao.errors.add(:base, 'Selecione ao menos um motorista')
|
||||||
|
preparar_form_new
|
||||||
|
return render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@consolidacao.save!
|
||||||
|
motoristas.each do |nome|
|
||||||
|
@consolidacao.consolidacao_motoristas.create!(motorista_nome: nome)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
auditar!(:criar, @consolidacao, dados_novos: @consolidacao.attributes)
|
||||||
|
redirect_to wizard_consolidacao_path(@consolidacao), notice: 'Consolidação criada! Selecione o motorista para validar.'
|
||||||
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
preparar_form_new
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /consolidacoes/:id — visão geral
|
||||||
|
def show
|
||||||
|
authorize @consolidacao
|
||||||
|
@motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /consolidacoes/:id/wizard — PASSO 1: selecionar motorista
|
||||||
|
def wizard
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
|
||||||
|
@motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm|
|
||||||
|
total = Entrega.contar_pagas(
|
||||||
|
inicio: @consolidacao.data_inicio,
|
||||||
|
fim: @consolidacao.data_fim,
|
||||||
|
motorista: cm.motorista_nome
|
||||||
|
)
|
||||||
|
classificadas = @consolidacao.consolidacao_entregas
|
||||||
|
.where(motorista_nome: cm.motorista_nome)
|
||||||
|
.count
|
||||||
|
{
|
||||||
|
registro: cm,
|
||||||
|
total: total,
|
||||||
|
classificadas: classificadas,
|
||||||
|
completo: total.positive? && classificadas >= total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:id/finalizar
|
||||||
|
def finalizar
|
||||||
|
authorize @consolidacao, :update?
|
||||||
|
|
||||||
|
if @consolidacao.finalizar!(current_user)
|
||||||
|
auditar!(:finalizar, @consolidacao)
|
||||||
|
redirect_to @consolidacao, notice: 'Consolidação finalizada com sucesso!'
|
||||||
|
else
|
||||||
|
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||||
|
alert: 'Não é possível finalizar: existem entregas não classificadas.'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /consolidacoes/:id/arquivar
|
||||||
|
def arquivar
|
||||||
|
authorize @consolidacao, :destroy?
|
||||||
|
@consolidacao.arquivar!(current_user)
|
||||||
|
auditar!(:arquivar, @consolidacao)
|
||||||
|
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_consolidacao
|
||||||
|
@consolidacao = Consolidacao.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def preparar_form_new
|
||||||
|
@motoristas = Entrega.motoristas_ativos
|
||||||
|
@rotas = Entrega.rotas_unicas
|
||||||
|
end
|
||||||
|
|
||||||
|
def consolidacao_params
|
||||||
|
params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, route_ids: [])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,82 +1,46 @@
|
|||||||
# app/helpers/application_helper.rb
|
# app/helpers/application_helper.rb
|
||||||
module ApplicationHelper
|
module ApplicationHelper
|
||||||
# Formata valor monetário → "R$ 1.250,00"
|
# 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)
|
def moeda(valor)
|
||||||
"R$ #{format('%.2f', valor.to_f).gsub('.', ',')}"
|
"R$ #{"%.2f" % valor.to_f}".gsub('.', ',')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Link ativo na sidebar
|
# Badge de status de consolidação
|
||||||
def nav_link_to(nome, path, icon: nil, **opts)
|
|
||||||
ativo = current_page?(path) || request.path.start_with?(path.to_s)
|
|
||||||
classes = [
|
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all',
|
|
||||||
ativo ? 'bg-[#f97316] text-white shadow-lg shadow-orange-500/20'
|
|
||||||
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
||||||
].join(' ')
|
|
||||||
|
|
||||||
link_to path, class: classes, **opts do
|
|
||||||
concat content_tag(:span, icon, class: 'text-lg w-5 text-center') if icon
|
|
||||||
concat content_tag(:span, nome)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Badge colorido por status de consolidação
|
|
||||||
def badge_status(status)
|
def badge_status(status)
|
||||||
config = {
|
cfg = case status.to_s
|
||||||
'aberta' => { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Aberta' },
|
when 'rascunho' then { cor: 'bg-yellow-500 text-black', icone: '📝', label: 'Rascunho' }
|
||||||
'em_revisao' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Em Revisão' },
|
when 'finalizada' then { cor: 'bg-green-600 text-white', icone: '✅', label: 'Finalizada' }
|
||||||
'fechada' => { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Fechada' },
|
when 'arquivada' then { cor: 'bg-gray-700 text-gray-300', icone: '📁', label: 'Arquivada' }
|
||||||
'aprovada' => { bg: 'bg-[#f97316]/20', text: 'text-[#f97316]', label: 'Aprovada' },
|
else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.humanize }
|
||||||
'cancelada' => { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Cancelada' }
|
end
|
||||||
}
|
|
||||||
c = config[status.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: status.to_s.humanize }
|
|
||||||
content_tag(:span, c[:label],
|
|
||||||
class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Badge de role do usuário
|
tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do
|
||||||
def badge_role(role)
|
"#{cfg[:icone]} #{cfg[:label]}"
|
||||||
config = {
|
|
||||||
'admin' => { bg: 'bg-purple-500/20', text: 'text-purple-400', label: 'Admin' },
|
|
||||||
'gerente' => { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Gerente' },
|
|
||||||
'operador' => { bg: 'bg-cyan-500/20', text: 'text-cyan-400', label: 'Operador' },
|
|
||||||
'motorista'=> { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Motorista' }
|
|
||||||
}
|
|
||||||
c = config[role.to_s] || { bg: 'bg-gray-500/20', text: 'text-gray-400', label: role.to_s.humanize }
|
|
||||||
content_tag(:span, c[:label],
|
|
||||||
class: "inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium #{c[:bg]} #{c[:text]}")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Badge de status ativo/inativo do usuário
|
|
||||||
def badge_status_usuario(ativo)
|
|
||||||
if ativo
|
|
||||||
content_tag(:span, 'Ativo',
|
|
||||||
class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-green-500/20 text-green-400')
|
|
||||||
else
|
|
||||||
content_tag(:span, 'Inativo',
|
|
||||||
class: 'inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-medium bg-red-500/20 text-red-400')
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Barra de progresso
|
# Barra de progresso laranja
|
||||||
def progress_bar(percentual, color: '#f97316')
|
def progress_bar(percentual, label: nil)
|
||||||
perc = [[percentual.to_i, 0].max, 100].min
|
pct = percentual.to_i.clamp(0, 100)
|
||||||
content_tag(:div, class: 'w-full bg-white/10 rounded-full h-2') do
|
tag.div class: "w-full" do
|
||||||
content_tag(:div, '',
|
concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") {
|
||||||
class: 'h-2 rounded-full transition-all duration-500',
|
concat tag.span(label || "#{pct}% classificado")
|
||||||
style: "width: #{perc}%; background-color: #{color}")
|
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
|
end
|
||||||
|
|
||||||
# Opções de role para select
|
|
||||||
def role_options_for_select
|
|
||||||
User.roles.keys.map { |r| [r.humanize, r] }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Classe base para inputs
|
|
||||||
def input_class
|
|
||||||
'w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white ' \
|
|
||||||
'placeholder-gray-600 focus:outline-none focus:border-[#f97316] focus:ring-1 ' \
|
|
||||||
'focus:ring-[#f97316] transition-colors'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
145
app/javascript/controllers/validacao_controller.js
Normal file
145
app/javascript/controllers/validacao_controller.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// app/javascript/controllers/validacao_controller.js
|
||||||
|
// Stimulus controller — Wizard Passo 2 (validação de entregas)
|
||||||
|
// Atualização em tempo real: toggles, ações em massa, barra de progresso, resumo lateral.
|
||||||
|
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = [
|
||||||
|
"linha", "checkbox", "toggles", "barra", "percentual",
|
||||||
|
"contadorClassificadas", "contadorTotal",
|
||||||
|
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "valorTotal"
|
||||||
|
]
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
url: String, // POST classificar
|
||||||
|
massaUrl: String, // POST classificar_em_massa
|
||||||
|
motorista: String,
|
||||||
|
total: Number
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
// Carrega estado inicial renderizado pelo servidor
|
||||||
|
const el = document.getElementById("validacao-estado-inicial")
|
||||||
|
if (el) {
|
||||||
|
const estado = JSON.parse(el.textContent)
|
||||||
|
this.atualizarResumo(estado)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle individual ──────────────────────────────────────
|
||||||
|
async classificar(event) {
|
||||||
|
const btn = event.currentTarget
|
||||||
|
const tracking = btn.dataset.tracking
|
||||||
|
const tipo = btn.dataset.tipo
|
||||||
|
|
||||||
|
btn.disabled = true
|
||||||
|
try {
|
||||||
|
const resp = await this.post(this.urlValue, {
|
||||||
|
tracking_id: tracking,
|
||||||
|
tipo: tipo,
|
||||||
|
motorista: this.motoristaValue
|
||||||
|
})
|
||||||
|
this.marcarToggleAtivo(tracking, tipo)
|
||||||
|
this.atualizarResumo(resp)
|
||||||
|
} catch (e) {
|
||||||
|
alert("Erro ao classificar entrega. Tente novamente.")
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marcar todos como Normal ───────────────────────────────
|
||||||
|
async marcarTodos(event) {
|
||||||
|
const tipo = event.currentTarget.dataset.tipo
|
||||||
|
if (!confirm(`Marcar TODAS as entregas como ${this.labelTipo(tipo)}?`)) return
|
||||||
|
|
||||||
|
const resp = await this.post(this.massaUrlValue, {
|
||||||
|
tipo: tipo,
|
||||||
|
motorista: this.motoristaValue,
|
||||||
|
tracking_ids: [] // vazio = todas
|
||||||
|
})
|
||||||
|
this.linhaTargets.forEach(l => this.marcarToggleAtivo(l.dataset.tracking, tipo))
|
||||||
|
this.atualizarResumo(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Marcar selecionados como X ─────────────────────────────
|
||||||
|
async marcarSelecionados(event) {
|
||||||
|
const tipo = event.currentTarget.dataset.tipo
|
||||||
|
const ids = this.checkboxTargets.filter(c => c.checked).map(c => c.value)
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
alert("Selecione ao menos uma entrega (checkbox à esquerda).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await this.post(this.massaUrlValue, {
|
||||||
|
tipo: tipo,
|
||||||
|
motorista: this.motoristaValue,
|
||||||
|
tracking_ids: ids
|
||||||
|
})
|
||||||
|
ids.forEach(id => this.marcarToggleAtivo(id, tipo))
|
||||||
|
this.checkboxTargets.forEach(c => c.checked = false)
|
||||||
|
this.atualizarResumo(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async post(url, body) {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
marcarToggleAtivo(tracking, tipo) {
|
||||||
|
const grupo = this.togglesTargets.find(t => t.dataset.tracking === tracking)
|
||||||
|
if (!grupo) return
|
||||||
|
grupo.querySelectorAll(".toggle-btn").forEach(b => {
|
||||||
|
const ativo = b.dataset.tipo === tipo
|
||||||
|
b.classList.toggle("ring-2", ativo)
|
||||||
|
b.classList.toggle("ring-green-400", ativo)
|
||||||
|
b.classList.toggle("scale-105", ativo)
|
||||||
|
b.classList.toggle("opacity-50", !ativo)
|
||||||
|
})
|
||||||
|
const linha = this.linhaTargets.find(l => l.dataset.tracking === tracking)
|
||||||
|
if (linha) linha.dataset.classificada = "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
atualizarResumo(dados) {
|
||||||
|
const porTipo = dados.por_tipo || {}
|
||||||
|
this.qtdNormalTarget.textContent = porTipo["entrega_normal"] || 0
|
||||||
|
this.qtdRetiradaTarget.textContent = porTipo["retirada"] || 0
|
||||||
|
this.qtdBonusTarget.textContent = porTipo["bonus"] || 0
|
||||||
|
this.qtdDescontoTarget.textContent = porTipo["desconto"] || 0
|
||||||
|
|
||||||
|
const valor = dados.valor_total || 0
|
||||||
|
this.valorTotalTarget.textContent =
|
||||||
|
"R$ " + valor.toFixed(2).replace(".", ",")
|
||||||
|
|
||||||
|
const classificadas = dados.classificadas || 0
|
||||||
|
this.contadorClassificadasTarget.textContent = classificadas
|
||||||
|
|
||||||
|
const total = this.totalValue || parseInt(this.contadorTotalTarget.textContent)
|
||||||
|
const pct = total > 0 ? Math.round((classificadas / total) * 100) : 0
|
||||||
|
this.barraTarget.style.width = `${Math.min(pct, 100)}%`
|
||||||
|
this.percentualTarget.textContent = `${Math.min(pct, 100)}%`
|
||||||
|
|
||||||
|
if (pct >= 100) {
|
||||||
|
this.barraTarget.classList.remove("bg-orange-500")
|
||||||
|
this.barraTarget.classList.add("bg-green-500")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labelTipo(tipo) {
|
||||||
|
return { entrega_normal: "Normal", retirada: "Retirada",
|
||||||
|
bonus: "Bônus", desconto: "Desconto" }[tipo] || tipo
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/jobs/application_job.rb
Normal file
5
app/jobs/application_job.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# app/jobs/application_job.rb
|
||||||
|
class ApplicationJob < ActiveJob::Base
|
||||||
|
retry_on ActiveRecord::Deadlocked
|
||||||
|
discard_on ActiveJob::DeserializationError
|
||||||
|
end
|
||||||
24
app/jobs/atualizar_historico_estimado_job.rb
Normal file
24
app/jobs/atualizar_historico_estimado_job.rb
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# app/jobs/atualizar_historico_estimado_job.rb
|
||||||
|
#
|
||||||
|
# Roda a cada 1 hora (via Whenever → rake historico:atualizar).
|
||||||
|
# Lógica: conta entregas do dia com status='completed' E checkin IS NOT NULL,
|
||||||
|
# multiplica pelo preco_entrega configurado e persiste em historico_estimados.
|
||||||
|
#
|
||||||
|
class AtualizarHistoricoEstimadoJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
preco = Configuracao.preco_entrega
|
||||||
|
|
||||||
|
entregas = Entrega.da_conta_gade
|
||||||
|
.pagas
|
||||||
|
.where(planned_date: Date.current)
|
||||||
|
.count
|
||||||
|
|
||||||
|
HistoricoEstimado.create!(
|
||||||
|
data_hora: Time.current,
|
||||||
|
valor_total_estimado: entregas * preco,
|
||||||
|
entregas_contadas: entregas
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
# app/models/auditoria_log.rb
|
# app/models/auditoria_log.rb
|
||||||
class AuditoriaLog < ApplicationRecord
|
class AuditoriaLog < ApplicationRecord
|
||||||
belongs_to :user, optional: true
|
belongs_to :user, optional: true # opcional caso user seja deletado
|
||||||
|
|
||||||
validates :acao, presence: true
|
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 :recentes, -> { order(created_at: :desc) }
|
||||||
scope :do_usuario, ->(user) { where(user: user) }
|
scope :por_user, ->(user_id) { where(user_id: user_id) }
|
||||||
|
scope :por_entidade, ->(entidade) { where(entidade: entidade) }
|
||||||
|
|
||||||
# Cria um log de auditoria de forma simples
|
def self.registrar(user:, acao:, entidade:, entidade_id: nil,
|
||||||
# AuditoriaLog.registrar(current_user, 'criar_consolidacao', ip, detalhes: '...')
|
dados_anteriores: {}, dados_novos: {}, request: nil)
|
||||||
def self.registrar(user, acao, ip = nil, detalhes: nil)
|
|
||||||
create!(
|
create!(
|
||||||
user: user,
|
user_id: user&.id,
|
||||||
acao: acao,
|
acao: acao.to_s,
|
||||||
ip: ip,
|
entidade: entidade.to_s,
|
||||||
detalhes: detalhes
|
entidade_id: entidade_id,
|
||||||
|
dados_anteriores: dados_anteriores,
|
||||||
|
dados_novos: dados_novos,
|
||||||
|
ip_address: request&.remote_ip,
|
||||||
|
user_agent: request&.user_agent
|
||||||
)
|
)
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue => e
|
||||||
Rails.logger.warn("AuditoriaLog falhou: #{e.message}")
|
Rails.logger.error("[AuditoriaLog] Erro ao registrar: #{e.message}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,39 +1,55 @@
|
|||||||
# app/models/configuracao.rb
|
# app/models/configuracao.rb
|
||||||
class Configuracao < ApplicationRecord
|
class Configuracao < ApplicationRecord
|
||||||
TIPOS = %w[entrega retirada bonus desconto].freeze
|
# Chaves válidas do sistema
|
||||||
|
CHAVES = %w[
|
||||||
|
preco_entrega
|
||||||
|
preco_retirada
|
||||||
|
preco_bonus
|
||||||
|
preco_desconto
|
||||||
|
notificacao_whatsapp
|
||||||
|
notificacao_email
|
||||||
|
empresa_nome
|
||||||
|
empresa_logo
|
||||||
|
].freeze
|
||||||
|
|
||||||
enum tipo: { entrega: 0, retirada: 1, bonus: 2, desconto: 3 }
|
CHAVES_MOEDA = %w[
|
||||||
|
preco_entrega
|
||||||
|
preco_retirada
|
||||||
|
preco_bonus
|
||||||
|
preco_desconto
|
||||||
|
].freeze
|
||||||
|
|
||||||
validates :tipo, presence: true, uniqueness: true
|
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
|
||||||
validates :valor, presence: true,
|
validates :valor, presence: true
|
||||||
numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
|
|
||||||
LABELS = {
|
# ── Acesso rápido ───────────────────────────────────────────
|
||||||
'entrega' => 'Entrega Normal',
|
|
||||||
'retirada' => 'Retirada',
|
|
||||||
'bonus' => 'Bônus',
|
|
||||||
'desconto' => 'Desconto'
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
ICONES = {
|
def self.valor(chave)
|
||||||
'entrega' => '🚚',
|
find_by(chave: chave)&.valor
|
||||||
'retirada' => '📦',
|
|
||||||
'bonus' => '⭐',
|
|
||||||
'desconto' => '🔻'
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
def tipo_label
|
|
||||||
LABELS[tipo] || tipo.humanize
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def icone
|
def self.preco_entrega
|
||||||
ICONES[tipo] || '💰'
|
valor('preco_entrega').to_f
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retorna um hash { entrega: 10.50, retirada: 8.00, bonus: 5.00, desconto: 2.00 }
|
def self.preco_retirada
|
||||||
def self.mapa_de_precos
|
valor('preco_retirada').to_f
|
||||||
all.each_with_object({}) do |c, h|
|
end
|
||||||
h[c.tipo.to_sym] = c.valor.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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,34 +1,75 @@
|
|||||||
# app/models/consolidacao.rb
|
# app/models/consolidacao.rb
|
||||||
class Consolidacao < ApplicationRecord
|
class Consolidacao < ApplicationRecord
|
||||||
# Soft delete
|
belongs_to :criador, class_name: 'User', foreign_key: :created_by
|
||||||
acts_as_paranoid if respond_to?(:acts_as_paranoid)
|
belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true
|
||||||
|
|
||||||
enum status: { aberta: 0, em_revisao: 1, fechada: 2, aprovada: 3, cancelada: 4 }
|
|
||||||
|
|
||||||
belongs_to :criado_por, class_name: 'User', foreign_key: :user_id
|
|
||||||
has_many :consolidacao_motoristas, dependent: :destroy
|
has_many :consolidacao_motoristas, dependent: :destroy
|
||||||
has_many :consolidacao_entregas, through: :consolidacao_motoristas
|
has_many :consolidacao_entregas, dependent: :destroy
|
||||||
has_many :motoristas, through: :consolidacao_motoristas, source: :user
|
|
||||||
|
|
||||||
validates :referencia_mes, presence: true
|
# ── Enums ───────────────────────────────────────────────────
|
||||||
validates :status, presence: true
|
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||||
|
|
||||||
scope :do_mes, ->(data = Date.today) {
|
# ── Soft delete ─────────────────────────────────────────────
|
||||||
where(referencia_mes: data.beginning_of_month..data.end_of_month)
|
scope :ativas, -> { where(deleted_at: nil) }
|
||||||
}
|
scope :arquivadas, -> { where.not(deleted_at: nil) }
|
||||||
|
|
||||||
scope :abertas, -> { where(status: :aberta) }
|
# ── Validações ──────────────────────────────────────────────
|
||||||
scope :fechadas, -> { where(status: [:fechada, :aprovada]) }
|
validates :nome, presence: true
|
||||||
|
validates :data_inicio, presence: true
|
||||||
|
validates :data_fim, presence: true
|
||||||
|
validates :created_by, presence: true
|
||||||
|
validate :periodo_valido
|
||||||
|
|
||||||
def progresso_percentual
|
# ── Callbacks ───────────────────────────────────────────────
|
||||||
return 0 if consolidacao_motoristas.none?
|
before_save :recalcular_valor_total
|
||||||
|
|
||||||
finalizados = consolidacao_motoristas.where(finalizado: true).count
|
# ── Escopos ─────────────────────────────────────────────────
|
||||||
total = consolidacao_motoristas.count
|
scope :recentes, -> { order(created_at: :desc) }
|
||||||
((finalizados.to_f / total) * 100).round
|
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
|
end
|
||||||
|
|
||||||
def mes_label
|
def finalizar!(user)
|
||||||
referencia_mes&.strftime('%B/%Y')&.capitalize
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,39 +1,88 @@
|
|||||||
# app/models/entrega.rb
|
# app/models/entrega.rb
|
||||||
# ⚠️ READ-ONLY — tabela existente: public.db_reem_simplerout_2026
|
#
|
||||||
# NUNCA fazer DROP, TRUNCATE, DELETE ou migration nessa tabela.
|
# 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
|
class Entrega < ApplicationRecord
|
||||||
self.table_name = 'db_reem_simplerout_2026'
|
self.table_name = 'db_reem_simplerout_2026'
|
||||||
self.primary_key = 'tracking_id'
|
self.primary_key = 'tracking_id'
|
||||||
|
|
||||||
# Apenas leitura
|
# Apenas leitura — segurança contra mutações acidentais
|
||||||
def readonly?
|
def readonly?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Scopes
|
# ── Scopes ──────────────────────────────────────────────────
|
||||||
scope :do_account, -> { where(account_id: 95907) }
|
scope :concluidas, -> { where(status: 'completed') }
|
||||||
scope :pagas, -> { where(status: 'completed').where.not(checkin: nil) }
|
scope :com_checkin, -> { where.not(checkin: nil) }
|
||||||
scope :pendentes, -> { where.not(status: 'completed').or(where(checkin: nil)) }
|
scope :pagas, -> { concluidas.com_checkin }
|
||||||
|
scope :pendentes, -> { where.not(status: 'completed') }
|
||||||
|
|
||||||
scope :do_mes, ->(data = Date.today) {
|
scope :no_periodo, ->(inicio, fim) {
|
||||||
do_account
|
where(planned_date: inicio.to_date..fim.to_date)
|
||||||
.where(planned_date: data.beginning_of_month..data.end_of_month)
|
|
||||||
}
|
|
||||||
|
|
||||||
scope :do_dia, ->(data = Date.today) {
|
|
||||||
do_account.where(planned_date: data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scope :do_motorista, ->(nome) {
|
scope :do_motorista, ->(nome) {
|
||||||
where('LOWER(driver) = ?', nome.to_s.downcase)
|
where(driver: nome)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helpers de instância
|
scope :da_rota, ->(route_id) {
|
||||||
def paga?
|
where(route_id: route_id)
|
||||||
status == 'completed' && checkin.present?
|
}
|
||||||
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
def data_formatada
|
# Lista rotas únicas (route_id → descrição)
|
||||||
planned_date&.strftime('%d/%m/%Y')
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,59 +1,69 @@
|
|||||||
# app/models/user.rb
|
# app/models/user.rb
|
||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
# Devise modules
|
devise :database_authenticatable,
|
||||||
devise :database_authenticatable, :registerable,
|
:registerable,
|
||||||
:recoverable, :rememberable, :validatable,
|
:recoverable,
|
||||||
:trackable, :lockable
|
:rememberable,
|
||||||
|
:validatable
|
||||||
|
|
||||||
# Roles
|
# ── Roles ──────────────────────────────────────────────────
|
||||||
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
|
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
|
||||||
|
|
||||||
# Validações
|
ROLES_LABEL = {
|
||||||
validates :name, presence: true
|
'admin' => 'Administrador',
|
||||||
validates :role, presence: true
|
'gerente' => 'Gerente',
|
||||||
validates :pin_acesso,
|
'operador' => 'Operador',
|
||||||
uniqueness: { allow_blank: true },
|
'motorista' => 'Motorista'
|
||||||
length: { is: 4, allow_blank: true },
|
}.freeze
|
||||||
format: { with: /\A\d{4}\z/, allow_blank: true, message: 'deve ter exatamente 4 dígitos numéricos' }
|
|
||||||
|
|
||||||
validate :pin_obrigatorio_para_motorista
|
# ── 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?
|
||||||
|
|
||||||
# Callbacks
|
validate :pin_unico_para_motoristas, if: :motorista?
|
||||||
before_validation :normalizar_pin
|
|
||||||
|
|
||||||
# Associações
|
# Motoristas podem não ter e-mail (login via PIN)
|
||||||
has_many :auditoria_logs, foreign_key: :user_id, dependent: :nullify
|
def email_required?
|
||||||
has_many :consolidacao_motoristas, foreign_key: :user_id, dependent: :restrict_with_error
|
!motorista?
|
||||||
|
end
|
||||||
|
|
||||||
# Scopes
|
def password_required?
|
||||||
scope :ativos, -> { where(ativo: true) }
|
!motorista? ? super : false
|
||||||
scope :motoristas, -> { where(role: :motorista) }
|
end
|
||||||
|
|
||||||
|
# ── Escopos ─────────────────────────────────────────────────
|
||||||
|
scope :ativos, -> { where(ativo: true) }
|
||||||
|
scope :por_nome, -> { order(:nome) }
|
||||||
|
scope :nao_motoristas, -> { where.not(role: :motorista) }
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────
|
||||||
def nome_display
|
def nome_display
|
||||||
name.split.first.capitalize
|
nome.presence || email
|
||||||
end
|
end
|
||||||
|
|
||||||
def active?
|
def role_label
|
||||||
ativo?
|
ROLES_LABEL[role] || role.humanize
|
||||||
end
|
end
|
||||||
|
|
||||||
def pode_acessar_admin?
|
def pode_ver_config?
|
||||||
admin? || gerente?
|
admin? || gerente?
|
||||||
end
|
end
|
||||||
|
|
||||||
def pode_criar_consolidacao?
|
def pode_consolidar?
|
||||||
admin? || gerente? || operador?
|
admin? || gerente? || operador?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def normalizar_pin
|
def pin_unico_para_motoristas
|
||||||
self.pin_acesso = pin_acesso.to_s.strip.presence
|
return if pin_code.blank?
|
||||||
end
|
|
||||||
|
|
||||||
def pin_obrigatorio_para_motorista
|
conflito = User.motorista.where(pin_code: pin_code).where.not(id: id)
|
||||||
if motorista? && pin_acesso.blank?
|
errors.add(:pin_code, 'já está em uso por outro motorista') if conflito.exists?
|
||||||
errors.add(:pin_acesso, 'é obrigatório para motoristas')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,44 +3,15 @@ class ApplicationPolicy
|
|||||||
attr_reader :user, :record
|
attr_reader :user, :record
|
||||||
|
|
||||||
def initialize(user, record)
|
def initialize(user, record)
|
||||||
raise Pundit::NotAuthorizedError, 'Você precisa estar logado.' unless user
|
|
||||||
@user = user
|
@user = user
|
||||||
@record = record
|
@record = record
|
||||||
end
|
end
|
||||||
|
|
||||||
def index? = false
|
def index? = user.pode_consolidar? || user.admin?
|
||||||
def show? = false
|
def show? = user.pode_consolidar? || user.admin?
|
||||||
def create? = false
|
def create? = user.pode_consolidar? || user.admin?
|
||||||
def new? = create?
|
def update? = user.pode_consolidar? || user.admin?
|
||||||
def update? = false
|
def destroy? = user.admin?
|
||||||
def edit? = update?
|
|
||||||
def destroy? = false
|
|
||||||
|
|
||||||
protected
|
|
||||||
|
|
||||||
def admin?
|
|
||||||
user.admin?
|
|
||||||
end
|
|
||||||
|
|
||||||
def gerente?
|
|
||||||
user.gerente?
|
|
||||||
end
|
|
||||||
|
|
||||||
def operador?
|
|
||||||
user.operador?
|
|
||||||
end
|
|
||||||
|
|
||||||
def motorista?
|
|
||||||
user.motorista?
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin_ou_gerente?
|
|
||||||
admin? || gerente?
|
|
||||||
end
|
|
||||||
|
|
||||||
def admin_gerente_ou_operador?
|
|
||||||
admin? || gerente? || operador?
|
|
||||||
end
|
|
||||||
|
|
||||||
class Scope
|
class Scope
|
||||||
def initialize(user, scope)
|
def initialize(user, scope)
|
||||||
@@ -49,7 +20,7 @@ class ApplicationPolicy
|
|||||||
end
|
end
|
||||||
|
|
||||||
def resolve
|
def resolve
|
||||||
raise NotImplementedError, "#{self.class}#resolve não implementado"
|
@scope.all
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
15
app/policies/consolidacao_policy.rb
Normal file
15
app/policies/consolidacao_policy.rb
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# app/policies/consolidacao_policy.rb
|
||||||
|
class ConsolidacaoPolicy < ApplicationPolicy
|
||||||
|
def index? = user.pode_consolidar?
|
||||||
|
def show? = user.pode_consolidar?
|
||||||
|
def create? = user.pode_consolidar?
|
||||||
|
def update? = user.pode_consolidar? && !record_finalizada_para_operador?
|
||||||
|
def destroy? = user.admin? || user.gerente?
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Operador não mexe em consolidação finalizada
|
||||||
|
def record_finalizada_para_operador?
|
||||||
|
record.respond_to?(:finalizada?) && record.finalizada? && user.operador?
|
||||||
|
end
|
||||||
|
end
|
||||||
10
app/policies/dashboard_policy.rb
Normal file
10
app/policies/dashboard_policy.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# app/policies/dashboard_policy.rb
|
||||||
|
class DashboardPolicy < Struct.new(:user, :dashboard)
|
||||||
|
def metricas?
|
||||||
|
user.admin? || user.gerente? || user.operador?
|
||||||
|
end
|
||||||
|
|
||||||
|
def index?
|
||||||
|
user.admin? || user.gerente?
|
||||||
|
end
|
||||||
|
end
|
||||||
74
app/views/consolidacao_entregas/revisar.html.erb
Normal file
74
app/views/consolidacao_entregas/revisar.html.erb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<%# app/views/consolidacao_entregas/revisar.html.erb — PASSO 3: Revisar %>
|
||||||
|
<% content_for :title, "Revisar — #{@motorista}" %>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= link_to '← Voltar (Passo 2)',
|
||||||
|
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
||||||
|
class: 'text-gray-500 hover:text-orange-500 text-sm' %>
|
||||||
|
<h1 class="text-2xl font-bold text-white mt-2">📋 Revisão — <%= @motorista %></h1>
|
||||||
|
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Resumo por tipo %>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||||
|
<% [
|
||||||
|
['entrega_normal', 'Normal', 'bg-orange-500 text-black'],
|
||||||
|
['retirada', 'Retirada', 'bg-orange-800 text-white'],
|
||||||
|
['bonus', 'Bônus', 'bg-white text-black'],
|
||||||
|
['desconto', 'Desconto', 'bg-black text-white border border-gray-700']
|
||||||
|
].each do |tipo, label, cores| %>
|
||||||
|
<div class="<%= cores %> rounded-xl p-4 text-center">
|
||||||
|
<p class="font-black text-3xl"><%= @resumo[tipo] || 0 %></p>
|
||||||
|
<p class="text-sm font-semibold"><%= label %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Total %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-orange-500 rounded-xl p-6 mb-6 text-center">
|
||||||
|
<p class="text-gray-400 text-sm mb-1">Total a pagar — <%= @motorista %></p>
|
||||||
|
<p class="text-orange-500 font-black text-4xl"><%= moeda(@total) %></p>
|
||||||
|
<p class="text-gray-500 text-xs mt-1"><%= @classificacoes.size %> entregas classificadas</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Detalhe das classificações %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden mb-6">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-[#0a0a0a] text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-4 py-3">Tracking / NF</th>
|
||||||
|
<th class="text-left px-4 py-3">Tipo</th>
|
||||||
|
<th class="text-right px-4 py-3">Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-[#2a2a2a]">
|
||||||
|
<% @classificacoes.each do |c| %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-white font-mono text-xs"><%= c.tracking_id %></td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="<%= c.tipo_cor[:bg] %> <%= c.tipo_cor[:text] %> px-2 py-1 rounded text-xs font-bold">
|
||||||
|
<%= c.tipo_cor[:label] %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-bold <%= c.desconto? ? 'text-red-400' : 'text-white' %>">
|
||||||
|
<%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Ações finais %>
|
||||||
|
<div class="flex flex-col md:flex-row gap-3">
|
||||||
|
<% if @proximo %>
|
||||||
|
<%= link_to "Próximo motorista: #{@proximo} →",
|
||||||
|
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @proximo),
|
||||||
|
class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to '💾 Salvar rascunho e voltar', wizard_consolidacao_path(@consolidacao),
|
||||||
|
class: 'flex-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] font-bold py-3.5 rounded-lg min-h-[48px] flex items-center justify-center' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
166
app/views/consolidacao_entregas/validar.html.erb
Normal file
166
app/views/consolidacao_entregas/validar.html.erb
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<%# app/views/consolidacao_entregas/validar.html.erb — PASSO 2: Validar entregas %>
|
||||||
|
<% content_for :title, "Validar — #{@motorista}" %>
|
||||||
|
|
||||||
|
<div class="max-w-6xl mx-auto" data-controller="validacao"
|
||||||
|
data-validacao-url-value="<%= classificar_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
|
data-validacao-massa-url-value="<%= classificar_em_massa_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||||
|
data-validacao-motorista-value="<%= @motorista %>"
|
||||||
|
data-validacao-total-value="<%= @entregas.size + @classificacoes.size - (@entregas.count { |e| @classificacoes.key?(e.tracking_id) }) %>">
|
||||||
|
|
||||||
|
<%# Header %>
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao), class: 'text-gray-500 hover:text-orange-500 text-sm' %>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">🚚 <%= @motorista %></h1>
|
||||||
|
<p class="text-gray-400 text-sm"><%= @consolidacao.nome %> · <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %></p>
|
||||||
|
</div>
|
||||||
|
<%= link_to 'Revisar (Passo 3) →',
|
||||||
|
revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
||||||
|
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Barra de progresso %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4">
|
||||||
|
<div class="flex justify-between text-sm mb-2">
|
||||||
|
<span class="text-white font-semibold">
|
||||||
|
<span data-validacao-target="contadorClassificadas"><%= @classificacoes.size %></span>
|
||||||
|
de <span data-validacao-target="contadorTotal"><%= @entregas.size %></span> entregas classificadas
|
||||||
|
</span>
|
||||||
|
<span class="text-orange-500 font-bold" data-validacao-target="percentual"></span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-[#0a0a0a] rounded-full h-3">
|
||||||
|
<div data-validacao-target="barra" class="h-3 rounded-full bg-orange-500 transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Ações em massa + filtro %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<span class="text-gray-400 text-sm font-semibold">Ações em massa:</span>
|
||||||
|
|
||||||
|
<button data-action="click->validacao#marcarTodos" data-tipo="entrega_normal"
|
||||||
|
class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-2.5 rounded-lg text-sm min-h-[44px]">
|
||||||
|
✓ Marcar todos como Normal
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 text-sm">Selecionados como:</span>
|
||||||
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="entrega_normal"
|
||||||
|
class="bg-orange-500 text-black font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Normal</button>
|
||||||
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="retirada"
|
||||||
|
class="bg-orange-800 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Retirada</button>
|
||||||
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="bonus"
|
||||||
|
class="bg-white text-black font-bold px-3 py-2 rounded-lg text-xs border border-orange-500 min-h-[44px]">Bônus</button>
|
||||||
|
<button data-action="click->validacao#marcarSelecionados" data-tipo="desconto"
|
||||||
|
class="bg-black text-white font-bold px-3 py-2 rounded-lg text-xs border border-gray-700 min-h-[44px]">Desconto</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto">
|
||||||
|
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
||||||
|
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista,
|
||||||
|
apenas_pendentes: params[:apenas_pendentes] == '1' ? nil : '1'),
|
||||||
|
class: 'text-orange-400 hover:text-orange-300 text-sm border border-orange-500/40 px-3 py-2 rounded-lg' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
|
|
||||||
|
<%# ── Lista de entregas (3 colunas) ─────────────────── %>
|
||||||
|
<div class="lg:col-span-3 space-y-2">
|
||||||
|
<% if @entregas.any? %>
|
||||||
|
<% @entregas.each do |e| %>
|
||||||
|
<% classificacao = @classificacoes[e.tracking_id] %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 flex flex-col md:flex-row md:items-center gap-3"
|
||||||
|
data-validacao-target="linha" data-tracking="<%= e.tracking_id %>"
|
||||||
|
data-classificada="<%= classificacao ? '1' : '0' %>">
|
||||||
|
|
||||||
|
<%# Checkbox seleção em massa %>
|
||||||
|
<input type="checkbox" data-validacao-target="checkbox" value="<%= e.tracking_id %>"
|
||||||
|
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
||||||
|
|
||||||
|
<%# Dados da entrega %>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-white font-semibold truncate">
|
||||||
|
NF <%= e.numero_nf %> · <%= e.local %>
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500 text-xs">
|
||||||
|
📅 <%= e.planned_date %> · 📍 <%= e.route_id.to_s.first(8) %>…
|
||||||
|
<% if e.atraso_minutos > 0 %> · ⏱️ atraso <%= e.atraso_minutos %>min<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Toggles coloridos — laranja=normal, laranja escuro=retirada, branco=bônus, preto=desconto %>
|
||||||
|
<div class="flex gap-1.5" data-validacao-target="toggles" data-tracking="<%= e.tracking_id %>">
|
||||||
|
<% [
|
||||||
|
['entrega_normal', 'Normal', 'bg-orange-500 text-black'],
|
||||||
|
['retirada', 'Retirada', 'bg-orange-800 text-white'],
|
||||||
|
['bonus', 'Bônus', 'bg-white text-black border border-orange-500'],
|
||||||
|
['desconto', 'Desc.', 'bg-black text-white border border-gray-700']
|
||||||
|
].each do |tipo, label, cores| %>
|
||||||
|
<button data-action="click->validacao#classificar"
|
||||||
|
data-tracking="<%= e.tracking_id %>" data-tipo="<%= tipo %>"
|
||||||
|
class="toggle-btn <%= cores %> font-bold px-3 py-2.5 rounded-lg text-xs min-h-[44px] transition-all
|
||||||
|
<%= classificacao&.tipo == tipo ? 'ring-2 ring-green-400 scale-105' : 'opacity-50 hover:opacity-100' %>">
|
||||||
|
<%= label %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-10 text-center">
|
||||||
|
<p class="text-4xl mb-3">🎉</p>
|
||||||
|
<p class="text-gray-400">Nenhuma entrega pendente neste filtro.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Resumo lateral (1 coluna, sticky) ─────────────── %>
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 sticky top-4 space-y-4">
|
||||||
|
<h3 class="text-white font-bold">📊 Resumo</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-orange-500 rounded-full"></span> Normal</span>
|
||||||
|
<span class="text-white font-bold" data-validacao-target="qtdNormal">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-orange-800 rounded-full"></span> Retirada</span>
|
||||||
|
<span class="text-white font-bold" data-validacao-target="qtdRetirada">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-white rounded-full border border-orange-500"></span> Bônus</span>
|
||||||
|
<span class="text-white font-bold" data-validacao-target="qtdBonus">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-black border border-gray-600 rounded-full"></span> Desconto</span>
|
||||||
|
<span class="text-white font-bold" data-validacao-target="qtdDesconto">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-[#2a2a2a] pt-4">
|
||||||
|
<p class="text-gray-500 text-xs mb-1">Total do motorista</p>
|
||||||
|
<p class="text-orange-500 font-black text-2xl" data-validacao-target="valorTotal">R$ 0,00</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-[#2a2a2a] pt-4 text-xs text-gray-500 space-y-1">
|
||||||
|
<p>💰 Normal: <%= moeda(@precos[:entrega_normal]) %></p>
|
||||||
|
<p>📦 Retirada: <%= moeda(@precos[:retirada]) %></p>
|
||||||
|
<p>⭐ Bônus: <%= moeda(@precos[:bonus]) %></p>
|
||||||
|
<p>⚠️ Desconto: -<%= moeda(@precos[:desconto]) %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Estado inicial para o Stimulus %>
|
||||||
|
<script type="application/json" id="validacao-estado-inicial">
|
||||||
|
<%= raw({
|
||||||
|
classificadas: @classificacoes.size,
|
||||||
|
por_tipo: @classificacoes.values.group_by(&:tipo).transform_values(&:size),
|
||||||
|
valor_total: @classificacoes.values.sum { |c| c.tipo == 'desconto' ? -c.valor_aplicado : c.valor_aplicado }.to_f
|
||||||
|
}.to_json) %>
|
||||||
|
</script>
|
||||||
65
app/views/consolidacoes/index.html.erb
Normal file
65
app/views/consolidacoes/index.html.erb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<%# app/views/consolidacoes/index.html.erb %>
|
||||||
|
<% content_for :title, 'Consolidações' %>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-white">📦 Consolidações</h1>
|
||||||
|
<%= link_to '+ Nova Consolidação', new_consolidacao_path,
|
||||||
|
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# ── Filtros ──────────────────────────────────────────── %>
|
||||||
|
<%= form_with url: consolidacoes_path, method: :get,
|
||||||
|
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-5 gap-3' do |f| %>
|
||||||
|
<%= f.text_field :nome, value: params[:nome], placeholder: 'Buscar por nome…',
|
||||||
|
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 focus:border-orange-500 focus:outline-none' %>
|
||||||
|
|
||||||
|
<%= f.select :status,
|
||||||
|
options_for_select([['Todos os status', ''], ['📝 Rascunho', 'rascunho'], ['✅ Finalizada', 'finalizada']], params[:status]),
|
||||||
|
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
|
<%= f.date_field :inicio, value: params[:inicio],
|
||||||
|
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
|
<%= f.date_field :fim, value: params[:fim],
|
||||||
|
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5' %>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<%= f.submit 'Filtrar', class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-lg cursor-pointer' %>
|
||||||
|
<%= link_to 'Limpar', consolidacoes_path, class: 'px-4 py-2.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# ── Lista ────────────────────────────────────────────── %>
|
||||||
|
<% if @consolidacoes.any? %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @consolidacoes.each do |c| %>
|
||||||
|
<%= link_to consolidacao_path(c),
|
||||||
|
class: 'block bg-[#1a1a1a] border border-[#2a2a2a] hover:border-orange-500 rounded-xl p-5 transition-colors' do %>
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-1">
|
||||||
|
<h3 class="text-white font-bold text-lg"><%= c.nome %></h3>
|
||||||
|
<%= badge_status(c.status) %>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
📅 <%= l c.data_inicio, format: :short %> → <%= l c.data_fim, format: :short %>
|
||||||
|
· 👤 <%= c.consolidacao_motoristas.count %> motorista(s)
|
||||||
|
· Criada por <%= c.criador.nome_display %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-orange-500 font-black text-2xl"><%= moeda(c.valor_total) %></p>
|
||||||
|
<p class="text-gray-500 text-xs"><%= c.consolidacao_entregas.count %> entregas classificadas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-12 text-center">
|
||||||
|
<p class="text-5xl mb-4">📭</p>
|
||||||
|
<p class="text-gray-400 mb-4">Nenhuma consolidação encontrada.</p>
|
||||||
|
<%= link_to 'Criar primeira consolidação', new_consolidacao_path,
|
||||||
|
class: 'inline-block bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
89
app/views/consolidacoes/new.html.erb
Normal file
89
app/views/consolidacoes/new.html.erb
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<%# app/views/consolidacoes/new.html.erb %>
|
||||||
|
<% content_for :title, 'Nova Consolidação' %>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold text-white mb-6">➕ Nova Consolidação</h1>
|
||||||
|
|
||||||
|
<%= form_with model: @consolidacao, class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-6 space-y-6' do |f| %>
|
||||||
|
|
||||||
|
<% if @consolidacao.errors.any? %>
|
||||||
|
<div class="bg-red-900/40 border border-red-600 rounded-lg p-4">
|
||||||
|
<p class="text-red-400 font-bold mb-1">⚠️ Corrija os erros:</p>
|
||||||
|
<ul class="text-red-300 text-sm list-disc list-inside">
|
||||||
|
<% @consolidacao.errors.full_messages.each do |msg| %>
|
||||||
|
<li><%= msg %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%# Nome %>
|
||||||
|
<div>
|
||||||
|
<%= f.label :nome, 'Nome da consolidação', class: 'block text-white font-semibold mb-2' %>
|
||||||
|
<%= f.text_field :nome, placeholder: 'Ex: Pagamento Janeiro/2026 — Quinzena 1',
|
||||||
|
class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none text-base' %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Período %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= f.label :data_inicio, 'Data início', class: 'block text-white font-semibold mb-2' %>
|
||||||
|
<%= f.date_field :data_inicio,
|
||||||
|
class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none' %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :data_fim, 'Data fim', class: 'block text-white font-semibold mb-2' %>
|
||||||
|
<%= f.date_field :data_fim,
|
||||||
|
class: 'w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-4 py-3 focus:border-orange-500 focus:outline-none' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Motoristas — multi-select %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white font-semibold mb-2">
|
||||||
|
Motoristas <span class="text-orange-500">*</span>
|
||||||
|
<span class="text-gray-500 font-normal text-sm">(<%= @motoristas.size %> encontrados no banco)</span>
|
||||||
|
</label>
|
||||||
|
<div class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-64 overflow-y-auto space-y-1">
|
||||||
|
<% if @motoristas.any? %>
|
||||||
|
<label class="flex items-center gap-2 text-orange-400 text-sm font-semibold pb-2 border-b border-[#2a2a2a] cursor-pointer">
|
||||||
|
<input type="checkbox" onclick="document.querySelectorAll('.chk-motorista').forEach(c => c.checked = this.checked)"
|
||||||
|
class="rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]">
|
||||||
|
Selecionar todos
|
||||||
|
</label>
|
||||||
|
<% @motoristas.each do |m| %>
|
||||||
|
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer min-h-[44px]">
|
||||||
|
<%= check_box_tag 'motoristas[]', m, false, class: 'chk-motorista rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %>
|
||||||
|
<%= m %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-500 text-sm p-2">Nenhum motorista encontrado na tabela de entregas.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Rotas — multi-select opcional %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-white font-semibold mb-2">
|
||||||
|
Rotas <span class="text-gray-500 font-normal text-sm">(opcional — filtra entregas por operação)</span>
|
||||||
|
</label>
|
||||||
|
<div class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1">
|
||||||
|
<% @rotas.each do |r| %>
|
||||||
|
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm font-mono min-h-[44px]">
|
||||||
|
<%= check_box_tag 'consolidacao[route_ids][]', r, false, class: 'rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %>
|
||||||
|
<%= r %>
|
||||||
|
</label>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Ações %>
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<%= f.submit 'Criar e iniciar validação →',
|
||||||
|
class: 'flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3.5 rounded-lg min-h-[48px] cursor-pointer transition-colors text-base' %>
|
||||||
|
<%= link_to 'Cancelar', consolidacoes_path,
|
||||||
|
class: 'px-6 py-3.5 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg min-h-[48px] flex items-center' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
91
app/views/consolidacoes/wizard.html.erb
Normal file
91
app/views/consolidacoes/wizard.html.erb
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<%# app/views/consolidacoes/wizard.html.erb — PASSO 1: Selecionar motorista %>
|
||||||
|
<% content_for :title, "Wizard — #{@consolidacao.nome}" %>
|
||||||
|
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
<%# Header do wizard %>
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= link_to '← Voltar para consolidações', consolidacoes_path, class: 'text-gray-500 hover:text-orange-500 text-sm' %>
|
||||||
|
<h1 class="text-2xl font-bold text-white mt-2"><%= @consolidacao.nome %></h1>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>
|
||||||
|
· <%= badge_status(@consolidacao.status) %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Indicador de passos %>
|
||||||
|
<div class="flex items-center gap-2 mb-8">
|
||||||
|
<div class="flex items-center gap-2 text-orange-500 font-bold">
|
||||||
|
<span class="w-8 h-8 bg-orange-500 text-black rounded-full flex items-center justify-center">1</span>
|
||||||
|
Selecionar motorista
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-[#2a2a2a]"></div>
|
||||||
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
|
<span class="w-8 h-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-full flex items-center justify-center">2</span>
|
||||||
|
Validar entregas
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-[#2a2a2a]"></div>
|
||||||
|
<div class="flex items-center gap-2 text-gray-600">
|
||||||
|
<span class="w-8 h-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-full flex items-center justify-center">3</span>
|
||||||
|
Revisar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Lista de motoristas %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @motoristas.each do |m| %>
|
||||||
|
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: m[:registro].motorista_nome),
|
||||||
|
class: "block bg-[#1a1a1a] border rounded-xl p-5 transition-colors
|
||||||
|
#{m[:completo] ? 'border-green-600 hover:border-green-500' : 'border-[#2a2a2a] hover:border-orange-500'}" do %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-full flex items-center justify-center text-xl font-black
|
||||||
|
<%= m[:completo] ? 'bg-green-600 text-white' : 'bg-orange-500 text-black' %>">
|
||||||
|
<%= m[:completo] ? '✓' : m[:registro].motorista_nome.first.upcase %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold text-lg"><%= m[:registro].motorista_nome %></p>
|
||||||
|
<p class="text-gray-400 text-sm">
|
||||||
|
<%= m[:total] %> entregas pagas no período
|
||||||
|
· <span class="<%= m[:completo] ? 'text-green-400' : 'text-orange-400' %>">
|
||||||
|
<%= m[:classificadas] %>/<%= m[:total] %> classificadas
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-orange-500 font-bold"><%= moeda(m[:registro].valor_total) %></p>
|
||||||
|
<span class="text-gray-600 text-2xl">→</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<% pct = m[:total].zero? ? 0 : (m[:classificadas] * 100 / m[:total]) %>
|
||||||
|
<div class="w-full bg-[#0a0a0a] rounded-full h-2">
|
||||||
|
<div class="h-2 rounded-full <%= m[:completo] ? 'bg-green-500' : 'bg-orange-500' %> transition-all"
|
||||||
|
style="width: <%= pct %>%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# Finalizar — só habilita se tudo classificado %>
|
||||||
|
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
|
||||||
|
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-white font-bold">Total da consolidação</p>
|
||||||
|
<p class="text-orange-500 font-black text-3xl"><%= moeda(@consolidacao.valor_total) %></p>
|
||||||
|
</div>
|
||||||
|
<% if @consolidacao.rascunho? %>
|
||||||
|
<%= button_to 'Finalizar consolidação ✓', finalizar_consolidacao_path(@consolidacao),
|
||||||
|
method: :post,
|
||||||
|
disabled: !tudo_completo,
|
||||||
|
data: { turbo_confirm: 'Finalizar? Após finalizada, a consolidação não poderá ser editada por operadores.' },
|
||||||
|
class: "font-bold px-6 py-3.5 rounded-lg min-h-[48px] transition-colors
|
||||||
|
#{tudo_completo ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% unless tudo_completo %>
|
||||||
|
<p class="text-gray-500 text-sm mt-2 text-center">⚠️ Classifique todas as entregas de todos os motoristas para poder finalizar.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -1,169 +1,95 @@
|
|||||||
<%# app/views/layouts/application.html.erb %>
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="pt-BR" class="<%= @tema == 'light' ? '' : 'dark' %>">
|
<html lang="pt-BR" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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 %>">
|
<meta name="csrf-token" content="<%= form_authenticity_token %>">
|
||||||
<title><%= content_for?(:title) ? yield(:title) + ' · ' : '' %>Gade Logística</title>
|
<title><%= content_for?(:title) ? "#{yield(:title)} | Gade Logística" : "Gade Logística" %></title>
|
||||||
|
|
||||||
<%= stylesheet_link_tag 'application', data: { turbo_track: 'reload' } %>
|
<%# Tailwind via CDN em desenvolvimento — compilar em produção %>
|
||||||
<%= javascript_importmap_tags %>
|
<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>
|
<style>
|
||||||
:root {
|
body { font-family: 'Inter', sans-serif; }
|
||||||
--bg-main: #0a0a0a;
|
/* Scrollbar tema escuro */
|
||||||
--bg-card: #1a1a1a;
|
::-webkit-scrollbar { width: 6px; }
|
||||||
--accent: #f97316;
|
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||||
--text-main: #ffffff;
|
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||||
--text-muted:#9ca3af;
|
/* Loading spinner laranja */
|
||||||
--border: rgba(255,255,255,0.05);
|
.spinner {
|
||||||
|
border: 3px solid #1a1a1a;
|
||||||
|
border-top-color: #f97316;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
html.light {
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
--bg-main: #f8fafc;
|
|
||||||
--bg-card: #ffffff;
|
|
||||||
--text-main: #0f172a;
|
|
||||||
--text-muted:#64748b;
|
|
||||||
--border: rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
body { background-color: var(--bg-main); color: var(--text-main); }
|
|
||||||
.scrollbar-hidden::-webkit-scrollbar { display: none; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="font-sans antialiased min-h-screen" style="background-color: var(--bg-main)">
|
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||||
|
|
||||||
<% if user_signed_in? && !current_user.motorista? %>
|
<%# ── Navbar ─────────────────────────────────────────── %>
|
||||||
<%# Layout principal com sidebar %>
|
<% if user_signed_in? %>
|
||||||
<div class="flex min-h-screen">
|
<%= render 'layouts/navbar' %>
|
||||||
|
|
||||||
<%# Sidebar Desktop %>
|
|
||||||
<aside id="sidebar"
|
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 flex-col hidden lg:flex
|
|
||||||
border-r border-white/5"
|
|
||||||
style="background-color: var(--bg-card)">
|
|
||||||
<%= render 'layouts/sidebar' %>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<%# Overlay mobile %>
|
|
||||||
<div id="sidebar-overlay"
|
|
||||||
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm hidden lg:hidden"
|
|
||||||
onclick="toggleSidebar()"></div>
|
|
||||||
|
|
||||||
<%# Sidebar Mobile (drawer) %>
|
|
||||||
<aside id="sidebar-mobile"
|
|
||||||
class="fixed inset-y-0 left-0 z-50 w-64 flex flex-col lg:hidden
|
|
||||||
transform -translate-x-full transition-transform duration-300 ease-in-out
|
|
||||||
border-r border-white/5"
|
|
||||||
style="background-color: var(--bg-card)">
|
|
||||||
<%= render 'layouts/sidebar' %>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<%# Main content %>
|
|
||||||
<div class="flex-1 lg:ml-64 flex flex-col min-h-screen">
|
|
||||||
<%# Topbar %>
|
|
||||||
<header class="sticky top-0 z-30 px-4 lg:px-6 py-4 flex items-center justify-between
|
|
||||||
border-b border-white/5 backdrop-blur-md"
|
|
||||||
style="background-color: rgba(10,10,10,0.8)">
|
|
||||||
<%# Hamburger mobile %>
|
|
||||||
<button onclick="toggleSidebar()"
|
|
||||||
class="lg:hidden p-2 text-gray-400 hover:text-white rounded-lg hover:bg-white/10 transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<%# Breadcrumb / título da página %>
|
|
||||||
<div class="flex-1 lg:ml-0 ml-3">
|
|
||||||
<span class="text-white font-semibold text-sm lg:text-base">
|
|
||||||
<%= content_for?(:page_title) ? yield(:page_title) : 'Dashboard' %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# Ações do header %>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<%# Toggle tema %>
|
|
||||||
<%= button_to toggle_tema_configuracoes_path, method: :post,
|
|
||||||
class: 'p-2 text-gray-400 hover:text-white hover:bg-white/10
|
|
||||||
rounded-lg transition-colors cursor-pointer',
|
|
||||||
title: @tema == 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro' do %>
|
|
||||||
<% if @tema == 'dark' %>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
|
||||||
</svg>
|
|
||||||
<% else %>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
|
||||||
</svg>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%# Avatar / menu usuário %>
|
|
||||||
<div class="relative" x-data="{ open: false }">
|
|
||||||
<button class="flex items-center gap-2 p-1.5 rounded-xl hover:bg-white/10 transition-colors">
|
|
||||||
<div class="w-8 h-8 rounded-full bg-[#f97316]/20 border border-[#f97316]/30
|
|
||||||
flex items-center justify-center text-[#f97316] font-bold text-sm">
|
|
||||||
<%= current_user.name.to_s[0].upcase %>
|
|
||||||
</div>
|
|
||||||
<span class="hidden sm:block text-white text-sm font-medium pr-1">
|
|
||||||
<%= current_user.nome_display %>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%# Logout %>
|
|
||||||
<%= button_to destroy_user_session_path, method: :delete,
|
|
||||||
class: 'px-3 py-2 text-gray-500 hover:text-red-400 text-sm
|
|
||||||
hover:bg-red-900/20 rounded-lg transition-colors cursor-pointer' do %>
|
|
||||||
Sair
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%# Conteúdo principal %>
|
|
||||||
<main class="flex-1 p-4 lg:p-6">
|
|
||||||
<%= yield %>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% elsif user_signed_in? && current_user.motorista? %>
|
|
||||||
<%# Layout simplificado para motorista %>
|
|
||||||
<div class="min-h-screen" style="background-color: var(--bg-main)">
|
|
||||||
<main class="max-w-2xl mx-auto px-4 py-6">
|
|
||||||
<%= yield %>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% else %>
|
|
||||||
<%# Layout sem sidebar (login, etc) %>
|
|
||||||
<%= yield %>
|
|
||||||
<% end %>
|
<% 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>
|
<script>
|
||||||
function toggleSidebar() {
|
setTimeout(() => {
|
||||||
const mobile = document.getElementById('sidebar-mobile');
|
['flash-notice', 'flash-alert'].forEach(id => {
|
||||||
const overlay = document.getElementById('sidebar-overlay');
|
const el = document.getElementById(id);
|
||||||
const isOpen = !mobile.classList.contains('-translate-x-full');
|
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||||
|
setTimeout(() => el.remove(), 500);
|
||||||
if (isOpen) {
|
});
|
||||||
mobile.classList.add('-translate-x-full');
|
}, 4000);
|
||||||
overlay.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
mobile.classList.remove('-translate-x-full');
|
|
||||||
overlay.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fecha sidebar ao pressionar Escape
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const mobile = document.getElementById('sidebar-mobile');
|
|
||||||
if (mobile && !mobile.classList.contains('-translate-x-full')) toggleSidebar();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,58 +1,56 @@
|
|||||||
# config/routes.rb
|
# config/routes.rb
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
# Devise com controller customizado para sessões
|
# Devise — login padrão para admin/gerente/operador
|
||||||
devise_for :users,
|
devise_for :users, path: 'auth', path_names: {
|
||||||
controllers: {
|
sign_in: 'login',
|
||||||
sessions: 'users/sessions'
|
sign_out: 'logout',
|
||||||
}
|
password: 'senha'
|
||||||
|
}
|
||||||
|
|
||||||
# Root
|
# Root — redireciona por role
|
||||||
authenticated :user do
|
root 'dashboard#index'
|
||||||
root 'dashboard#index', as: :authenticated_root
|
|
||||||
end
|
|
||||||
root 'devise/sessions#new'
|
|
||||||
|
|
||||||
# Dashboard (Fase 3)
|
# Dashboard principal (admin/gerente/operador)
|
||||||
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
get '/dashboard', to: 'dashboard#index', as: :dashboard
|
||||||
|
|
||||||
# Painel motorista (Fase 2 / 8)
|
# Painel motorista
|
||||||
get '/motorista', to: 'motorista#index', as: :motorista
|
get '/motorista', to: 'motorista/dashboard#index', as: :motorista_dashboard
|
||||||
|
|
||||||
# Usuários (Fase 2)
|
# Consolidações
|
||||||
resources :usuarios do
|
|
||||||
member do
|
|
||||||
post :toggle_ativo
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Configurações (Fase 2)
|
|
||||||
resources :configuracoes, only: [:index, :edit, :update] do
|
|
||||||
collection do
|
|
||||||
post :toggle_tema
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Consolidações (Fases 5 e 6)
|
|
||||||
resources :consolidacoes do
|
resources :consolidacoes do
|
||||||
member do
|
member do
|
||||||
patch :fechar
|
get :wizard # Passo 1 — selecionar motorista
|
||||||
patch :aprovar
|
post :finalizar
|
||||||
patch :cancelar
|
post :arquivar
|
||||||
post :reabrir
|
get :preview_holerite
|
||||||
|
get :gerar_pdf_relatorio
|
||||||
|
get :gerar_pdf_holerite
|
||||||
|
end
|
||||||
|
|
||||||
|
resources :consolidacao_entregas, only: [] do
|
||||||
|
collection do
|
||||||
|
get :validar # Passo 2 — toggles de classificação
|
||||||
|
post :classificar # toggle individual (JSON)
|
||||||
|
post :classificar_em_massa # ações em massa (JSON)
|
||||||
|
get :revisar # Passo 3 — revisão do motorista
|
||||||
|
end
|
||||||
end
|
end
|
||||||
resources :consolidacao_motoristas, only: [:create, :update, :destroy], shallow: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Histórico estimado (Fase 4)
|
# Configurações (apenas admin/gerente)
|
||||||
resources :historico_estimados, only: [:index, :show]
|
namespace :admin do
|
||||||
|
resources :usuarios, except: [:show]
|
||||||
|
resources :configuracoes, only: [:index, :update]
|
||||||
|
resources :auditoria_logs, only: [:index]
|
||||||
|
end
|
||||||
|
|
||||||
# Logs de auditoria (Fase 4)
|
# API interna — Dashboard métricas
|
||||||
resources :auditoria_logs, only: [:index, :show]
|
namespace :api do
|
||||||
|
namespace :v1 do
|
||||||
|
get 'dashboard/metricas', to: 'dashboard#metricas'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# PDFs (Fase 7)
|
# Health check (Docker)
|
||||||
# get '/consolidacoes/:id/pdf', to: 'pdfs#consolidacao', as: :pdf_consolidacao
|
get '/health', to: proc { [200, {}, ['OK']] }
|
||||||
# get '/consolidacao_motoristas/:id/pdf', to: 'pdfs#holerite', as: :pdf_holerite
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
get '/health', to: proc { [200, {}, ['ok']] }
|
|
||||||
end
|
end
|
||||||
|
|||||||
12
config/schedule.rb
Normal file
12
config/schedule.rb
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# config/schedule.rb
|
||||||
|
# Agendamento com Whenever gem
|
||||||
|
# Atualizar crontab: bundle exec whenever --update-crontab
|
||||||
|
# Ver crontab gerado: bundle exec whenever
|
||||||
|
|
||||||
|
set :output, "log/cron.log"
|
||||||
|
set :environment, ENV.fetch("RAILS_ENV", "production")
|
||||||
|
|
||||||
|
# A cada 1 hora — atualiza o valor estimado do dia
|
||||||
|
every 1.hour do
|
||||||
|
rake "historico:atualizar"
|
||||||
|
end
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddRouteIdsToConsolidacoes < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :consolidacoes, :route_ids, :jsonb, default: []
|
||||||
|
add_index :consolidacoes, :route_ids, using: :gin
|
||||||
|
end
|
||||||
|
end
|
||||||
101
db/seeds.rb
101
db/seeds.rb
@@ -1,78 +1,47 @@
|
|||||||
# db/seeds.rb
|
# db/seeds.rb
|
||||||
# Rodar com: docker-compose exec app bundle exec rails db:seed
|
# Dados iniciais do sistema Gade Hospitalar
|
||||||
|
# Execute com: bundle exec rails db:seed
|
||||||
|
|
||||||
puts "🌱 Iniciando seeds..."
|
puts "🌱 Criando dados iniciais..."
|
||||||
|
|
||||||
# ─── Configurações de preço (Fase 1 + confirmação) ───────────────────────────
|
# ── Usuário Admin padrão ─────────────────────────────────────
|
||||||
configs = [
|
admin = User.find_or_create_by!(email: 'admin@gade.com') do |u|
|
||||||
{ tipo: :entrega, valor: 10.50, descricao: 'Entrega padrão para UBS, EMAD, STS, SAD' },
|
u.nome = 'Administrador Gade'
|
||||||
{ tipo: :retirada, valor: 8.00, descricao: 'Retirada de material nos pontos de origem' },
|
u.password = 'Gade@2026!'
|
||||||
{ tipo: :bonus, valor: 5.00, descricao: 'Bônus por desempenho / entregas especiais' },
|
u.role = :admin
|
||||||
{ tipo: :desconto, valor: 2.00, descricao: 'Desconto aplicado por ocorrência' }
|
u.ativo = true
|
||||||
]
|
|
||||||
|
|
||||||
configs.each do |c|
|
|
||||||
Configuracao.find_or_create_by!(tipo: c[:tipo]) do |cfg|
|
|
||||||
cfg.valor = c[:valor]
|
|
||||||
cfg.descricao = c[:descricao]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
puts " ✅ #{Configuracao.count} configurações de preço"
|
puts " ✅ Admin criado: #{admin.email} / senha: Gade@2026!"
|
||||||
|
|
||||||
# ─── Usuários ────────────────────────────────────────────────────────────────
|
# ── Usuário Gerente de exemplo ───────────────────────────────
|
||||||
usuarios = [
|
gerente = User.find_or_create_by!(email: 'gerente@gade.com') do |u|
|
||||||
{
|
u.nome = 'Gerente Gade'
|
||||||
name: 'Admin Gade',
|
u.password = 'Gade@2026!'
|
||||||
email: 'admin@gade.com',
|
u.role = :gerente
|
||||||
password: 'Gade@2026!',
|
u.ativo = true
|
||||||
role: :admin,
|
end
|
||||||
ativo: true
|
puts " ✅ Gerente criado: #{gerente.email}"
|
||||||
},
|
|
||||||
{
|
# ── Configurações de preço padrão ────────────────────────────
|
||||||
name: 'Gerente Operações',
|
configs = [
|
||||||
email: 'gerente@gade.com',
|
{ chave: 'preco_entrega', valor: '15.00', descricao: 'Valor pago por entrega bem-sucedida (R$)' },
|
||||||
password: 'Gade@2026!',
|
{ chave: 'preco_retirada', valor: '20.00', descricao: 'Valor pago por retirada de equipamento (R$)' },
|
||||||
role: :gerente,
|
{ chave: 'preco_bonus', valor: '10.00', descricao: 'Valor de bônus por meta/critério (R$)' },
|
||||||
ativo: true
|
{ 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)' },
|
||||||
name: 'Operador Entregas',
|
{ chave: 'empresa_nome', valor: 'Gade Hospitalar', descricao: 'Nome da empresa exibido no sistema' },
|
||||||
email: 'operador@gade.com',
|
|
||||||
password: 'Gade@2026!',
|
|
||||||
role: :operador,
|
|
||||||
ativo: true
|
|
||||||
},
|
|
||||||
# Motoristas com PIN
|
|
||||||
{ name: 'João Silva', role: :motorista, pin_acesso: '1234', ativo: true },
|
|
||||||
{ name: 'Maria Oliveira', role: :motorista, pin_acesso: '5678', ativo: true },
|
|
||||||
{ name: 'Carlos Santos', role: :motorista, pin_acesso: '9012', ativo: true }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
usuarios.each do |u|
|
configs.each do |cfg|
|
||||||
user = User.find_or_initialize_by(
|
Configuracao.find_or_create_by!(chave: cfg[:chave]) do |c|
|
||||||
u[:role] == :motorista ? { name: u[:name], role: :motorista } : { email: u[:email] }
|
c.valor = cfg[:valor]
|
||||||
)
|
c.descricao = cfg[:descricao]
|
||||||
|
|
||||||
user.assign_attributes(u.except(:email))
|
|
||||||
user.email = u[:email] || "#{u[:name].downcase.gsub(' ', '.')}@gade.local"
|
|
||||||
user.password = u[:password] || SecureRandom.hex(12) if user.new_record?
|
|
||||||
|
|
||||||
if user.save
|
|
||||||
puts " ✅ #{user.role.upcase}: #{user.name}"
|
|
||||||
else
|
|
||||||
puts " ⚠️ #{user.name}: #{user.errors.full_messages.join(', ')}"
|
|
||||||
end
|
end
|
||||||
|
puts " ✅ Config: #{cfg[:chave]} = #{cfg[:valor]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
puts ""
|
puts ""
|
||||||
puts "✅ Seeds concluídos!"
|
puts "✅ Seeds concluídos!"
|
||||||
puts ""
|
puts " Admin: admin@gade.com / Gade@2026!"
|
||||||
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!"
|
||||||
puts " Admin: admin@gade.com / Gade@2026!"
|
|
||||||
puts " Gerente: gerente@gade.com / Gade@2026!"
|
|
||||||
puts " Operador: operador@gade.com / Gade@2026!"
|
|
||||||
puts " Motorista João: PIN 1234"
|
|
||||||
puts " Motorista Maria: PIN 5678"
|
|
||||||
puts " Motorista Carlos: PIN 9012"
|
|
||||||
puts " ⚠️ ALTERE AS SENHAS NO PRIMEIRO ACESSO!"
|
|
||||||
puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
|
|||||||
13
lib/tasks/historico.rake
Normal file
13
lib/tasks/historico.rake
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# lib/tasks/historico.rake
|
||||||
|
namespace :historico do
|
||||||
|
desc "Atualiza o histórico de valor estimado (rodado a cada 1h via cron/whenever)"
|
||||||
|
task atualizar: :environment do
|
||||||
|
puts "[#{Time.current}] Atualizando histórico estimado..."
|
||||||
|
registro = AtualizarHistoricoEstimadoJob.perform_now
|
||||||
|
puts "[#{Time.current}] OK — #{registro.entregas_contadas} entregas | R$ #{registro.valor_total_estimado}"
|
||||||
|
rescue => e
|
||||||
|
puts "[#{Time.current}] ERRO: #{e.message}"
|
||||||
|
Rails.logger.error("[historico:atualizar] #{e.message}\n#{e.backtrace.first(5).join("\n")}")
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user