Compare commits
125 Commits
a40cb29b96
...
teste
| Author | SHA256 | Date | |
|---|---|---|---|
| e001a48e1a | |||
| c0ef42a116 | |||
| 04efe871f0 | |||
| 1d4bed785d | |||
| 75d82227c9 | |||
| bd61b32edd | |||
| a08a0712f0 | |||
| 8e73bbe0ed | |||
| b55b602eb5 | |||
| 962f79266a | |||
| df9256c556 | |||
| 1dd2610c1f | |||
| 96b85adbe9 | |||
| bb9d55675a | |||
| 3f56e87a33 | |||
| 44ba0a93c6 | |||
| 07fda079aa | |||
| 53341ac22e | |||
| b2695cd920 | |||
| fd14c057b6 | |||
| ea652c1d70 | |||
| 82e6a191f8 | |||
| cfdc01a522 | |||
| cfdf734c3f | |||
| 96b7b03f57 | |||
| 89b12fc91b | |||
| 279e9a54a1 | |||
| e05ee5313b | |||
| cdb4d45336 | |||
| 65e926196e | |||
| e6e8ddc8d1 | |||
| 069301bc2e | |||
| 0a6b5a68fc | |||
| eca6df39eb | |||
| 83310e9e66 | |||
| ca626b493b | |||
| 3b4d796f7c | |||
| 8d0d262bf8 | |||
| 96d01aa52a | |||
| ff9614d84c | |||
| 8ab2f8e72a | |||
| 0a9713c298 | |||
| cb6eda74f1 | |||
| a5742d178a | |||
| 4b3f80a2e0 | |||
| ac0524d60f | |||
| 3fb2b69399 | |||
| acd3d20caf | |||
| 3698b826c0 | |||
| 4c035d4a4c | |||
| 7d6c79f878 | |||
| 6b899ebafa | |||
| 7f21a16327 | |||
| 59c0caa774 | |||
| 11531479f8 | |||
| 16032be620 | |||
| 3f5829c816 | |||
| ce2a610300 | |||
| 6b21ce0dba | |||
| 418cf43353 | |||
| 1bda666d5a | |||
| b659a66612 | |||
| 1ad1d4c143 | |||
| 1ca5fc67d7 | |||
| 06e287e611 | |||
| 09867c8d39 | |||
| f42949db78 | |||
| 0c5b5bcbe6 | |||
| 022aac2616 | |||
| 48531a1996 | |||
| 280c2c7143 | |||
| 1807dc1821 | |||
| 8572e6a842 | |||
| 057cde6fbd | |||
| 8a7c8ad6b7 | |||
| 6576927ed4 | |||
| 415cabc88f | |||
| 21c84bf4f4 | |||
| 5d5531b695 | |||
| 74391d5a0c | |||
| 56b3e146bf | |||
| b742b2c2e3 | |||
| 94d8420bbd | |||
| e23ca0f10d | |||
| 66794b817f | |||
| 7b83f2e248 | |||
| 79c0f646da | |||
| 81128e7a68 | |||
| b92e67406d | |||
| 731774c32a | |||
| 6d246cadb8 | |||
| 13d6c243c3 | |||
| 92ba679a81 | |||
| 6878f3040e | |||
| e6f30134a0 | |||
| f47675c1dc | |||
| 9cb0c226c3 | |||
| 3389dc9806 | |||
| 883a7aba52 | |||
| 076bd42973 | |||
| 4b728c0c4d | |||
| b86105d1e4 | |||
| c8ce50fd0e | |||
| 082a1f0d87 | |||
| 70d29029f6 | |||
| a01af23691 | |||
| 50166686f8 | |||
| a476e32dc6 | |||
| c969049c1a | |||
| e9def7e21a | |||
| 292ac31c66 | |||
| 5b2de23cfe | |||
| 69448ddabd | |||
| 0a12b10acc | |||
| cf896de165 | |||
| 80c068c798 | |||
| 7c57b8e09d | |||
| ae6f0122d4 | |||
| 6efbb45b70 | |||
| 9c663ee3da | |||
| fedf5bfcc9 | |||
| aad167873f | |||
| 44843351ae | |||
| 2cf3b08d84 | |||
| 19a72c066f |
50
.agents/skills/agent-browser/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: agent-browser
|
||||
description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools.
|
||||
allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*)
|
||||
hidden: true
|
||||
---
|
||||
|
||||
# agent-browser
|
||||
|
||||
Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with accessibility-tree snapshots and compact `@eN` element refs.
|
||||
|
||||
Install: `npm i -g agent-browser && agent-browser install`
|
||||
|
||||
## Start here
|
||||
|
||||
This file is a discovery stub, not the usage guide. Before running any `agent-browser` command, load the actual workflow content from the CLI:
|
||||
|
||||
```bash
|
||||
agent-browser skills get core # start here — workflows, common patterns, troubleshooting
|
||||
agent-browser skills get core --full # include full command reference and templates
|
||||
```
|
||||
|
||||
The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skills get core`.
|
||||
|
||||
## Specialized skills
|
||||
|
||||
Load a specialized skill when the task falls outside browser web pages:
|
||||
|
||||
```bash
|
||||
agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...)
|
||||
agent-browser skills get slack # Slack workspace automation
|
||||
agent-browser skills get dogfood # Exploratory testing / QA / bug hunts
|
||||
agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs
|
||||
agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers
|
||||
```
|
||||
|
||||
Run `agent-browser skills list` to see everything available on the installed version.
|
||||
|
||||
## Why agent-browser
|
||||
|
||||
- Fast native Rust CLI, not a Node.js wrapper
|
||||
- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.)
|
||||
- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency
|
||||
- Accessibility-tree snapshots with element refs for reliable interaction
|
||||
- Sessions, authentication vault, state persistence, video recording
|
||||
- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers
|
||||
|
||||
## Observability Dashboard
|
||||
|
||||
The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed.
|
||||
18
.env.example
@@ -4,7 +4,10 @@
|
||||
# ============================================================
|
||||
|
||||
# Ambiente Rails
|
||||
RAILS_ENV=development
|
||||
# ⚠️ NO SERVIDOR DE PRODUÇÃO use `production` (modo development expõe páginas de
|
||||
# erro com código-fonte/stack, o rack-mini-profiler e desliga otimizações).
|
||||
# Use `development` só na sua máquina local.
|
||||
RAILS_ENV=production
|
||||
|
||||
# ── Banco de dados (seu PostgreSQL já existente) ─────────────
|
||||
# Aponte DB_HOST para o IP ou hostname do seu servidor PostgreSQL
|
||||
@@ -15,6 +18,14 @@ DB_NAME=logistica_db # nome do banco NOVO que o sistema vai criar
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=senha_segura
|
||||
|
||||
# ── SimpliRoute — API de escrita (edição de lançamentos pelo ADM) ─────
|
||||
# A base de rastreio (DB_EXISTING_TABLE) é só LEITURA e sincroniza a partir
|
||||
# do SimpliRoute. Para CORRIGIR um lançamento (status/motivo/comentário) o
|
||||
# sistema grava direto na API do SimpliRoute usando este token.
|
||||
# Pegue em: SimpliRoute → Configurações → API. NUNCA versione o token real.
|
||||
SIMPLIROUTE_TOKEN=
|
||||
SIMPLIROUTE_BASE_URL=https://api.simpliroute.com
|
||||
|
||||
# ── Tabela existente (apenas leitura — NÃO modificar) ────────
|
||||
DB_EXISTING_TABLE=db_reem_simplerout_2026
|
||||
DB_EXISTING_SCHEMA=public
|
||||
@@ -29,6 +40,11 @@ DB_EXISTING_ACCOUNT_ID=all
|
||||
# Gere com: docker-compose exec app bundle exec rails secret
|
||||
SECRET_KEY_BASE=gere_com_rails_secret_e_cole_aqui
|
||||
|
||||
# Força HTTPS: redireciona HTTP→HTTPS e marca cookies como "Secure".
|
||||
# Deixe "true" no servidor (atrás do Cloudflare). Em desenvolvimento local
|
||||
# deixe vazio/false para não forçar https em localhost.
|
||||
FORCE_SSL=true
|
||||
|
||||
# ── Twilio — WhatsApp (opcional, Fase 8) ─────────────────────
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
|
||||
10
.gitignore
vendored
@@ -13,6 +13,16 @@ docker-compose.override.yml
|
||||
config/master.key
|
||||
config/credentials/*.key
|
||||
|
||||
# ============================
|
||||
# Ferramentas de IA — não versionar
|
||||
# ============================
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.aider*
|
||||
.github/copilot-instructions.md
|
||||
|
||||
# Rails
|
||||
/log/*
|
||||
/tmp/*
|
||||
|
||||
125
CLAUDE.md
@@ -1,125 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project overview
|
||||
|
||||
**Reem Logística** is a Rails 7 web application for controlling delivery costs for **Gade Hospitalar** (account_id: 95907). It manages consolidations of driver payments across delivery routes (UBS SUL, UBS LESTE, EMAD, STS, SAD, etc.).
|
||||
|
||||
## Commands
|
||||
|
||||
All commands run inside Docker. The PostgreSQL database lives externally (not in docker-compose).
|
||||
|
||||
```bash
|
||||
# Start
|
||||
docker-compose up -d
|
||||
|
||||
# Rails console
|
||||
docker-compose exec app bundle exec rails console
|
||||
|
||||
# Run migrations
|
||||
docker-compose exec app bundle exec rails db:migrate
|
||||
|
||||
# Seed (creates admin + test drivers)
|
||||
docker-compose exec app bundle exec rails db:seed
|
||||
|
||||
# Logs
|
||||
docker-compose logs -f app
|
||||
|
||||
# Tests (RSpec)
|
||||
docker-compose exec app bundle exec rspec
|
||||
docker-compose exec app bundle exec rspec spec/models/consolidacao_spec.rb # single file
|
||||
|
||||
# Rake task (runs manually what the cron job runs hourly)
|
||||
docker-compose exec app bundle exec rake historico:atualizar
|
||||
|
||||
# Activate the hourly cron job on the server
|
||||
docker-compose exec app bundle exec whenever --update-crontab
|
||||
```
|
||||
|
||||
## Setup from scratch
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env: set DB_HOST, DB_PASSWORD, and generate SECRET_KEY_BASE with:
|
||||
# openssl rand -hex 64
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
docker-compose exec app bundle exec rails db:create db:migrate db:seed
|
||||
```
|
||||
|
||||
Default credentials (change in production): `admin@gade.com` / `Gade@2026!`
|
||||
|
||||
## Critical constraint: read-only external table
|
||||
|
||||
The table `public.db_reem_simplerout_2026` is owned by an external system (SimpleRoute) that updates it every hour. **NEVER** create migrations for it, and never run INSERT/UPDATE/DELETE/DROP/TRUNCATE on it.
|
||||
|
||||
`Entrega` (app/models/entrega.rb) maps to this table and enforces `readonly? = true`. All queries go through its scopes — the most important being `.pagas` (`status='completed' AND checkout IS NOT NULL`), which defines payment eligibility. Consolidations filter eligible deliveries by **vehicle** (`vehicle` column → `Consolidacao#vehicle_ids`), not by route.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data flow
|
||||
|
||||
1. External system writes to `db_reem_simplerout_2026` hourly
|
||||
2. `Atualizar HistoricoEstimadoJob` (triggered by Whenever cron every hour) counts paid deliveries per driver and writes to `historico_estimados`
|
||||
3. Admins/managers create **Consolidações** (payment batches) covering a date range + list of drivers
|
||||
4. A 3-step wizard classifies each delivery per driver (Normal/Retirada/Bônus/Desconto toggles)
|
||||
5. Finalizing a consolidation sends WhatsApp (Twilio) and email notifications to drivers
|
||||
6. Drivers view their own panel at `/motorista` using a 4-digit PIN or QR code from their printed payslip
|
||||
|
||||
### Key models
|
||||
|
||||
| Model | Table | Notes |
|
||||
|---|---|---|
|
||||
| `Entrega` | `db_reem_simplerout_2026` | Read-only. External data. |
|
||||
| `Consolidacao` | `consolidacoes` | Payment batch. Enum status: `rascunho/finalizada/arquivada`. Soft-delete via `deleted_at`. |
|
||||
| `ConsolidacaoMotorista` | `consolidacao_motoristas` | Join: which drivers are in a consolidation. |
|
||||
| `ConsolidacaoEntrega` | `consolidacao_entregas` | Each classified delivery. Enum tipo: `normal/retirada/bonus/desconto`. |
|
||||
| `HistoricoEstimado` | `historico_estimados` | Snapshot of hourly estimated values per driver. |
|
||||
| `Configuracao` | `configuracoes` | Key-value store for prices and feature flags. Access via `Configuracao.valor('chave')`. |
|
||||
| `AuditoriaLog` | `auditoria_logs` | Audit trail. Controllers include `Auditavel` concern and call `auditar!(:acao, registro)`. |
|
||||
| `User` | `users` | Devise auth. Roles: `admin/gerente/operador/motorista`. Drivers may have no email — they log in via `pin_code` (4 digits, unique per driver) or `login_token` (QR). |
|
||||
|
||||
### Authorization
|
||||
|
||||
Pundit policies in `app/policies/`. `ApplicationController` includes `Pundit::Authorization` and calls `authenticate_user!` on every action. The motorista panel (`/motorista/**`) uses a separate session mechanism (`motorista/sessoes_controller.rb`) — drivers are NOT authenticated via Devise.
|
||||
|
||||
### Two login flows for drivers
|
||||
|
||||
- `/auth/login` (Devise, Fase 2) — PIN tab on the standard login page
|
||||
- `/motorista/login` (Fase 8) — dedicated numeric keypad, used by QR codes printed on payslips. Prefer this for mobile. Both use `pin_code`.
|
||||
|
||||
### PDF generation
|
||||
|
||||
`app/services/pdf/` contains three classes:
|
||||
- `BasePdf` — shared black/orange header and footer layout
|
||||
- `RelatorioMotoristaPdf` — detailed delivery table per driver
|
||||
- `HoleritePdf` — payslip style + embedded QR code (via `rqrcode`/`chunky_png`) linking to `/motorista/acesso/:token`
|
||||
|
||||
### The consolidation wizard
|
||||
|
||||
`/consolidacoes/:id/wizard` → `/consolidacao_entregas/validar` → `/consolidacao_entregas/revisar`
|
||||
|
||||
- **Passo 1** (wizard): pick a driver. Shows progress per driver.
|
||||
- **Passo 2** (validar): color-coded toggle buttons per delivery (orange=Normal, dark orange=Retirada, white=Bônus, black=Desconto). Toggle individual or mass-classify. Stimulus controller (`validacao_controller.js`) handles real-time totals and progress.
|
||||
- **Passo 3** (revisar): summary for the selected driver, then move to the next driver or save as draft.
|
||||
|
||||
Finalizing is blocked (server-side + UI) until all drivers have all eligible deliveries classified.
|
||||
|
||||
### Notifications (NotificacaoService)
|
||||
|
||||
Controlled by `Configuracao` flags `notificacao_whatsapp` and `notificacao_email`. Failures are rescued and logged — they never abort the finalization. WhatsApp requires Twilio ENV vars + driver `telefone` field in `+5511999998888` format.
|
||||
|
||||
## Design system
|
||||
|
||||
Dark theme (default). Colors: background `#0a0a0a`, cards `#1a1a1a`, accent/buttons `#f97316` (orange), text `#ffffff`, success `#22c55e`, error `#ef4444`. Minimum font 16px desktop / 18px mobile. Touch targets minimum 48px height.
|
||||
|
||||
## Environment variables
|
||||
|
||||
All config comes from `.env` (never hardcoded). Key variables:
|
||||
- `DB_HOST` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` — PostgreSQL connection
|
||||
- `DB_EXISTING_ACCOUNT_ID` — filters `db_reem_simplerout_2026` to Gade's account (default: 95907)
|
||||
- `SECRET_KEY_BASE` — generate with `openssl rand -hex 64`
|
||||
- `APP_HOST` — used in QR code URLs and notification links
|
||||
- `TWILIO_*` — WhatsApp notifications (optional)
|
||||
- `SMTP_*` — Email notifications (optional, only activates if `SMTP_USERNAME` is set)
|
||||
@@ -9,6 +9,7 @@ RUN apt-get update -qq && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
libvips \
|
||||
cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Diretório da app
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-param" content="authenticity_token">
|
||||
<meta name="csrf-token" content="G-9RRM76mXTElY86HJ18dlR93xoYQyO4uD1MzgEGhSZLg9p1i8wFt73pl0AHSNGSxXOBT0R5D0q_c_iovOAGZQ">
|
||||
<title>Reem Logística</title>
|
||||
|
||||
<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>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||
/* Loading spinner laranja */
|
||||
.spinner {
|
||||
border: 3px solid #1a1a1a;
|
||||
border-top-color: #f97316;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<script type="importmap" data-turbo-track="reload">{
|
||||
"imports": {
|
||||
"application": "/assets/application-09caced1.js",
|
||||
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
|
||||
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
|
||||
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
|
||||
"controllers/application": "/assets/controllers/application-75a69c37.js",
|
||||
"controllers": "/assets/controllers/index-dfefe98d.js",
|
||||
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
|
||||
}
|
||||
}</script>
|
||||
<link rel="modulepreload" href="/assets/application-09caced1.js">
|
||||
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
|
||||
<script type="module">import "application"</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
|
||||
|
||||
<div id="sidebar-overlay"
|
||||
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
|
||||
onclick="toggleSidebar()"></div>
|
||||
|
||||
<aside id="sidebar"
|
||||
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<p class="text-xs text-gray-500 mb-1">Logado como</p>
|
||||
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
|
||||
bg-orange-500 text-black">
|
||||
Administrador
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
|
||||
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
|
||||
<span>🚪</span> Sair
|
||||
</a> </div>
|
||||
</aside>
|
||||
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Reem Logística</span>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">
|
||||
Novo Usuário
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">
|
||||
Preencha os dados para criar um novo acesso
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<form class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-8 space-y-6" action="/admin/usuarios" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="ENsNJddbt-MKLOBei4dMU8xI5qs3plOiQMzqfepJ4wk7d_oooUwVJUOzd7xxU_Kjqz8jnh7JOzPwpymLh0pwVQ" autocomplete="off" />
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_nome">Nome completo</label>
|
||||
<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" placeholder="João da Silva" type="text" name="user[nome]" id="user_nome" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_role">Perfil de acesso</label>
|
||||
<select class="w-full px-4 py-3 bg-[#0a0a0a] border border-white/10 rounded-xl text-white
|
||||
focus:outline-none focus:border-[#f97316] focus:ring-1 focus:ring-[#f97316]
|
||||
transition-colors cursor-pointer" name="user[role]" id="user_role"><option value="admin">Administrador</option>
|
||||
<option value="gerente">Gerente</option>
|
||||
<option value="operador">Operador</option>
|
||||
<option selected="selected" value="motorista">Motorista</option></select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300" for="user_ativo">Usuário ativo</label>
|
||||
<p class="text-gray-500 text-xs mt-0.5">Usuários inativos não conseguem fazer login</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer ml-4">
|
||||
<input name="user[ativo]" type="hidden" value="0" autocomplete="off" /><input class="sr-only peer" type="checkbox" value="1" checked="checked" name="user[ativo]" id="user_ativo" />
|
||||
<div class="w-11 h-6 bg-gray-700 peer-focus:outline-none rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-gray-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-[#f97316]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fields-email" class="space-y-6">
|
||||
<div class="border-t border-white/5 pt-6">
|
||||
<p class="text-sm font-medium text-gray-300 mb-4">Credenciais de acesso</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_email">E-mail</label>
|
||||
<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" placeholder="usuario@gade.com" type="email" value="" name="user[email]" id="user_email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password">Senha</label>
|
||||
<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" placeholder="••••••••" type="password" name="user[password]" id="user_password" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_password_confirmation">Confirmar senha</label>
|
||||
<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" placeholder="••••••••" type="password" name="user[password_confirmation]" id="user_password_confirmation" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fields-pin" class="hidden border-t border-white/5 pt-6">
|
||||
<p class="text-sm font-medium text-gray-300 mb-4">PIN de acesso</p>
|
||||
<div class="max-w-xs">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5" for="user_pin_code">PIN (4 dígitos)</label>
|
||||
<input maxlength="4" pattern="\d{4}" inputmode="numeric" 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 text-center text-2xl tracking-[0.5em] font-bold" placeholder="0000" size="4" type="text" name="user[pin_code]" id="user_pin_code" />
|
||||
<p class="text-gray-500 text-xs mt-1.5">Deve ser único entre todos os motoristas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-white/5">
|
||||
<a class="px-6 py-3 text-gray-400 hover:text-white border border-white/10
|
||||
hover:border-white/20 rounded-xl transition-colors" href="/admin/usuarios">Cancelar</a>
|
||||
<input type="submit" name="commit" value="Criar Usuário" class="px-8 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold
|
||||
rounded-xl transition-colors cursor-pointer min-h-[48px]" data-disable-with="Criar Usuário" />
|
||||
</div>
|
||||
</form></div>
|
||||
|
||||
<script>
|
||||
// IIFE: evita "Identifier already declared" quando o Turbo re-executa o script
|
||||
// ao navegar (o que antes abortava o toggle e escondia o campo de PIN).
|
||||
(function () {
|
||||
const roleSelect = document.querySelector('select[name="user[role]"]');
|
||||
const fieldsEmail = document.getElementById('fields-email');
|
||||
const fieldsPin = document.getElementById('fields-pin');
|
||||
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
|
||||
|
||||
function toggleFields() {
|
||||
const motorista = roleSelect.value === 'motorista';
|
||||
fieldsEmail.classList.toggle('hidden', motorista);
|
||||
fieldsPin.classList.toggle('hidden', !motorista);
|
||||
}
|
||||
|
||||
roleSelect.addEventListener('change', toggleFields);
|
||||
toggleFields(); // estado inicial
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
['flash-notice', 'flash-alert'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||
setTimeout(() => el.remove(), 500);
|
||||
});
|
||||
}, 4000);
|
||||
</script>
|
||||
|
||||
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="1ahgpnmdas9x6acftfbr" data-ids="1ahgpnmdas9x6acftfbr,ho1l1mkabtvdcaskpjm7" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
|
||||
</body>
|
||||
</html>
|
||||
2280
Erros/holerite_carlos-matheus-pimentel_10-1.pdf
Normal file
3911
Erros/relatorio_bruno-corgozinho_4-2.pdf
Normal file
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-param" content="authenticity_token">
|
||||
<meta name="csrf-token" content="V0huiUnnR6iuZZliHnfydw0_28pJd5ho66yiE1jO186V5ffx6xm7oL5TuqTEwVv9v-9pxKKkTDbXwLQRAdLL3w">
|
||||
<title>teste | Reem Logística</title>
|
||||
|
||||
<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>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
::-webkit-scrollbar-thumb { background: #f97316; border-radius: 3px; }
|
||||
/* Loading spinner laranja */
|
||||
.spinner {
|
||||
border: 3px solid #1a1a1a;
|
||||
border-top-color: #f97316;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
||||
<script type="importmap" data-turbo-track="reload">{
|
||||
"imports": {
|
||||
"application": "/assets/application-09caced1.js",
|
||||
"@hotwired/turbo-rails": "/assets/turbo.min-86bf8853.js",
|
||||
"@hotwired/stimulus": "/assets/stimulus.min-7ea3d58b.js",
|
||||
"@hotwired/stimulus-loading": "/assets/stimulus-loading-25917588.js",
|
||||
"controllers/application": "/assets/controllers/application-75a69c37.js",
|
||||
"controllers": "/assets/controllers/index-dfefe98d.js",
|
||||
"controllers/validacao_controller": "/assets/controllers/validacao_controller-3ce614b7.js"
|
||||
}
|
||||
}</script>
|
||||
<link rel="modulepreload" href="/assets/application-09caced1.js">
|
||||
<link rel="modulepreload" href="/assets/turbo.min-86bf8853.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus.min-7ea3d58b.js">
|
||||
<link rel="modulepreload" href="/assets/stimulus-loading-25917588.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/application-75a69c37.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/index-dfefe98d.js">
|
||||
<link rel="modulepreload" href="/assets/controllers/validacao_controller-3ce614b7.js">
|
||||
<script type="module">import "application"</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
|
||||
|
||||
<div id="sidebar-overlay"
|
||||
class="fixed inset-0 bg-black/70 z-30 hidden md:hidden"
|
||||
onclick="toggleSidebar()"></div>
|
||||
|
||||
<aside id="sidebar"
|
||||
class="fixed top-0 left-0 h-full w-64 bg-[#111111] border-r border-[#2a2a2a] z-40
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<p class="text-xs text-gray-500 mb-1">Logado como</p>
|
||||
<p class="text-white font-semibold text-sm truncate">Administrador Gade</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs rounded-full
|
||||
bg-orange-500 text-black">
|
||||
Administrador
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/dashboard">📊 Dashboard</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/consolidacoes">📦 Consolidações</a>
|
||||
|
||||
|
||||
<div class="pt-4 mt-4 border-t border-[#2a2a2a]">
|
||||
<p class="px-3 text-xs text-gray-600 uppercase tracking-wider mb-2">Administração</p>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/usuarios">👥 Usuários</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/configuracoes">⚙️ Configurações</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px] text-gray-400 hover:bg-[#1a1a1a] hover:text-white" href="/admin/auditoria_logs">🔍 Auditoria</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a data-turbo-method="delete" data-turbo-confirm="Deseja sair?" class="flex items-center gap-2 text-gray-400 hover:text-red-400 text-sm transition-colors" href="/auth/logout">
|
||||
<span>🚪</span> Sair
|
||||
</a> </div>
|
||||
</aside>
|
||||
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
class="text-orange-500 text-2xl min-h-[48px] min-w-[48px] flex items-center justify-center">
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Reem Logística</span>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
<main class="ml-0 md:ml-64 p-4 md:p-8 min-h-screen">
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
<div class="mb-6">
|
||||
<a class="text-gray-500 hover:text-orange-500 text-sm" href="/consolidacoes">← Consolidações</a>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-white">teste</h1>
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-yellow-500 text-black">📝 Rascunho</span>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
📅 01/02/2026 → 28/02/2026
|
||||
· Criada por Administrador Gade
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center" href="/consolidacoes/3/wizard">✏️ Continuar validação</a>
|
||||
<form class="button_to" method="post" action="/consolidacoes/3/arquivar"><button data-turbo-confirm="Arquivar esta consolidação? Ela não será excluída, apenas movida para o arquivo." class="text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px]" type="submit">📁 Arquivar</button><input type="hidden" name="authenticity_token" value="bFgW-DhUfHVabQb6YBBZhcI6kTfr6RY-m2AcIg74Too_85JbSI2x1yKmq7VymjoA3QnfDb2v-F5VSGRHh8Q_DQ" autocomplete="off" /></form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-[#1a1a1a] border border-orange-500 rounded-xl p-6 mb-6 text-center">
|
||||
<p class="text-gray-400 text-sm">Valor total da consolidação</p>
|
||||
<p class="text-orange-500 font-black text-4xl">R$ 5265,00</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-white font-bold text-lg mb-3">👥 Motoristas</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-white font-bold text-lg">Bruno Corgozinho</p>
|
||||
<p class="text-orange-500 font-black text-xl">R$ 5265,00</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button onclick="abrirPreview('/consolidacoes/3/preview_holerite?motorista=Bruno+Corgozinho')"
|
||||
class="bg-[#0a0a0a] hover:bg-[#2a2a2a] text-white border border-orange-500 font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px]">
|
||||
👁️ Ver antes de gerar
|
||||
</button>
|
||||
<a class="bg-orange-800 hover:bg-orange-700 text-white font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_relatorio?motorista=Bruno+Corgozinho">📄 Relatório Individual</a>
|
||||
<a class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center" href="/consolidacoes/3/gerar_pdf_holerite?motorista=Bruno+Corgozinho">🧾 Gerar Holerite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-preview" class="fixed inset-0 bg-black/80 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl max-w-2xl w-full max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<h3 class="text-white font-bold">👁️ Pré-visualização do Holerite</h3>
|
||||
<button onclick="fecharPreview()" class="text-gray-400 hover:text-white text-2xl min-w-[44px] min-h-[44px]">✕</button>
|
||||
</div>
|
||||
<div id="modal-preview-conteudo" class="overflow-y-auto p-6 flex-1">
|
||||
<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>
|
||||
</div>
|
||||
<div class="flex gap-3 px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a id="modal-confirmar" href="#"
|
||||
class="flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3 rounded-lg text-center min-h-[48px] flex items-center justify-center">
|
||||
✓ Confirmar e baixar PDF
|
||||
</a>
|
||||
<button onclick="fecharPreview()"
|
||||
class="px-6 py-3 text-gray-400 hover:text-white border border-[#2a2a2a] rounded-lg min-h-[48px]">
|
||||
← Voltar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function abrirPreview(url) {
|
||||
const modal = document.getElementById('modal-preview');
|
||||
const conteudo = document.getElementById('modal-preview-conteudo');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
conteudo.innerHTML = '<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>';
|
||||
|
||||
const resp = await fetch(url);
|
||||
conteudo.innerHTML = await resp.text();
|
||||
|
||||
// O link de confirmar aponta para o PDF do mesmo motorista
|
||||
document.getElementById('modal-confirmar').href =
|
||||
url.replace('preview_holerite', 'gerar_pdf_holerite');
|
||||
}
|
||||
function fecharPreview() {
|
||||
const modal = document.getElementById('modal-preview');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
</script>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
['flash-notice', 'flash-alert'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.transition = 'opacity 0.5s', el.style.opacity = '0',
|
||||
setTimeout(() => el.remove(), 500);
|
||||
});
|
||||
}, 4000);
|
||||
</script>
|
||||
|
||||
<script async nonce="" type="text/javascript" id="mini-profiler" src="/mini-profiler-resources/includes.js?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-css-url="/mini-profiler-resources/includes.css?v=e0bcc9ce0ae3bb5d6b736b6f282f601f" data-version="e0bcc9ce0ae3bb5d6b736b6f282f601f" data-path="/mini-profiler-resources/" data-current-id="5vfdctp5mnyu0tdtx94a" data-ids="5vfdctp5mnyu0tdtx94a,md2esz0jc4j57ce236e8" data-horizontal-position="left" data-vertical-position="top" data-trivial="false" data-children="false" data-max-traces="20" data-controls="false" data-total-sql-count="false" data-authorized="true" data-toggle-shortcut="alt+p" data-start-hidden="false" data-collapse-results="true" data-html-container="body" data-hidden-custom-fields="" data-turbo-permanent="false"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
Gemfile
@@ -26,6 +26,9 @@ gem "dotenv-rails"
|
||||
# PDF
|
||||
gem "prawn"
|
||||
gem "prawn-table"
|
||||
|
||||
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
|
||||
gem "caxlsx"
|
||||
gem "rqrcode" # QR code no holerite (login rápido motorista)
|
||||
gem "chunky_png" # renderiza QR como PNG para embed no Prawn
|
||||
|
||||
@@ -36,7 +39,7 @@ gem "whenever", require: false
|
||||
gem "twilio-ruby" # WhatsApp — só envia se TWILIO_ACCOUNT_SID estiver no .env
|
||||
|
||||
# Utilitários
|
||||
gem "pagy" # paginação
|
||||
gem "pagy", "~> 9.0" # paginação (fixado: API :limit e sem extra overflow)
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
group :development, :test do
|
||||
@@ -48,7 +51,11 @@ end
|
||||
|
||||
group :development do
|
||||
gem "web-console"
|
||||
gem "rack-mini-profiler"
|
||||
# require: false → não monta o middleware automaticamente (evita vazar o
|
||||
# cookie __profilin e a rota /mini-profiler-resources em produção). Para usar
|
||||
# numa investigação de performance, rode com RACK_MINI_PROFILER=1 ou
|
||||
# adicione `require "rack-mini-profiler"` temporariamente.
|
||||
gem "rack-mini-profiler", require: false
|
||||
gem "annotate"
|
||||
end
|
||||
|
||||
|
||||
379
Gemfile.lock
Normal file
@@ -0,0 +1,379 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activejob (= 7.1.6)
|
||||
activerecord (= 7.1.6)
|
||||
activestorage (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
actionview (= 7.1.6)
|
||||
activejob (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.1.6)
|
||||
actionview (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
cgi
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
actiontext (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activerecord (= 7.1.6)
|
||||
activestorage (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
builder (~> 3.1)
|
||||
cgi
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
activerecord (7.1.6)
|
||||
activemodel (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activejob (= 7.1.6)
|
||||
activerecord (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.1.6)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindex (0.8.1)
|
||||
builder (3.3.0)
|
||||
capybara (3.40.0)
|
||||
addressable
|
||||
matrix
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.11)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cgi (0.5.1)
|
||||
chronic (0.10.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (5.0.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 7.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (3.2.0)
|
||||
dotenv-rails (3.2.0)
|
||||
dotenv (= 3.2.0)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.3)
|
||||
erb (6.0.4)
|
||||
erubi (1.13.1)
|
||||
factory_bot (6.6.0)
|
||||
activesupport (>= 6.1.0)
|
||||
factory_bot_rails (6.5.1)
|
||||
factory_bot (~> 6.5)
|
||||
railties (>= 6.1.0)
|
||||
faker (3.8.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (2.0.2)
|
||||
importmap-rails (2.2.3)
|
||||
actionpack (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.15.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.19.9)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
loofah (2.25.1)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.4.1)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
pagy (9.4.0)
|
||||
pdf-core (0.10.0)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prawn (2.5.0)
|
||||
matrix (~> 0.4)
|
||||
pdf-core (~> 0.10.0)
|
||||
ttfunk (~> 1.8)
|
||||
prawn-table (0.2.2)
|
||||
prawn (>= 1.3.0, < 3.0.0)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.2)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
psych (5.4.0)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (6.6.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
rack-mini-profiler (4.0.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (7.1.6)
|
||||
actioncable (= 7.1.6)
|
||||
actionmailbox (= 7.1.6)
|
||||
actionmailer (= 7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
actiontext (= 7.1.6)
|
||||
actionview (= 7.1.6)
|
||||
activejob (= 7.1.6)
|
||||
activemodel (= 7.1.6)
|
||||
activerecord (= 7.1.6)
|
||||
activestorage (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.1.6)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (7.1.6)
|
||||
actionpack (= 7.1.6)
|
||||
activesupport (= 7.1.6)
|
||||
cgi
|
||||
irb
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.1.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.7)
|
||||
rubyzip (3.4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.44.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
tailwindcss-rails (4.5.0)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-ruby (~> 4.0)
|
||||
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
ttfunk (1.8.0)
|
||||
bigdecimal (~> 3.1)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
twilio-ruby (7.10.7)
|
||||
faraday (>= 2.0, < 3.0)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
nokogiri (>= 1.6, < 2.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uri (1.1.1)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.8.1)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
whenever (1.1.2)
|
||||
chronic (>= 0.6.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.8.2)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
annotate
|
||||
capybara
|
||||
chunky_png
|
||||
debug
|
||||
devise
|
||||
dotenv-rails
|
||||
factory_bot_rails
|
||||
faker
|
||||
image_processing (>= 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
pagy (~> 9.0)
|
||||
pg (~> 1.1)
|
||||
prawn
|
||||
prawn-table
|
||||
propshaft
|
||||
puma (~> 6.0)
|
||||
pundit
|
||||
rack-mini-profiler
|
||||
rails (~> 7.1.0)
|
||||
rqrcode
|
||||
rspec-rails
|
||||
selenium-webdriver
|
||||
stimulus-rails
|
||||
tailwindcss-rails
|
||||
turbo-rails
|
||||
twilio-ruby
|
||||
web-console
|
||||
whenever
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.2p53
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.10
|
||||
BIN
Imagens para correção /BKP.zip
Normal file
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 140 KiB |
6472
Imagens para correção /reem-notas-teste-app-1.html
Normal file
BIN
Imagens para implantação /Bkp Imagens.zip
Normal file
BIN
Imagens para implantação /Entregas SUDESTE 07.2026_FINAL.xlsx
Normal file
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 228 KiB |
BIN
Imagens para implantação /Imagem colada (4).png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
Imagens para implantação /Imagem colada (5).png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
Imagens para implantação /Imagem colada (6).png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 599 KiB |
BIN
Imagens para implantação /pt_br_example.xlsx
Normal file
@@ -159,5 +159,5 @@ docker-compose up -d
|
||||
| **4** | Job Whenever (1h) + HistoricoEstimado + AuditoriaLog UI |
|
||||
| **5** | Nova Consolidação + Wizard Passo 1 (selecionar motorista) |
|
||||
| **6** | Toggle switches + ações em massa + barra progresso (Stimulus) |
|
||||
| **7** | PDFs com Prawn: relatório individual + holerite com QR code |
|
||||
| **7** | PDFs com Prawn: relatório individual + extrato com QR code |
|
||||
| **8** | Notificações WhatsApp/email + responsividade final |
|
||||
|
||||
BIN
app/assets/fonts/DejaVuSans-Bold.ttf
Normal file
BIN
app/assets/fonts/DejaVuSans.ttf
Normal file
@@ -4,7 +4,8 @@ class Admin::ConfiguracoesController < ApplicationController
|
||||
|
||||
def index
|
||||
authorize Configuracao
|
||||
@configuracoes = Configuracao.all.order(:chave)
|
||||
# Só os pilares de PREÇO (empresa/notificações não são preço e não entram aqui)
|
||||
@configuracoes = Configuracao.where(chave: Configuracao::CHAVES_MOEDA).order(:chave)
|
||||
end
|
||||
|
||||
def edit
|
||||
|
||||
231
app/controllers/admin/edicao_lancamentos_controller.rb
Normal file
@@ -0,0 +1,231 @@
|
||||
# app/controllers/admin/edicao_lancamentos_controller.rb
|
||||
#
|
||||
# Correção de lançamentos do SimpliRoute pelo ADM. Busca a entrega pela NF (na
|
||||
# base de rastreio local), resolve o id numérico na API e grava a alteração
|
||||
# DIRETO na API do SimpliRoute — registrando tudo no AuditoriaLog.
|
||||
#
|
||||
# ⚠️ A base de rastreio local (Entrega) é só leitura e sincroniza depois: a
|
||||
# alteração só reflete no painel na próxima sincronização.
|
||||
class Admin::EdicaoLancamentosController < ApplicationController
|
||||
before_action :garantir_configurado
|
||||
|
||||
# Campos que o ADM pode editar (fase 1). checkout_observation = UUID do motivo.
|
||||
CAMPOS_EDITAVEIS = %w[
|
||||
status checkout_observation checkout_comment notes
|
||||
checkout_time checkout_latitude checkout_longitude
|
||||
].freeze
|
||||
|
||||
STATUS_VALIDOS = %w[pending partial completed failed canceled].freeze
|
||||
|
||||
def show
|
||||
authorize :edicao_lancamento
|
||||
@motivos = motivos_seguros
|
||||
end
|
||||
|
||||
# GET /admin/edicao_lancamento/buscar?nf=79774 → JSON com o estado atual.
|
||||
def buscar
|
||||
authorize :edicao_lancamento
|
||||
|
||||
nf = params[:nf].to_s.strip
|
||||
return render_erro('Informe o número da NF.') if nf.blank?
|
||||
|
||||
entrega = Entrega.por_nf(nf).first
|
||||
return render_erro("NF #{nf} não encontrada na base de rastreio.") if entrega.nil?
|
||||
|
||||
id = client.resolver_id(entrega)
|
||||
visita = client.visita(id)
|
||||
|
||||
render json: {
|
||||
ok: true,
|
||||
visita: {
|
||||
id: id,
|
||||
nf: visita['reference'],
|
||||
titulo: visita['title'],
|
||||
endereco: visita['address'],
|
||||
status: visita['status'],
|
||||
checkout_observation: visita['checkout_observation'],
|
||||
checkout_comment: visita['checkout_comment'],
|
||||
notes: visita['notes'],
|
||||
checkout_time: visita['checkout_time'],
|
||||
checkout_latitude: visita['checkout_latitude'],
|
||||
checkout_longitude: visita['checkout_longitude'],
|
||||
planned_date: visita['planned_date'],
|
||||
contato: visita['contact_name'],
|
||||
telefone: visita['contact_phone'],
|
||||
# Fotos do card: fachada vem do rastreio (mesma foto do mapa de operações);
|
||||
# pictures/assinatura vêm da API. Só URLs http(s) chegam ao <img>.
|
||||
foto_fachada: url_imagem(entrega.try(:foto_da_fachada)),
|
||||
pictures: Array(visita['pictures']).filter_map { |u| url_imagem(u) },
|
||||
signature: url_imagem(visita['signature']),
|
||||
motorista: entrega.driver,
|
||||
veiculo: entrega.vehicle
|
||||
},
|
||||
motivos: motivos_seguros
|
||||
}
|
||||
rescue SimpliRoute::NotFound => e
|
||||
render_erro(e.message)
|
||||
rescue SimpliRoute::Error => e
|
||||
render_erro("Erro ao consultar o SimpliRoute: #{e.message}", status: :bad_gateway)
|
||||
end
|
||||
|
||||
# PATCH /admin/edicao_lancamento/atualizar
|
||||
# HTML: fluxo de form clássico (fallback). JSON: salvar-por-campo do card
|
||||
# (click-to-edit) — o fetch manda visit_id + só o campo editado.
|
||||
def atualizar
|
||||
authorize :edicao_lancamento
|
||||
|
||||
id = params[:visit_id].to_s.strip
|
||||
return responder_erro('Visita não identificada.') if id.blank?
|
||||
|
||||
anterior = client.visita(id)
|
||||
attrs = mudancas(anterior)
|
||||
|
||||
# Confirmar um valor idêntico não é erro no inline edit — só não há o que fazer.
|
||||
if attrs.empty?
|
||||
return respond_to do |format|
|
||||
format.json { render json: { ok: true, campos: [], visita: {} } }
|
||||
format.html { redirect_to admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.' }
|
||||
end
|
||||
end
|
||||
if attrs['status'].present? && STATUS_VALIDOS.exclude?(attrs['status'])
|
||||
return responder_erro('Status inválido.')
|
||||
end
|
||||
|
||||
atualizada = client.atualizar_visita(id, attrs)
|
||||
|
||||
AuditoriaLog.registrar(
|
||||
user: current_user,
|
||||
acao: 'editar',
|
||||
entidade: 'SimpliRoute::Visita',
|
||||
entidade_id: id,
|
||||
dados_anteriores: anterior.slice(*attrs.keys),
|
||||
dados_novos: atualizada.slice(*attrs.keys),
|
||||
request: request
|
||||
)
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
render json: { ok: true, campos: attrs.keys, visita: atualizada.slice(*CAMPOS_EDITAVEIS) }
|
||||
end
|
||||
format.html do
|
||||
redirect_to admin_edicao_lancamento_path,
|
||||
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
|
||||
'O painel refletirá na próxima sincronização.'
|
||||
end
|
||||
end
|
||||
rescue SimpliRoute::Error => e
|
||||
responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
|
||||
end
|
||||
|
||||
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON com as DUAS fontes:
|
||||
# o histórico da API do SimpliRoute (best-effort, costuma vir vazio) e a nossa
|
||||
# trilha do AuditoriaLog (quem editou, quando, de→para).
|
||||
def historico
|
||||
authorize :edicao_lancamento
|
||||
|
||||
id = params[:visit_id].to_s.strip
|
||||
interno = AuditoriaLog.por_entidade('SimpliRoute::Visita')
|
||||
.where(entidade_id: id)
|
||||
.includes(:user).recentes.limit(50)
|
||||
.map do |log|
|
||||
{
|
||||
usuario: log.user&.nome_display || '—',
|
||||
acao: log.acao,
|
||||
quando: log.created_at.in_time_zone.strftime('%d/%m/%Y %H:%M'),
|
||||
de: log.dados_anteriores,
|
||||
para: log.dados_novos
|
||||
}
|
||||
end
|
||||
|
||||
render json: { ok: true, api: client.historico(id), interno: interno }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= SimpliRoute::Client.new
|
||||
end
|
||||
|
||||
# Monta o hash de PATCH só com os campos que vieram no form E que realmente
|
||||
# mudaram em relação ao estado atual — PATCH mínimo, sem sobrescrever à toa.
|
||||
def mudancas(anterior)
|
||||
CAMPOS_EDITAVEIS.each_with_object({}) do |campo, memo|
|
||||
next unless params.key?(campo)
|
||||
|
||||
novo = normalizar(campo, params[campo])
|
||||
next if equivalente?(campo, novo, anterior[campo])
|
||||
|
||||
memo[campo] = novo
|
||||
end
|
||||
end
|
||||
|
||||
# Evita PATCH falso: nil/"" são equivalentes; datas comparadas por instante;
|
||||
# lat/long por valor numérico (o reparse difere do texto original da API).
|
||||
def equivalente?(campo, novo, atual)
|
||||
return true if novo.to_s == atual.to_s
|
||||
|
||||
case campo
|
||||
when 'checkout_time'
|
||||
a = (Time.zone.parse(novo.to_s) rescue nil)
|
||||
b = (Time.zone.parse(atual.to_s) rescue nil)
|
||||
a.present? && b.present? && a.to_i == b.to_i
|
||||
when 'checkout_latitude', 'checkout_longitude'
|
||||
novo.is_a?(Float) && atual.present? && (novo - atual.to_f).abs < 1e-9
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Normaliza valores por campo. Campos em branco viram nil (permite LIMPAR o
|
||||
# motivo/comentário). checkout_time aceita datetime-local e vira ISO8601.
|
||||
def normalizar(campo, valor)
|
||||
valor = valor.to_s.strip
|
||||
return nil if valor.blank?
|
||||
|
||||
case campo
|
||||
when 'checkout_latitude', 'checkout_longitude'
|
||||
Float(valor) rescue valor
|
||||
when 'checkout_time'
|
||||
(Time.zone.parse(valor)&.iso8601 rescue nil) || valor
|
||||
else
|
||||
valor
|
||||
end
|
||||
end
|
||||
|
||||
# Lista de motivos; nunca quebra a tela se a API estiver fora.
|
||||
def motivos_seguros
|
||||
client.observations
|
||||
rescue SimpliRoute::Error
|
||||
[]
|
||||
end
|
||||
|
||||
def render_erro(msg, status: :unprocessable_entity)
|
||||
render json: { ok: false, erro: msg }, status: status
|
||||
end
|
||||
|
||||
# Erro do atualizar nos dois formatos (JSON p/ inline edit, HTML p/ fallback).
|
||||
def responder_erro(msg, status: :unprocessable_entity)
|
||||
respond_to do |format|
|
||||
format.json { render json: { ok: false, erro: msg }, status: status }
|
||||
format.html { redirect_to admin_edicao_lancamento_path, alert: msg }
|
||||
end
|
||||
end
|
||||
|
||||
# Só aceita URL http(s) — evita injetar lixo no <img src> do card (mesmo
|
||||
# filtro do foto_url do OperacaoMetricas).
|
||||
def url_imagem(valor)
|
||||
url = valor.to_s.strip
|
||||
url.match?(%r{\Ahttps?://}i) ? url : nil
|
||||
end
|
||||
|
||||
# Sem token não há o que fazer — avisa e volta.
|
||||
def garantir_configurado
|
||||
return if SimpliRoute.configurado?
|
||||
|
||||
msg = 'Integração com o SimpliRoute não configurada (defina SIMPLIROUTE_TOKEN no servidor).'
|
||||
respond_to do |format|
|
||||
format.html { redirect_to dashboard_path, alert: msg }
|
||||
format.json { render json: { ok: false, erro: msg }, status: :service_unavailable }
|
||||
end
|
||||
end
|
||||
end
|
||||
43
app/controllers/admin/planilhas_simpli_route_controller.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# app/controllers/admin/planilhas_simpliroute_controller.rb
|
||||
#
|
||||
# Gera a planilha de carga do SimpliRoute (.xlsx) a partir de UMA operação
|
||||
# vigente. Lat/long são preenchidas com o histórico do mês anterior
|
||||
# (SimpliRoute::PlanilhaCarga). Somente leitura das bases.
|
||||
class Admin::PlanilhasSimpliRouteController < ApplicationController
|
||||
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
|
||||
|
||||
def show
|
||||
authorize :planilha_simpli_route
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
end
|
||||
|
||||
# GET /admin/planilha_simpli_route/baixar?tabela=gade_entregas_...
|
||||
def baixar
|
||||
authorize :planilha_simpli_route
|
||||
|
||||
tabela = params[:tabela].to_s
|
||||
unless Operacao.valida?(tabela)
|
||||
return redirect_to(admin_planilha_simpli_route_path, alert: 'Selecione uma operação válida.')
|
||||
end
|
||||
|
||||
carga = SimpliRoute::PlanilhaCarga.new(tabela)
|
||||
linhas = carga.linhas
|
||||
|
||||
if linhas.empty?
|
||||
return redirect_to(admin_planilha_simpli_route_path,
|
||||
alert: "A operação #{carga.label} não tem registros.")
|
||||
end
|
||||
|
||||
binario = SimpliRoute::PlanilhaCargaXlsx.new(linhas).gerar
|
||||
send_data binario,
|
||||
filename: "carga_simpliroute_#{nome_arquivo(carga.label)}.xlsx",
|
||||
type: XLSX_MIME,
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def nome_arquivo(label)
|
||||
label.to_s.parameterize(separator: '_').presence || 'operacao'
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,11 @@
|
||||
# app/controllers/application_controller.rb
|
||||
class ApplicationController < ActionController::Base
|
||||
include Pundit::Authorization
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :authenticate_user!
|
||||
before_action :set_tema
|
||||
before_action :preparar_splash
|
||||
|
||||
# Pundit: redireciona se não autorizado
|
||||
rescue_from Pundit::NotAuthorizedError do |e|
|
||||
@@ -17,11 +19,17 @@ class ApplicationController < ActionController::Base
|
||||
@tema = current_user&.tema_preferido || 'dark'
|
||||
end
|
||||
|
||||
# Mostra a animação de abertura uma única vez, na primeira página HTML após o
|
||||
# login. O flag é gravado em #after_sign_in_path_for (Devise) e nos logins do
|
||||
# motorista (PIN/QR); aqui ele é lido e apagado (one-shot).
|
||||
def preparar_splash
|
||||
return unless request.get? && request.format.html?
|
||||
|
||||
@mostrar_splash = session.delete(:mostrar_splash)
|
||||
end
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
if resource.motorista?
|
||||
motorista_dashboard_path
|
||||
else
|
||||
dashboard_path
|
||||
end
|
||||
session[:mostrar_splash] = true
|
||||
resource.motorista? ? motorista_dashboard_path : dashboard_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,24 +5,53 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
before_action :set_consolidacao
|
||||
before_action :bloquear_se_finalizada, except: %i[validar revisar]
|
||||
|
||||
# Rótulo/sentinela do grupo de entregas sem veículo no filtro "fechar por carro".
|
||||
SEM_VEICULO = '(sem veículo)'.freeze
|
||||
|
||||
# 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]
|
||||
@veiculos_sel = veiculos_param(params[:vehicle]) # filtro por veículo(s) — multi-seleção
|
||||
|
||||
# Entregas pagas do período no banco existente (read-only)
|
||||
@entregas = Entrega.pagas
|
||||
# Entregas atendidas do período no banco existente (read-only): sucesso +
|
||||
# insucesso, pois o motorista foi ao local e a entrega precisa constar.
|
||||
# Período pela DATA REAL (checkout), não pela planejada.
|
||||
@entregas = Entrega.atendidas
|
||||
.da_conta_gade
|
||||
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||
.no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||
.do_motorista(@motorista)
|
||||
@entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||
@entregas = @entregas.order(:planned_date)
|
||||
@entregas = @entregas.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||
@entregas = @entregas.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
||||
@entregas = filtrar_por_veiculo(@entregas, @veiculos_sel) if @veiculos_sel.any?
|
||||
@entregas = @entregas.order(:checkout)
|
||||
|
||||
# Classificações já feitas — indexadas por tracking_id
|
||||
# Classificações já feitas — agrupadas por tracking_id (uma entrega pode ter
|
||||
# vários pilares). { tracking_id => [ConsolidacaoEntrega, ...] }
|
||||
@classificacoes = @consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: @motorista)
|
||||
.index_by(&:tracking_id)
|
||||
.group_by(&:tracking_id)
|
||||
|
||||
# Status "fechado" por veículo (filtro + acompanhamento) e totais com escopo:
|
||||
# quando um carro está selecionado, a barra reflete só aquele carro.
|
||||
@veiculos_status = veiculos_status(@motorista)
|
||||
if @veiculos_sel.any?
|
||||
sel = @veiculos_status.select { |v| @veiculos_sel.include?(v[:vehicle]) }
|
||||
@total_entregas = sel.sum { |v| v[:eligible] }
|
||||
@classificadas = sel.sum { |v| v[:classificadas] }
|
||||
else
|
||||
@total_entregas = @consolidacao.entregas_elegiveis_count(@motorista)
|
||||
@classificadas = @consolidacao.classificadas_count(@motorista)
|
||||
end
|
||||
|
||||
# Nomes dos motoristas ATIVOS — alimenta o seletor do apontamento 100% manual.
|
||||
@motoristas_consolidacao = @consolidacao.consolidacao_motoristas.ativos.pluck(:motorista_nome)
|
||||
|
||||
# Lançamentos manuais do motorista (apontamentos + termo) — feedback no resumo
|
||||
# e na barra de progresso. Ficam fora do conjunto elegível do período.
|
||||
@manuais = contar_manuais(@consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: @motorista, manual: true))
|
||||
|
||||
# Filtro "apenas não classificadas"
|
||||
if params[:apenas_pendentes] == '1'
|
||||
@@ -33,7 +62,9 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
entrega_normal: Configuracao.preco_entrega,
|
||||
retirada: Configuracao.preco_retirada,
|
||||
bonus: Configuracao.preco_bonus,
|
||||
desconto: Configuracao.preco_desconto
|
||||
desconto: Configuracao.preco_desconto,
|
||||
extraordinaria: Configuracao.preco_extraordinaria,
|
||||
termo: Configuracao.preco_termo
|
||||
}
|
||||
end
|
||||
|
||||
@@ -44,17 +75,33 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
|
||||
return render_nao_elegivel unless entrega_elegivel?(params[:motorista], params[:tracking_id])
|
||||
|
||||
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!
|
||||
# Toggle do pilar: se já existe, remove; senão, adiciona (permite combinar
|
||||
# vários pilares na mesma entrega).
|
||||
ce = @consolidacao.consolidacao_entregas
|
||||
.find_by(tracking_id: params[:tracking_id], tipo: params[:tipo])
|
||||
if ce
|
||||
ce.destroy
|
||||
ativo = false
|
||||
else
|
||||
@consolidacao.consolidacao_entregas.create!(
|
||||
tracking_id: params[:tracking_id],
|
||||
motorista_nome: params[:motorista],
|
||||
tipo: params[:tipo],
|
||||
valor_aplicado: valor_classificacao(params[:tipo]),
|
||||
vehicle: veiculo_do_tracking(params[:tracking_id]), # snapshot p/ fechar por veículo
|
||||
created_by: current_user.id
|
||||
)
|
||||
ativo = true
|
||||
end
|
||||
|
||||
recalcular_motorista(params[:motorista])
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(params[:motorista]) }
|
||||
format.json do
|
||||
render json: resumo_json(params[:motorista], vehicles: params[:vehicle]).merge(
|
||||
ativo: ativo, tracking_id: params[:tracking_id], tipo: params[:tipo]
|
||||
)
|
||||
end
|
||||
format.html { redirect_back fallback_location: wizard_consolidacao_path(@consolidacao) }
|
||||
end
|
||||
end
|
||||
@@ -70,31 +117,45 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
|
||||
return render_nao_elegivel unless motorista_na_consolidacao?(motorista)
|
||||
|
||||
elegiveis = tracking_ids_elegiveis(motorista)
|
||||
# "Marcar todos como Normal" → sem tracking_ids = todas as elegíveis do período.
|
||||
# Recorte por veículo(s) selecionado(s). Sem veículo = todos.
|
||||
elegiveis = tracking_ids_elegiveis(motorista, params[:vehicle])
|
||||
# Sem tracking_ids = todas as elegíveis do período/veículo (botão "todos").
|
||||
# Com tracking_ids → mantém só as que de fato são elegíveis para o motorista.
|
||||
tracking_ids = tracking_ids.empty? ? elegiveis : (tracking_ids & elegiveis)
|
||||
|
||||
valor = valor_para_tipo(tipo)
|
||||
remover = params[:acao].to_s == 'remover'
|
||||
|
||||
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!
|
||||
if remover
|
||||
# Desmarca o pilar das entregas selecionadas (mantém os demais pilares).
|
||||
@consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: motorista, tipo: tipo, tracking_id: tracking_ids)
|
||||
.destroy_all
|
||||
else
|
||||
# ADICIONA o pilar às entregas (não remove os demais já marcados).
|
||||
valor = valor_classificacao(tipo)
|
||||
veiculos = veiculos_dos_trackings(tracking_ids) # tracking_id => vehicle (snapshot)
|
||||
tracking_ids.each do |tid|
|
||||
ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: tid, tipo: tipo)
|
||||
ce.assign_attributes(motorista_nome: motorista,
|
||||
valor_aplicado: valor, created_by: current_user.id)
|
||||
ce.vehicle = veiculos[tid] if ce.vehicle.blank?
|
||||
ce.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
recalcular_motorista(motorista)
|
||||
auditar!(:editar, @consolidacao,
|
||||
dados_novos: { acao_massa: tipo, qtd: tracking_ids.size, motorista: motorista })
|
||||
dados_novos: { acao_massa: tipo, modo: (remover ? 'remover' : 'adicionar'),
|
||||
qtd: tracking_ids.size, motorista: motorista })
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista) }
|
||||
format.json { render json: resumo_json(motorista, vehicles: params[:vehicle]) }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
|
||||
notice: "#{tracking_ids.size} entregas marcadas como #{tipo.humanize}."
|
||||
verbo = remover ? 'desmarcadas de' : 'marcadas como'
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle]).presence),
|
||||
notice: "#{tracking_ids.size} entregas #{verbo} #{tipo.humanize}."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -105,17 +166,27 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
authorize @consolidacao, :show?
|
||||
@motorista = params[:motorista]
|
||||
|
||||
@classificacoes = @consolidacao.consolidacao_entregas
|
||||
.where(motorista_nome: @motorista)
|
||||
.order(:created_at)
|
||||
todas = @consolidacao.consolidacao_entregas.where(motorista_nome: @motorista)
|
||||
|
||||
@resumo = @classificacoes.group(:tipo).count
|
||||
@total = @classificacoes.sum do |c|
|
||||
# Agregados calculados sobre o conjunto COMPLETO (não só a página exibida).
|
||||
# sum(:quantidade) (default 1) conta os termos lançados em lote como N entregas.
|
||||
@resumo = todas.group(:tipo).sum(:quantidade)
|
||||
@total = todas.sum do |c|
|
||||
c.tipo == 'desconto' ? -c.valor_aplicado : c.valor_aplicado
|
||||
end
|
||||
@total_pilares = todas.count
|
||||
@total_classificadas = todas.distinct.count(:tracking_id)
|
||||
|
||||
# Próximo motorista pendente (para botão "Próximo Motorista")
|
||||
nomes_completos = @consolidacao.consolidacao_motoristas.map(&:motorista_nome)
|
||||
# Paginação só da tabela exibida — 20 padrão, com seletor 20/50/100
|
||||
@por_pagina = [20, 50, 100].include?(params[:por_pagina].to_i) ? params[:por_pagina].to_i : 20
|
||||
begin
|
||||
@pagy, @classificacoes = pagy(todas.order(:created_at), limit: @por_pagina)
|
||||
rescue Pagy::OverflowError
|
||||
@pagy, @classificacoes = pagy(todas.order(:created_at), limit: @por_pagina, page: 1)
|
||||
end
|
||||
|
||||
# Próximo motorista pendente (para botão "Próximo Motorista") — só ativos
|
||||
nomes_completos = @consolidacao.consolidacao_motoristas.ativos.map(&:motorista_nome)
|
||||
@proximo = nomes_completos.find do |nome|
|
||||
next false if nome == @motorista
|
||||
total = @consolidacao.entregas_elegiveis_count(nome)
|
||||
@@ -123,33 +194,281 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# GET /consolidacoes/:consolidacao_id/consolidacao_entregas/buscar_nf?nf=123
|
||||
# APONTAMENTO MANUAL — busca a NF na SimpleRoute (sem filtro de período).
|
||||
def buscar_nf
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
nf = params[:nf].to_s.strip
|
||||
return render(json: { ok: false, erro: 'Informe o número da nota.' }, status: :unprocessable_entity) if nf.blank?
|
||||
|
||||
entregas = Entrega.da_conta_gade.por_nf(nf).order(:checkout)
|
||||
|
||||
if entregas.empty?
|
||||
render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." }
|
||||
else
|
||||
render json: { ok: true, nf: nf, entregas: entregas.map(&:resumo_apontamento) }
|
||||
end
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar
|
||||
# Cria um apontamento manual a partir de um tracking_id encontrado pela NF.
|
||||
# Aceita um ou vários pilares (tipos[]) na mesma entrega.
|
||||
def apontar
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id])
|
||||
return render_apontamento_erro('Entrega não encontrada na base de rastreio.') unless entrega
|
||||
|
||||
motorista = @consolidacao.adicionar_apontamento!(entrega, tipos_param, current_user)
|
||||
auditar!(:editar, @consolidacao,
|
||||
dados_novos: { apontamento_manual: entrega.numero_nf, tipos: tipos_param, motorista: motorista })
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
|
||||
notice: "Apontamento da nota #{entrega.numero_nf} adicionado para #{motorista}."
|
||||
end
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
render_apontamento_erro(e.message)
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_manual
|
||||
# Apontamento 100% MANUAL — não consulta a base de rastreio. O admin informa o
|
||||
# motorista (pode ser outro que não o da tela), a NF (texto livre), uma
|
||||
# observação/local e os pilares. Usado quando o motorista foi ao local mas a
|
||||
# NF ficou registrada com outro motorista no rastreio.
|
||||
def apontar_manual
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
motorista = @consolidacao.adicionar_apontamento_manual!(
|
||||
motorista: params[:motorista].to_s.strip,
|
||||
tipos: tipos_param,
|
||||
nf: params[:nf],
|
||||
obs: params[:obs],
|
||||
valor_extra: valor_extraordinaria_param,
|
||||
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
|
||||
user: current_user
|
||||
)
|
||||
auditar!(:editar, @consolidacao,
|
||||
dados_novos: { apontamento_100_manual: params[:nf], motorista: motorista, tipos: tipos_param })
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
|
||||
notice: "Apontamento manual adicionado para #{motorista}."
|
||||
end
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
render_apontamento_erro(e.message)
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/apontar_termo
|
||||
# ENTREGA DE TERMO — lança um lote de N termos para o motorista. Não há NF nem
|
||||
# código de rastreio: só a quantidade, a um preço fixo configurável.
|
||||
def apontar_termo
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
motorista = @consolidacao.adicionar_termos!(
|
||||
motorista: params[:motorista].to_s.strip,
|
||||
quantidade: params[:quantidade],
|
||||
vehicle: params[:vehicle], # veículo escolhido no lançamento (Q3)
|
||||
user: current_user
|
||||
)
|
||||
auditar!(:editar, @consolidacao,
|
||||
dados_novos: { entrega_termo: params[:quantidade].to_i, motorista: motorista })
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: motorista),
|
||||
notice: "#{params[:quantidade].to_i} entrega(s) de termo adicionada(s) para #{motorista}."
|
||||
end
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
render_apontamento_erro(e.message)
|
||||
end
|
||||
|
||||
# DELETE /consolidacoes/:consolidacao_id/consolidacao_entregas/remover_apontamento
|
||||
# Remove um apontamento manual — por ?id= (um pilar) ou por ?tracking_id=&motorista=
|
||||
# (todos os pilares daquela nota). Não passa pelo guard de elegibilidade, pois o
|
||||
# tracking está fora do período (por isso não reusa o toggle classificar).
|
||||
def remover_apontamento
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
scope = @consolidacao.consolidacao_entregas.apontamentos
|
||||
registros = if params[:id].present?
|
||||
Array(scope.find_by(id: params[:id]))
|
||||
else
|
||||
scope.where(tracking_id: params[:tracking_id], motorista_nome: params[:motorista]).to_a
|
||||
end
|
||||
return render_apontamento_erro('Apontamento não encontrado.') if registros.empty?
|
||||
|
||||
motorista = registros.first.motorista_nome
|
||||
tracking = registros.first.tracking_id
|
||||
registros.each(&:destroy)
|
||||
@consolidacao.recalcular_motorista!(motorista)
|
||||
auditar!(:editar, @consolidacao,
|
||||
dados_novos: { remover_apontamento_manual: tracking, motorista: motorista })
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista).merge(motorista: motorista) }
|
||||
format.html { redirect_back fallback_location: consolidacao_path(@consolidacao), notice: 'Apontamento removido.' }
|
||||
end
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:consolidacao_id/consolidacao_entregas/fechar_veiculo
|
||||
# Consolida (fecha) UM veículo do motorista. Exige que o veículo esteja 100%
|
||||
# classificado. O pagamento continua sendo por motorista (não é pago aqui).
|
||||
def fechar_veiculo
|
||||
authorize @consolidacao, :update?
|
||||
motorista = params[:motorista]
|
||||
veiculo = params[:vehicle].to_s
|
||||
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
|
||||
|
||||
status = veiculos_status(motorista).find { |v| v[:vehicle] == veiculo }
|
||||
return render_veiculo_erro('Classifique todas as entregas deste veículo antes de consolidar.') if status && !status[:completo]
|
||||
|
||||
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
|
||||
cv.fechar!(current_user)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { fechar_veiculo: veiculo, motorista: motorista })
|
||||
|
||||
responder_veiculo(motorista, "Veículo #{veiculo} consolidado.")
|
||||
end
|
||||
|
||||
# POST .../reabrir_veiculo — reabre um veículo já consolidado (reversível).
|
||||
def reabrir_veiculo
|
||||
authorize @consolidacao, :update?
|
||||
motorista = params[:motorista]
|
||||
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
|
||||
return render_veiculo_erro('Veículo não encontrado.') unless cv
|
||||
|
||||
cv.reabrir!(current_user)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { reabrir_veiculo: cv.vehicle, motorista: motorista })
|
||||
responder_veiculo(motorista, "Veículo #{cv.vehicle} reaberto.")
|
||||
end
|
||||
|
||||
# POST .../arquivar_veiculo — tira um veículo do gate de finalização (reversível).
|
||||
# As entregas já classificadas dele continuam somando no pagamento do motorista.
|
||||
def arquivar_veiculo
|
||||
authorize @consolidacao, :update?
|
||||
motorista = params[:motorista]
|
||||
veiculo = params[:vehicle].to_s
|
||||
return render_veiculo_erro('Veículo/motorista inválido.') unless motorista_na_consolidacao?(motorista) && veiculo.present?
|
||||
|
||||
cv = @consolidacao.consolidacao_veiculos.find_or_create_by!(motorista_nome: motorista, vehicle: veiculo)
|
||||
cv.arquivar!(current_user)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { arquivar_veiculo: veiculo, motorista: motorista })
|
||||
responder_veiculo(motorista, "Veículo #{veiculo} arquivado — fora da finalização.")
|
||||
end
|
||||
|
||||
# POST .../desarquivar_veiculo — restaura um veículo arquivado.
|
||||
def desarquivar_veiculo
|
||||
authorize @consolidacao, :update?
|
||||
motorista = params[:motorista]
|
||||
cv = @consolidacao.consolidacao_veiculos.find_by(motorista_nome: motorista, vehicle: params[:vehicle].to_s)
|
||||
return render_veiculo_erro('Veículo não encontrado.') unless cv
|
||||
|
||||
cv.desarquivar!(current_user)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { desarquivar_veiculo: cv.vehicle, motorista: motorista })
|
||||
responder_veiculo(motorista, "Veículo #{cv.vehicle} restaurado.")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_consolidacao
|
||||
@consolidacao = Consolidacao.find(params[:consolidacao_id])
|
||||
end
|
||||
|
||||
# Resposta padrão das ações de veículo: JSON com o resumo atualizado (barra +
|
||||
# chips com o novo estado) ou redirect de volta pra tela de validação.
|
||||
def responder_veiculo(motorista, aviso)
|
||||
respond_to do |format|
|
||||
format.json { render json: resumo_json(motorista, vehicles: params[:vehicle_sel]) }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(
|
||||
@consolidacao, motorista: motorista, vehicle: veiculos_param(params[:vehicle_sel]).presence
|
||||
), notice: aviso
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def render_veiculo_erro(msg)
|
||||
respond_to do |format|
|
||||
format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity }
|
||||
format.html do
|
||||
redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: params[:motorista]),
|
||||
alert: msg
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Veículo (snapshot da rastreio) de UM tracking_id. nil para sintéticos.
|
||||
def veiculo_do_tracking(tracking_id)
|
||||
Entrega.da_conta_gade.find_by(tracking_id: tracking_id)&.vehicle
|
||||
end
|
||||
|
||||
# Mapa tracking_id => vehicle para um lote (uma query só).
|
||||
def veiculos_dos_trackings(ids)
|
||||
return {} if ids.blank?
|
||||
|
||||
Entrega.da_conta_gade.where(tracking_id: ids).pluck(:tracking_id, :vehicle).to_h
|
||||
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
|
||||
|
||||
# Motorista realmente faz parte desta consolidação?
|
||||
# Motorista realmente faz parte desta consolidação? (ignora arquivados)
|
||||
def motorista_na_consolidacao?(nome)
|
||||
nome.present? && @consolidacao.consolidacao_motoristas.exists?(motorista_nome: nome)
|
||||
nome.present? && @consolidacao.consolidacao_motoristas.ativos.exists?(motorista_nome: nome)
|
||||
end
|
||||
|
||||
# tracking_ids pagos/elegíveis para o motorista no período (respeita veículos)
|
||||
def tracking_ids_elegiveis(motorista)
|
||||
# tracking_ids atendidos/elegíveis para o motorista no período (respeita veículos).
|
||||
# Atendidas = sucesso + insucesso com checkout (o motorista foi ao local).
|
||||
# `vehicle` (opcional) recorta para UM carro — é o que faz "Marcar todos" fechar
|
||||
# só o veículo selecionado.
|
||||
def tracking_ids_elegiveis(motorista, vehicle = nil)
|
||||
return [] unless motorista.present?
|
||||
|
||||
rel = Entrega.pagas.da_conta_gade
|
||||
.no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||
rel = Entrega.atendidas.da_conta_gade
|
||||
.no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||
.do_motorista(motorista)
|
||||
rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||
rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||
rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
||||
rel = filtrar_por_veiculo(rel, vehicle) if vehicle.present?
|
||||
rel.pluck(:tracking_id)
|
||||
end
|
||||
|
||||
# Normaliza o parâmetro de veículo (string única OU array vindo de vehicle[])
|
||||
# numa lista limpa de selecionados.
|
||||
def veiculos_param(raw)
|
||||
Array(raw).reject(&:blank?)
|
||||
end
|
||||
|
||||
# Recorte por um OU MAIS veículos (trata o grupo "sem veículo"). Lista vazia
|
||||
# = sem recorte (todos os veículos).
|
||||
def filtrar_por_veiculo(rel, veiculos)
|
||||
lista = veiculos_param(veiculos)
|
||||
return rel if lista.empty?
|
||||
valores = lista.reject { |v| v == SEM_VEICULO }
|
||||
valores += [nil, ''] if lista.include?(SEM_VEICULO)
|
||||
rel.where(vehicle: valores)
|
||||
end
|
||||
|
||||
# Status "fechado" por veículo do motorista: lista de
|
||||
# { vehicle:, eligible:, classificadas:, fechado: }. Fonte única do filtro de
|
||||
# carros e do acompanhamento (chips). 2 queries: elegíveis (com a coluna do
|
||||
# veículo) e as já classificadas (não-manuais).
|
||||
# Status por veículo do motorista (fonte única no model: Consolidacao#veiculos_do_motorista).
|
||||
def veiculos_status(motorista)
|
||||
@consolidacao.veiculos_do_motorista(motorista)
|
||||
end
|
||||
|
||||
# Valida que a entrega pertence ao motorista informado e ao período/veículo da
|
||||
# consolidação — impede classificar tracking_ids arbitrários via requisição.
|
||||
def entrega_elegivel?(motorista, tracking_id)
|
||||
@@ -158,6 +477,19 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
tracking_ids_elegiveis(motorista).include?(tracking_id)
|
||||
end
|
||||
|
||||
# Pilares (tipos) selecionados no modal — aceita tipos[] (vários) ou tipo (um).
|
||||
def tipos_param
|
||||
tipos = Array(params[:tipos]).presence || Array(params[:tipo])
|
||||
tipos.reject(&:blank?)
|
||||
end
|
||||
|
||||
def render_apontamento_erro(msg)
|
||||
respond_to do |format|
|
||||
format.json { render json: { ok: false, erro: msg }, status: :unprocessable_entity }
|
||||
format.html { redirect_to wizard_consolidacao_path(@consolidacao), alert: msg }
|
||||
end
|
||||
end
|
||||
|
||||
def render_nao_elegivel
|
||||
respond_to do |format|
|
||||
format.json { render json: { ok: false, erro: 'Entrega não elegível para este motorista.' }, status: :unprocessable_entity }
|
||||
@@ -169,33 +501,70 @@ class ConsolidacaoEntregasController < ApplicationController
|
||||
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
|
||||
ConsolidacaoEntrega.valor_para(tipo)
|
||||
end
|
||||
|
||||
# Valor a aplicar na classificação. Para o pilar "extraordinária" o admin pode
|
||||
# informar um valor customizado (params[:valor]) por ser uma exceção; se vier
|
||||
# ausente/inválido, cai no preço padrão de Configurações. Demais pilares usam
|
||||
# sempre o preço configurado.
|
||||
def valor_classificacao(tipo)
|
||||
return valor_para_tipo(tipo) unless tipo.to_s == 'extraordinaria'
|
||||
|
||||
valor = params[:valor].to_s.strip.tr(',', '.')
|
||||
valor = BigDecimal(valor) rescue nil
|
||||
valor.present? && valor >= 0 ? valor : Configuracao.preco_extraordinaria
|
||||
end
|
||||
|
||||
# Valor customizado da Extraordinária vindo do formulário (params[:valor]),
|
||||
# aceitando vírgula decimal. Retorna nil se ausente/inválido — aí o model usa
|
||||
# o preço padrão de Configurações.
|
||||
def valor_extraordinaria_param
|
||||
bruto = params[:valor].to_s.strip.tr(',', '.')
|
||||
return nil if bruto.blank?
|
||||
|
||||
valor = BigDecimal(bruto) rescue nil
|
||||
valor if valor && valor >= 0
|
||||
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))
|
||||
@consolidacao.recalcular_motorista!(nome)
|
||||
end
|
||||
|
||||
def resumo_json(motorista)
|
||||
# Conta lançamentos manuais como ENTREGAS: cada termo vale `quantidade` (lote
|
||||
# numa só linha); os demais apontamentos manuais valem 1 por nota (tracking).
|
||||
def contar_manuais(rel_manual)
|
||||
rel_manual.termo.sum(:quantidade) +
|
||||
rel_manual.where.not(tipo: ConsolidacaoEntrega.tipos[:termo]).distinct.count(:tracking_id)
|
||||
end
|
||||
|
||||
# `vehicles` (opcional, string ou array): quando presente, TODO o resumo
|
||||
# (barra de progresso, contagem por pilar e valor total) reflete apenas o(s)
|
||||
# veículo(s) selecionado(s). `veiculos_status` acompanha o payload para o front
|
||||
# atualizar os chips sem recarregar.
|
||||
def resumo_json(motorista, vehicles: nil)
|
||||
lista = veiculos_param(vehicles)
|
||||
classificacoes = @consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||
status = veiculos_status(motorista)
|
||||
classificadas_motorista = classificacoes.distinct.count(:tracking_id)
|
||||
|
||||
if lista.any?
|
||||
# Recorta as classificações às NFs dos veículos selecionados.
|
||||
ids = tracking_ids_elegiveis(motorista, lista)
|
||||
classificacoes = classificacoes.where(tracking_id: ids)
|
||||
escopo = status.select { |v| lista.include?(v[:vehicle]) }.sum { |v| v[:classificadas] }
|
||||
else
|
||||
escopo = classificadas_motorista
|
||||
end
|
||||
|
||||
{
|
||||
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
|
||||
classificadas: classificadas_motorista,
|
||||
classificadas_escopo: escopo,
|
||||
manuais: contar_manuais(classificacoes.where(manual: true)),
|
||||
por_tipo: classificacoes.group(:tipo).sum(:quantidade),
|
||||
valor_total: classificacoes.sum { |e| e.tipo == 'desconto' ? -e.valor_aplicado : e.valor_aplicado }.to_f,
|
||||
veiculos_status: status
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
class ConsolidacoesController < ApplicationController
|
||||
include Auditavel
|
||||
|
||||
before_action :set_consolidacao, only: %i[show edit update destroy finalizar arquivar wizard]
|
||||
before_action :set_consolidacao,
|
||||
only: %i[show edit update destroy finalizar finalizar_parcial arquivar reativar wizard
|
||||
registrar_pagamento cancelar_pagamento
|
||||
arquivar_motorista desarquivar_motorista
|
||||
excluir_motorista adicionar_motorista
|
||||
preview_extrato gerar_pdf_relatorio gerar_pdf_extrato
|
||||
gerar_pdf_financeiro]
|
||||
|
||||
# Formas de pagamento aceitas (o select da UI usa esta mesma lista).
|
||||
FORMAS_PAGAMENTO = %w[pix transferencia dinheiro].freeze
|
||||
|
||||
# GET /consolidacoes — lista com filtros
|
||||
def index
|
||||
@@ -25,6 +34,10 @@ class ConsolidacoesController < ApplicationController
|
||||
if params[:vehicle].present?
|
||||
@consolidacoes = @consolidacoes.where('vehicle_ids @> ?', [params[:vehicle]].to_json)
|
||||
end
|
||||
|
||||
if %w[pago pendente parcial].include?(params[:pagamento])
|
||||
@consolidacoes = @consolidacoes.public_send("pagamento_#{params[:pagamento]}")
|
||||
end
|
||||
end
|
||||
|
||||
# GET /consolidacoes/new
|
||||
@@ -36,12 +49,14 @@ class ConsolidacoesController < ApplicationController
|
||||
)
|
||||
@motoristas = Entrega.motoristas_ativos
|
||||
@veiculos = Entrega.veiculos_unicos
|
||||
@operacoes = Operacao.todas
|
||||
end
|
||||
|
||||
# POST /consolidacoes
|
||||
def create
|
||||
authorize Consolidacao
|
||||
@consolidacao = Consolidacao.new(consolidacao_params.merge(created_by: current_user.id))
|
||||
@consolidacao.operacoes = Operacao.sanitizar(@consolidacao.operacoes)
|
||||
|
||||
motoristas = Array(params[:motoristas]).reject(&:blank?)
|
||||
|
||||
@@ -65,24 +80,121 @@ class ConsolidacoesController < ApplicationController
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# GET /consolidacoes/veiculos_disponiveis?motoristas[]=X&inicio=&fim=
|
||||
# Lista (JSON) os veículos usados pelos motoristas selecionados no período —
|
||||
# alimenta o filtro dinâmico de veículos no formulário de nova consolidação.
|
||||
def veiculos_disponiveis
|
||||
authorize Consolidacao, :new?
|
||||
veiculos = Entrega.veiculos_de_motoristas(
|
||||
params[:motoristas], inicio: params[:inicio], fim: params[:fim]
|
||||
)
|
||||
render json: { veiculos: veiculos }
|
||||
end
|
||||
|
||||
# GET /consolidacoes/operacoes_disponiveis — lista (JSON) as tabelas de operação.
|
||||
def operacoes_disponiveis
|
||||
authorize Consolidacao, :new?
|
||||
render json: { operacoes: Operacao.todas }
|
||||
end
|
||||
|
||||
# GET /consolidacoes/dados_da_operacao?operacoes[]=X — motoristas/veículos/período
|
||||
# derivados das operações selecionadas, para pré-preencher o formulário.
|
||||
def dados_da_operacao
|
||||
authorize Consolidacao, :new?
|
||||
render json: Operacao.dados(params[:operacoes])
|
||||
end
|
||||
|
||||
# GET /consolidacoes/buscar_nf?nf=123 — busca a NF na SimpleRoute (nota avulsa).
|
||||
def buscar_nf
|
||||
authorize Consolidacao, :create?
|
||||
|
||||
nf = params[:nf].to_s.strip
|
||||
return render(json: { ok: false, erro: 'Informe o número da nota.' }, status: :unprocessable_entity) if nf.blank?
|
||||
|
||||
entregas = Entrega.da_conta_gade.por_nf(nf).order(:checkout)
|
||||
if entregas.empty?
|
||||
render json: { ok: false, erro: "Nota #{nf} não encontrada na base de rastreio." }
|
||||
else
|
||||
render json: { ok: true, nf: nf, entregas: entregas.map(&:resumo_apontamento) }
|
||||
end
|
||||
end
|
||||
|
||||
# POST /consolidacoes/avulsa — cria uma consolidação JÁ a partir de uma NF
|
||||
# avulsa (entrega que chegou por fora) e adiciona o apontamento ao motorista.
|
||||
def avulsa
|
||||
authorize Consolidacao, :create?
|
||||
|
||||
entrega = Entrega.da_conta_gade.find_by(tracking_id: params[:tracking_id])
|
||||
return redirect_to(consolidacoes_path, alert: 'Entrega não encontrada na base de rastreio.') unless entrega
|
||||
|
||||
data_ref = (entrega.checkout || entrega.planned_date)&.to_date || Date.current
|
||||
|
||||
consolidacao = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
consolidacao = Consolidacao.create!(
|
||||
nome: "Avulsa nf#{entrega.numero_nf}",
|
||||
data_inicio: data_ref,
|
||||
data_fim: data_ref.end_of_month,
|
||||
created_by: current_user.id
|
||||
)
|
||||
consolidacao.adicionar_apontamento!(entrega, Array(params[:tipos]).reject(&:blank?), current_user)
|
||||
end
|
||||
|
||||
auditar!(:criar, consolidacao,
|
||||
dados_novos: { avulsa_nf: entrega.numero_nf, motorista: entrega.driver, tipos: params[:tipos] })
|
||||
redirect_to consolidacao_path(consolidacao),
|
||||
notice: "Consolidação avulsa criada para #{entrega.driver} (NF #{entrega.numero_nf})."
|
||||
rescue ArgumentError => e
|
||||
redirect_to consolidacoes_path, alert: e.message
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_to consolidacoes_path, alert: "Não foi possível criar a consolidação avulsa: #{e.message}"
|
||||
end
|
||||
|
||||
# GET /consolidacoes/:id — visão geral
|
||||
def show
|
||||
authorize @consolidacao
|
||||
@motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome)
|
||||
# A gestão de arquivamento de motorista fica no wizard; aqui listamos só os ativos.
|
||||
@motoristas = @consolidacao.consolidacao_motoristas.ativos.order(:motorista_nome)
|
||||
# Apontamentos manuais agrupados por nota (tracking) p/ exibição e remoção.
|
||||
@apontamentos = @consolidacao.consolidacao_entregas.apontamentos
|
||||
.order(:motorista_nome, :tracking_id)
|
||||
.group_by { |ce| [ce.motorista_nome, ce.tracking_id] }
|
||||
# Veículos consolidados por motorista (breakdown informativo no card).
|
||||
@veiculos_por_motorista = @consolidacao.consolidacao_veiculos.ativos
|
||||
.order(:vehicle)
|
||||
.group_by(&: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|
|
||||
@arquivados = @consolidacao.consolidacao_motoristas.arquivados.order(:motorista_nome)
|
||||
# Alimenta o datalist do "Adicionar motorista": motoristas PREVISTOS no período
|
||||
# da consolidação (atendidas, respeitando filtros de veículo/operação) e os
|
||||
# veículos que cada um usou — para o usuário escolher com contexto.
|
||||
@motoristas_periodo = motoristas_previstos_no_periodo
|
||||
@motoristas = @consolidacao.consolidacao_motoristas.ativos.order(:motorista_nome).map do |cm|
|
||||
total = @consolidacao.entregas_elegiveis_count(cm.motorista_nome)
|
||||
classificadas = @consolidacao.classificadas_count(cm.motorista_nome)
|
||||
apontamentos = @consolidacao.consolidacao_entregas
|
||||
.apontamentos
|
||||
.where(motorista_nome: cm.motorista_nome)
|
||||
.distinct.count(:tracking_id)
|
||||
# Status por veículo (breakdown do card): total usados, consolidados (🔒),
|
||||
# pendentes (não dá p/ consolidar ainda) e prontos (100%, falta consolidar).
|
||||
vinfo = @consolidacao.veiculos_do_motorista(cm.motorista_nome).reject { |v| v[:arquivado] }
|
||||
{
|
||||
registro: cm,
|
||||
total: total,
|
||||
classificadas: classificadas,
|
||||
completo: total.positive? && classificadas >= total
|
||||
registro: cm,
|
||||
total: total,
|
||||
classificadas: classificadas,
|
||||
apontamentos: apontamentos,
|
||||
veiculos_total: vinfo.size,
|
||||
veiculos_fechados: vinfo.count { |v| v[:fechado] },
|
||||
veiculos_prontos: vinfo.count { |v| !v[:fechado] && v[:completo] },
|
||||
veiculos_pendentes: vinfo.select { |v| !v[:fechado] && !v[:completo] && v[:eligible].positive? }
|
||||
.map { |v| v[:vehicle] },
|
||||
completo: @consolidacao.motorista_completo?(cm.motorista_nome)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -97,7 +209,26 @@ class ConsolidacoesController < ApplicationController
|
||||
redirect_to @consolidacao, notice: 'Consolidação finalizada! Motoristas notificados.'
|
||||
else
|
||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
alert: 'Não é possível finalizar: existem entregas não classificadas.'
|
||||
alert: 'Não é possível finalizar: ainda há entregas não classificadas. Classifique-as ou arquive o veículo pendente.'
|
||||
end
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/finalizar_parcial
|
||||
# Atalho "finalizar só os veículos consolidados": arquiva os veículos que ainda
|
||||
# não dá para consolidar (não 100% classificados) e finaliza a consolidação. Os
|
||||
# veículos consolidados/prontos seguem no fechamento; os pendentes ficam de fora.
|
||||
def finalizar_parcial
|
||||
authorize @consolidacao, :update?
|
||||
|
||||
arquivados = @consolidacao.arquivar_veiculos_pendentes!(current_user)
|
||||
if @consolidacao.finalizar!(current_user)
|
||||
auditar!(:finalizar, @consolidacao, dados_novos: { finalizar_parcial: true, veiculos_arquivados: arquivados })
|
||||
NotificacaoService.notificar_finalizacao(@consolidacao)
|
||||
redirect_to @consolidacao,
|
||||
notice: "Consolidação finalizada. #{arquivados.size} veículo(s) pendente(s) ficaram de fora. Motoristas notificados."
|
||||
else
|
||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
alert: 'Não foi possível finalizar: ainda há entregas pendentes fora dos veículos arquivados.'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -109,6 +240,153 @@ class ConsolidacoesController < ApplicationController
|
||||
redirect_to consolidacoes_path, notice: 'Consolidação arquivada.'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/arquivadas — histórico de consolidações arquivadas
|
||||
def arquivadas
|
||||
authorize Consolidacao
|
||||
@consolidacoes = Consolidacao.arquivadas.recentes.includes(:criador, :arquivador)
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/reativar — restaura uma consolidação arquivada
|
||||
def reativar
|
||||
authorize @consolidacao, :destroy?
|
||||
@consolidacao.reativar!(current_user)
|
||||
auditar!(:reativar, @consolidacao)
|
||||
redirect_to consolidacoes_path, notice: 'Consolidação reativada.'
|
||||
end
|
||||
|
||||
# DELETE /consolidacoes/:id — exclui PERMANENTEMENTE (apenas arquivadas)
|
||||
def destroy
|
||||
authorize @consolidacao, :destroy?
|
||||
@consolidacao.destroy!
|
||||
auditar!(:excluir, @consolidacao)
|
||||
redirect_to arquivadas_consolidacoes_path, notice: 'Consolidação excluída permanentemente.'
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/registrar_pagamento
|
||||
# Marca 1 motorista (param consolidacao_motorista_id) ou TODOS os pendentes.
|
||||
def registrar_pagamento
|
||||
authorize @consolidacao, :registrar_pagamento?
|
||||
return pagamento_indisponivel unless @consolidacao.finalizada?
|
||||
|
||||
forma = params[:forma_pagamento].presence_in(FORMAS_PAGAMENTO) || 'pix'
|
||||
alvos = motoristas_alvo.select { |cm| !cm.pago? }
|
||||
|
||||
# NF obrigatória por motorista. Se faltar para qualquer alvo, aborta sem
|
||||
# marcar nenhum (a NF vai pro painel do motorista e pro Extrato).
|
||||
notas = nf_por_motorista(alvos)
|
||||
if notas.nil?
|
||||
return redirect_to @consolidacao,
|
||||
alert: 'Informe o número da Nota Fiscal de cada motorista antes de marcar como pago.'
|
||||
end
|
||||
|
||||
alvos.each do |cm|
|
||||
cm.marcar_pago!(current_user, forma: forma, nota_fiscal: notas[cm.id])
|
||||
NotificacaoService.notificar_pagamento(@consolidacao, cm)
|
||||
end
|
||||
|
||||
auditar!(:registrar_pagamento, @consolidacao,
|
||||
dados_novos: { motoristas: alvos.map(&:motorista_nome), forma: forma,
|
||||
notas_fiscais: alvos.map { |cm| [cm.motorista_nome, notas[cm.id]] }.to_h })
|
||||
redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :pago)
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/cancelar_pagamento — estorna 1 motorista ou TODOS.
|
||||
def cancelar_pagamento
|
||||
authorize @consolidacao, :cancelar_pagamento?
|
||||
|
||||
alvos = motoristas_alvo.select(&:pago?)
|
||||
alvos.each { |cm| cm.cancelar_pagamento!(current_user) }
|
||||
|
||||
auditar!(:cancelar_pagamento, @consolidacao,
|
||||
dados_novos: { motoristas: alvos.map(&:motorista_nome) })
|
||||
redirect_to @consolidacao, notice: pagamento_notice(alvos.size, :estornado)
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/arquivar_motorista — arquiva 1 motorista da
|
||||
# consolidação (reversível). Ele sai dos totais/pagamento/gate, mas os dados
|
||||
# ficam guardados e podem ser restaurados. Não arquiva motorista já pago.
|
||||
def arquivar_motorista
|
||||
authorize @consolidacao, :update?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
if cm.pago?
|
||||
return redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
alert: "#{cm.motorista_nome} já está pago — estorne o pagamento antes de arquivar."
|
||||
end
|
||||
|
||||
cm.arquivar!(current_user)
|
||||
@consolidacao.recalcular_motorista!(cm.motorista_nome) # tira o valor do total
|
||||
auditar!(:editar, @consolidacao, dados_novos: { arquivar_motorista: cm.motorista_nome })
|
||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
notice: "#{cm.motorista_nome} arquivado. Você pode restaurar quando quiser."
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/desarquivar_motorista — restaura um motorista arquivado.
|
||||
def desarquivar_motorista
|
||||
authorize @consolidacao, :update?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
cm.desarquivar!(current_user)
|
||||
@consolidacao.recalcular_motorista!(cm.motorista_nome) # devolve o valor ao total
|
||||
auditar!(:editar, @consolidacao, dados_novos: { desarquivar_motorista: cm.motorista_nome })
|
||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
notice: "#{cm.motorista_nome} restaurado para a consolidação."
|
||||
end
|
||||
|
||||
# DELETE /consolidacoes/:id/excluir_motorista — apaga de vez 1 motorista desta
|
||||
# consolidação (só admin/gerente). Caminho destrutivo (o reversível é arquivar):
|
||||
# como consolidacao_entregas se liga ao motorista por STRING (motorista_nome,
|
||||
# sem FK/cascade), apagamos os lançamentos dele à mão antes de remover o registro.
|
||||
def excluir_motorista
|
||||
authorize @consolidacao, :destroy?
|
||||
cm = @consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])
|
||||
|
||||
if cm.pago?
|
||||
return redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
alert: "#{cm.motorista_nome} já está pago — estorne o pagamento antes de excluir."
|
||||
end
|
||||
|
||||
nome = cm.motorista_nome
|
||||
ActiveRecord::Base.transaction do
|
||||
@consolidacao.consolidacao_entregas.where(motorista_nome: nome).delete_all
|
||||
@consolidacao.consolidacao_veiculos.where(motorista_nome: nome).delete_all
|
||||
cm.destroy!
|
||||
end
|
||||
# cm já não existe — atualiza o total da consolidação direto (soma dos ativos).
|
||||
@consolidacao.update_column(:valor_total, @consolidacao.consolidacao_motoristas.ativos.sum(:valor_total))
|
||||
auditar!(:editar, @consolidacao, dados_novos: { excluir_motorista: nome })
|
||||
redirect_to wizard_consolidacao_path(@consolidacao),
|
||||
notice: "#{nome} excluído desta consolidação."
|
||||
end
|
||||
|
||||
# POST /consolidacoes/:id/adicionar_motorista — inclui um motorista nesta
|
||||
# consolidação (ou restaura, se estava arquivado). Útil para refazer rápido
|
||||
# depois de excluir/arquivar por erro de precificação.
|
||||
def adicionar_motorista
|
||||
authorize @consolidacao, :update?
|
||||
nome = params[:motorista_nome].to_s.strip
|
||||
|
||||
if nome.blank?
|
||||
return redirect_to wizard_consolidacao_path(@consolidacao), alert: 'Informe o nome do motorista.'
|
||||
end
|
||||
|
||||
cm = @consolidacao.consolidacao_motoristas.find_or_initialize_by(motorista_nome: nome)
|
||||
aviso =
|
||||
if cm.persisted? && cm.arquivado?
|
||||
cm.desarquivar!(current_user)
|
||||
"#{nome} restaurado para a consolidação."
|
||||
elsif cm.persisted?
|
||||
"#{nome} já está nesta consolidação."
|
||||
else
|
||||
cm.save!
|
||||
"#{nome} adicionado à consolidação."
|
||||
end
|
||||
|
||||
@consolidacao.recalcular_motorista!(nome)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { adicionar_motorista: nome })
|
||||
redirect_to wizard_consolidacao_path(@consolidacao), notice: aviso
|
||||
end
|
||||
|
||||
# ── FASE 7 — PDFs ────────────────────────────────────────────
|
||||
|
||||
# GET /consolidacoes/:id/gerar_pdf_relatorio?motorista=X
|
||||
@@ -125,45 +403,139 @@ class ConsolidacoesController < ApplicationController
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/:id/gerar_pdf_holerite?motorista=X
|
||||
def gerar_pdf_holerite
|
||||
# GET /consolidacoes/:id/gerar_pdf_financeiro — relatório financeiro da consolidação
|
||||
def gerar_pdf_financeiro
|
||||
authorize @consolidacao, :show?
|
||||
motorista = params[:motorista]
|
||||
user_mot = User.motorista.find_by('LOWER(nome) = ?', motorista.to_s.downcase)
|
||||
user_mot&.regenerar_login_token! if user_mot&.login_token.blank?
|
||||
|
||||
pdf = Pdf::HoleritePdf.new(consolidacao: @consolidacao, motorista: motorista,
|
||||
user_motorista: user_mot, app_host: ENV['APP_HOST'])
|
||||
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'holerite', motorista: motorista })
|
||||
pdf = Pdf::RelatorioFinanceiroConsolidacaoPdf.new(consolidacao: @consolidacao)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'financeiro' })
|
||||
|
||||
send_data pdf.render,
|
||||
filename: "holerite_#{motorista.parameterize}_#{@consolidacao.id}.pdf",
|
||||
filename: "financeiro_#{@consolidacao.nome.parameterize}_#{@consolidacao.id}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/:id/preview_holerite?motorista=X — preview HTML antes do PDF
|
||||
def preview_holerite
|
||||
# GET /consolidacoes/:id/gerar_pdf_extrato?motorista=X
|
||||
def gerar_pdf_extrato
|
||||
autorizar_extrato!(params[:motorista])
|
||||
motorista = params[:motorista]
|
||||
user_mot = buscar_user_motorista(motorista)
|
||||
user_mot&.regenerar_login_token! if user_mot&.login_token.blank?
|
||||
cm = @consolidacao.consolidacao_motoristas.find_by(motorista_nome: motorista)
|
||||
|
||||
pdf = Pdf::ExtratoPdf.new(consolidacao: @consolidacao, motorista: motorista,
|
||||
user_motorista: user_mot, app_host: ENV['APP_HOST'],
|
||||
pago_em: cm&.pago_em, forma_pagamento: cm&.forma_pagamento,
|
||||
nota_fiscal: cm&.nota_fiscal)
|
||||
auditar!(:editar, @consolidacao, dados_novos: { pdf: 'extrato', motorista: motorista })
|
||||
|
||||
send_data pdf.render,
|
||||
filename: "extrato_#{motorista.parameterize}_#{@consolidacao.id}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
# GET /consolidacoes/:id/preview_extrato?motorista=X — preview HTML antes do PDF
|
||||
def preview_extrato
|
||||
authorize @consolidacao, :show?
|
||||
@motorista = params[:motorista]
|
||||
@entregas = @consolidacao.consolidacao_entregas.where(motorista_nome: @motorista)
|
||||
@resumo = @entregas.group(:tipo).count
|
||||
# sum(:quantidade) (default 1) conta os termos lançados em lote como N entregas.
|
||||
@resumo = @entregas.group(:tipo).sum(:quantidade)
|
||||
@total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
render layout: false # renderiza dentro do modal (Turbo Frame)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Acha o User motorista pelo nome para gerar o QR Code de acesso. O
|
||||
# motorista_nome vem dos dados externos de entrega e o users.nome é digitado
|
||||
# no admin — então toleramos diferenças de caixa e de espaços (extras/duplos).
|
||||
# Sem isso, qualquer divergência deixava user_mot nil e o QR sumia do extrato.
|
||||
def buscar_user_motorista(nome)
|
||||
exato = User.motorista.find_by('LOWER(nome) = ?', nome.to_s.downcase.strip)
|
||||
return exato if exato
|
||||
|
||||
alvo = normalizar_nome(nome)
|
||||
User.motorista.find { |u| normalizar_nome(u.nome) == alvo }
|
||||
end
|
||||
|
||||
def normalizar_nome(nome)
|
||||
nome.to_s.strip.downcase.gsub(/\s+/, ' ')
|
||||
end
|
||||
|
||||
# Staff (show?) baixa qualquer extrato. O motorista baixa só o PRÓPRIO — o PDF
|
||||
# já é filtrado pelo nome dele, então não há vazamento de dados de terceiros.
|
||||
def autorizar_extrato!(motorista_nome)
|
||||
if current_user.motorista?
|
||||
raise Pundit::NotAuthorizedError unless motorista_nome.to_s.casecmp?(current_user.nome.to_s)
|
||||
skip_authorization
|
||||
else
|
||||
authorize @consolidacao, :show?
|
||||
end
|
||||
end
|
||||
|
||||
def set_consolidacao
|
||||
@consolidacao = Consolidacao.find(params[:id])
|
||||
end
|
||||
|
||||
# Motoristas com entregas atendidas no período da consolidação (respeitando os
|
||||
# filtros de veículo/operação dela), com os veículos que cada um usou. Alimenta
|
||||
# o datalist de "Adicionar motorista". Retorna [{ nome:, veiculos: [..] }, ...].
|
||||
def motoristas_previstos_no_periodo
|
||||
rel = Entrega.atendidas.da_conta_gade
|
||||
.no_periodo_checkout(@consolidacao.data_inicio, @consolidacao.data_fim)
|
||||
rel = rel.da_veiculo(@consolidacao.vehicle_ids) if @consolidacao.vehicle_ids.present?
|
||||
rel = rel.da_operacoes(@consolidacao.operacoes) if @consolidacao.operacoes.present?
|
||||
|
||||
rel.pluck(:driver, :vehicle)
|
||||
.group_by(&:first)
|
||||
.map { |nome, linhas| { nome: nome, veiculos: linhas.filter_map { |(_, v)| v.presence }.uniq.sort } }
|
||||
.reject { |m| m[:nome].blank? }
|
||||
.sort_by { |m| m[:nome] }
|
||||
end
|
||||
|
||||
# Um motorista específico (consolidacao_motorista_id) ou todos da consolidação.
|
||||
# "Todos" cobre apenas os ATIVOS — motorista arquivado não recebe pagamento.
|
||||
def motoristas_alvo
|
||||
if params[:consolidacao_motorista_id].present?
|
||||
[@consolidacao.consolidacao_motoristas.find(params[:consolidacao_motorista_id])]
|
||||
else
|
||||
@consolidacao.consolidacao_motoristas.ativos.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Mapeia { cm.id => "numero da NF" } a partir de params[:notas_fiscais].
|
||||
# Retorna nil se a NF de QUALQUER alvo estiver em branco (pagamento bloqueado).
|
||||
def nf_por_motorista(alvos)
|
||||
informadas = params[:notas_fiscais] || {}
|
||||
alvos.each_with_object({}) do |cm, mapa|
|
||||
nf = informadas[cm.id.to_s].to_s.strip
|
||||
return nil if nf.blank?
|
||||
|
||||
mapa[cm.id] = nf
|
||||
end
|
||||
end
|
||||
|
||||
def pagamento_indisponivel
|
||||
redirect_to @consolidacao, alert: 'Só é possível registrar pagamento de uma consolidação finalizada.'
|
||||
end
|
||||
|
||||
def pagamento_notice(qtd, acao)
|
||||
return 'Nenhuma alteração: motoristas já estavam no estado solicitado.' if qtd.zero?
|
||||
|
||||
verbo = acao == :pago ? 'marcado(s) como pago(s)' : 'com pagamento estornado'
|
||||
"#{qtd} motorista(s) #{verbo}."
|
||||
end
|
||||
|
||||
def preparar_form_new
|
||||
@motoristas = Entrega.motoristas_ativos
|
||||
@veiculos = Entrega.veiculos_unicos
|
||||
@operacoes = Operacao.todas
|
||||
end
|
||||
|
||||
def consolidacao_params
|
||||
params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, vehicle_ids: [])
|
||||
params.require(:consolidacao).permit(:nome, :data_inicio, :data_fim, vehicle_ids: [], operacoes: [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,44 @@ class DashboardController < ApplicationController
|
||||
carregar_dados_dashboard
|
||||
end
|
||||
|
||||
# GET /dashboard/relatorio_financeiro?inicio=&fim=&operacoes[]=
|
||||
# Relatório financeiro do período (PDF) — mesmos números da tela.
|
||||
def relatorio_financeiro
|
||||
skip_authorization
|
||||
return redirect_to(motorista_dashboard_path) if current_user.motorista?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
carregar_dados_dashboard
|
||||
|
||||
op_label = @operacao_filtro.present? ? @operacao_filtro.map { |t| Operacao.label(t) }.join(', ') : 'Todas'
|
||||
|
||||
dados = {
|
||||
custo_total: @fin_custo_total,
|
||||
ticket_medio: @fin_ticket_medio,
|
||||
pago: @fin_pago,
|
||||
pendente: @fin_pendente,
|
||||
por_operacao: @fin_por_operacao,
|
||||
por_motorista: @fin_por_motorista,
|
||||
por_tipo: @fin_por_tipo,
|
||||
pagamentos_feitos: @pagamentos_feitos,
|
||||
pagamentos_pendentes: @pagamentos_pendentes,
|
||||
pag_pago_valor: @pag_pago_valor,
|
||||
pag_pago_qtd: @pag_pago_qtd,
|
||||
pag_pend_valor: @pag_pend_valor,
|
||||
pag_pend_qtd: @pag_pend_qtd
|
||||
}
|
||||
|
||||
pdf = Pdf::RelatorioFinanceiroPeriodoPdf.new(
|
||||
periodo_inicio: @periodo_inicio, periodo_fim: @periodo_fim,
|
||||
operacao_label: op_label, dados: dados
|
||||
)
|
||||
|
||||
send_data pdf.render,
|
||||
filename: "financeiro_#{@periodo_inicio.strftime('%Y%m%d')}_#{@periodo_fim.strftime('%Y%m%d')}.pdf",
|
||||
type: 'application/pdf',
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Faixa de datas vinda do filtro de calendário (params inicio/fim).
|
||||
@@ -33,13 +71,28 @@ class DashboardController < ApplicationController
|
||||
end
|
||||
|
||||
def carregar_dados_dashboard
|
||||
# Entregas do período (tabela read-only) — restritas à conta Gade
|
||||
entregas = Entrega.da_conta_gade.no_periodo(@periodo_inicio, @periodo_fim)
|
||||
# Filtro de operação (multi-seleção) — aplicado a todo o dashboard.
|
||||
@operacoes = Operacao.todas
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@operacao_filtro = Operacao.sanitizar(params[:operacoes])
|
||||
|
||||
# PAGAS pela DATA REAL (checkout) — base de toda a análise financeira por data.
|
||||
pagas = Entrega.da_conta_gade.pagas.no_periodo_checkout(@periodo_inicio, @periodo_fim)
|
||||
pagas = pagas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
|
||||
# PENDENTES (em aberto, nem concluídas nem falhadas) pela data planejada.
|
||||
pendentes = Entrega.da_conta_gade.pendentes.no_periodo(@periodo_inicio, @periodo_fim)
|
||||
pendentes = pendentes.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
|
||||
# FALHADAS (insucesso) pela data planejada — também não têm checkout.
|
||||
falhadas = Entrega.da_conta_gade.falhadas.no_periodo(@periodo_inicio, @periodo_fim)
|
||||
falhadas = falhadas.da_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
|
||||
# Totais gerais
|
||||
@total_entregas = entregas.count
|
||||
@entregas_pagas = entregas.pagas.count
|
||||
@entregas_pendentes = @total_entregas - @entregas_pagas
|
||||
@entregas_pagas = pagas.count
|
||||
@entregas_pendentes = pendentes.count
|
||||
@entregas_falhadas = falhadas.count
|
||||
@total_entregas = @entregas_pagas + @entregas_pendentes + @entregas_falhadas
|
||||
|
||||
# Configurações de preço
|
||||
config = Configuracao.mapa_de_precos
|
||||
@@ -47,13 +100,11 @@ class DashboardController < ApplicationController
|
||||
# Valor estimado total
|
||||
@valor_estimado = (@entregas_pagas * config[:entrega]).round(2)
|
||||
|
||||
# Por motorista (top 10)
|
||||
@motoristas = entregas.pagas
|
||||
.group(:driver)
|
||||
.count
|
||||
.sort_by { |_, v| -v }
|
||||
.first(10)
|
||||
.map do |driver, qtd|
|
||||
# Por motorista (todos)
|
||||
@motoristas = pagas.group(:driver)
|
||||
.count
|
||||
.sort_by { |_, v| -v }
|
||||
.map do |driver, qtd|
|
||||
{
|
||||
nome: driver,
|
||||
entregas: qtd,
|
||||
@@ -62,39 +113,167 @@ class DashboardController < ApplicationController
|
||||
end
|
||||
|
||||
# Por local de entrega (contact_name)
|
||||
@por_operacao = entregas.pagas
|
||||
.group(:contact_name)
|
||||
.count
|
||||
.sort_by { |_, v| -v }
|
||||
.first(6)
|
||||
.to_h
|
||||
@por_operacao = pagas.group(:contact_name)
|
||||
.count
|
||||
.sort_by { |_, v| -v }
|
||||
.first(6)
|
||||
.to_h
|
||||
|
||||
# Evolução diária no período (para Chart.js)
|
||||
@grafico_diario = build_grafico_diario(entregas, config[:entrega])
|
||||
# Evolução diária no período (para Chart.js) — por data real (checkout)
|
||||
@grafico_diario = build_grafico_diario(pagas, config[:entrega])
|
||||
|
||||
# Consolidações criadas no período
|
||||
@consolidacoes_mes = Consolidacao.ativas.where(created_at: @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day)
|
||||
# Consolidações cujo período (data_inicio..data_fim) cruza o período do dashboard
|
||||
# — independe de quando foram criadas (created_at).
|
||||
@consolidacoes_mes = Consolidacao.ativas
|
||||
.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio)
|
||||
@consolidacoes_mes = @consolidacoes_mes.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
@consolidacoes_abertas = @consolidacoes_mes.where(status: :rascunho).count
|
||||
@consolidacoes_fechadas = @consolidacoes_mes.where(status: :finalizada).count
|
||||
|
||||
# Pagamentos: pago vs pendente entre as consolidações FINALIZADAS do período
|
||||
# (medido por motorista — fonte: consolidacao_motoristas).
|
||||
carregar_dados_pagamentos
|
||||
|
||||
# Visão financeira por operação (KPIs + gráficos)
|
||||
carregar_dados_financeiro
|
||||
|
||||
# Histórico estimado mais recente
|
||||
@historico_recente = HistoricoEstimado.order(created_at: :desc).limit(5)
|
||||
end
|
||||
|
||||
def build_grafico_diario(entregas, preco_entrega)
|
||||
# Em períodos muito longos, agrupa o gráfico de forma a não gerar centenas de pontos.
|
||||
def carregar_dados_pagamentos
|
||||
intervalo = @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day
|
||||
|
||||
# PAGOS: filtrados pela DATA DO PAGAMENTO (pago_em) dentro do período do topo
|
||||
# — "quanto foi pago neste período".
|
||||
pagos = ConsolidacaoMotorista.ativos
|
||||
.joins(:consolidacao)
|
||||
.where(consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.where(pago_em: intervalo)
|
||||
if @operacao_filtro.present?
|
||||
pagos = pagos.where(consolidacao_id: Consolidacao.com_operacoes(@operacao_filtro).select(:id))
|
||||
end
|
||||
@pag_pago_qtd = pagos.count
|
||||
@pag_pago_valor = pagos.sum(:valor_total)
|
||||
|
||||
# PENDENTES: consolidações finalizadas cujo PERÍODO cruza o filtro, ainda sem
|
||||
# pagamento (pendente não tem data de pagamento para filtrar).
|
||||
finalizadas_ids = @consolidacoes_mes.where(status: :finalizada).select(:id)
|
||||
pendentes = ConsolidacaoMotorista.ativos.where(consolidacao_id: finalizadas_ids).pendentes
|
||||
@pag_pend_qtd = pendentes.count
|
||||
@pag_pend_valor = pendentes.sum(:valor_total)
|
||||
|
||||
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
|
||||
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :desc)
|
||||
|
||||
# Fluxo de pagamentos por dia (valor pago + acumulado) para o gráfico
|
||||
@grafico_pagamentos_dia = build_grafico_pagamentos(pagos)
|
||||
end
|
||||
|
||||
# Visão financeira (custo) das consolidações finalizadas do período, já com o
|
||||
# filtro de operação aplicado.
|
||||
def carregar_dados_financeiro
|
||||
intervalo = @periodo_inicio.beginning_of_day..@periodo_fim.end_of_day
|
||||
|
||||
base = Consolidacao.ativas.where(status: :finalizada)
|
||||
base = base.com_operacoes(@operacao_filtro) if @operacao_filtro.present?
|
||||
|
||||
# Consolidações relevantes ao período: as que CRUZAM o período OU que tiveram
|
||||
# algum pagamento feito dentro do período. Sem o 2º caso, pagar uma
|
||||
# consolidação de outro mês não mexia nos gráficos (só na tabela de pagamentos).
|
||||
ids_periodo = base.where('data_inicio <= ? AND data_fim >= ?', @periodo_fim, @periodo_inicio).ids
|
||||
ids_pagos = ConsolidacaoMotorista.ativos.where(pago_em: intervalo)
|
||||
.where(consolidacao_id: base.select(:id))
|
||||
.distinct.pluck(:consolidacao_id)
|
||||
|
||||
consols = Consolidacao.where(id: (ids_periodo + ids_pagos).uniq).to_a
|
||||
ids = consols.map(&:id)
|
||||
cms = ConsolidacaoMotorista.ativos.where(consolidacao_id: ids)
|
||||
ces = ConsolidacaoEntrega.where(consolidacao_id: ids)
|
||||
|
||||
# KPIs
|
||||
@fin_custo_total = cms.sum(:valor_total)
|
||||
@fin_qtd_entregas = ces.distinct.count(:tracking_id)
|
||||
@fin_ticket_medio = @fin_qtd_entregas.zero? ? 0 : (@fin_custo_total / @fin_qtd_entregas)
|
||||
@fin_pago = cms.pagos.sum(:valor_total)
|
||||
@fin_pendente = cms.pendentes.sum(:valor_total)
|
||||
|
||||
# Custo por operação — rateia o valor da consolidação igualmente entre suas
|
||||
# operações (mantém o total consistente; exato no caso de 1 operação).
|
||||
por_op = Hash.new(0)
|
||||
consols.each do |c|
|
||||
ops = c.operacoes.presence || ['(sem operação)']
|
||||
fatia = c.valor_total / ops.size
|
||||
ops.each { |o| por_op[o == '(sem operação)' ? o : Operacao.label(o)] += fatia }
|
||||
end
|
||||
@fin_por_operacao = por_op.sort_by { |_, v| -v }
|
||||
|
||||
# Composição por tipo (normal/retirada/bônus/desconto)
|
||||
soma_tipo = ces.group(:tipo).sum(:valor_aplicado)
|
||||
@fin_por_tipo = ConsolidacaoEntrega::TIPO_CORES.filter_map do |tipo, cfg|
|
||||
valor = soma_tipo[tipo].to_f
|
||||
next if valor.zero?
|
||||
{ label: cfg[:label], valor: valor }
|
||||
end
|
||||
|
||||
# Custo por motorista (todos)
|
||||
@fin_por_motorista = cms.group(:motorista_nome).sum(:valor_total)
|
||||
.sort_by { |_, v| -v }
|
||||
end
|
||||
|
||||
# `pagas` já vem filtrado por checkout no período. Agrupa pela DATA REAL
|
||||
# (DATE(checkout)) — DATE() sobre timestamp não faz conversão de fuso.
|
||||
def build_grafico_diario(pagas, preco_entrega)
|
||||
dias = (@periodo_inicio..@periodo_fim).to_a
|
||||
return { labels: [], valores: [], qtds: [] } if dias.size > MAX_DIAS_GRAFICO
|
||||
|
||||
contagem = entregas.pagas
|
||||
.group("DATE(planned_date)")
|
||||
.count
|
||||
.transform_keys(&:to_s)
|
||||
|
||||
labels = dias.map { |d| d.strftime('%d/%m') }
|
||||
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
|
||||
qtds = dias.map { |d| contagem[d.to_s] || 0 }
|
||||
if dias.size <= MAX_DIAS_GRAFICO
|
||||
# Granularidade diária (períodos curtos)
|
||||
contagem = pagas.group("DATE(checkout)").count.transform_keys(&:to_s)
|
||||
labels = dias.map { |d| d.strftime('%d/%m') }
|
||||
valores = dias.map { |d| ((contagem[d.to_s] || 0) * preco_entrega).round(2) }
|
||||
qtds = dias.map { |d| contagem[d.to_s] || 0 }
|
||||
else
|
||||
# Período longo: agrega por mês para o gráfico não ficar vazio nem poluído.
|
||||
contagem = pagas.group("TO_CHAR(checkout, 'YYYY-MM')").count
|
||||
meses = meses_no_periodo
|
||||
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
||||
valores = meses.map { |m| ((contagem[m] || 0) * preco_entrega).round(2) }
|
||||
qtds = meses.map { |m| contagem[m] || 0 }
|
||||
end
|
||||
|
||||
{ labels: labels, valores: valores, qtds: qtds }
|
||||
end
|
||||
|
||||
# Valor pago por dia (pela data do pagamento) + acumulado no período,
|
||||
# para o gráfico de fluxo de pagamentos.
|
||||
def build_grafico_pagamentos(pagos)
|
||||
dias = (@periodo_inicio..@periodo_fim).to_a
|
||||
|
||||
if dias.size <= MAX_DIAS_GRAFICO
|
||||
soma = pagos.group("DATE(pago_em)").sum(:valor_total).transform_keys(&:to_s)
|
||||
labels = dias.map { |d| d.strftime('%d/%m') }
|
||||
chaves = dias.map(&:to_s)
|
||||
else
|
||||
soma = pagos.group("TO_CHAR(pago_em, 'YYYY-MM')").sum(:valor_total)
|
||||
meses = meses_no_periodo
|
||||
labels = meses.map { |m| Date.strptime(m, '%Y-%m').strftime('%m/%Y') }
|
||||
chaves = meses
|
||||
end
|
||||
|
||||
valores = chaves.map { |k| soma[k].to_f.round(2) }
|
||||
acumulado = 0
|
||||
{ labels: labels, valores: valores,
|
||||
acumulado: valores.map { |v| (acumulado += v).round(2) } }
|
||||
end
|
||||
|
||||
# Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
|
||||
def meses_no_periodo
|
||||
meses = []
|
||||
cursor = @periodo_inicio.beginning_of_month
|
||||
while cursor <= @periodo_fim
|
||||
meses << cursor.strftime('%Y-%m')
|
||||
cursor = cursor.next_month
|
||||
end
|
||||
meses
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,9 +8,11 @@ module Motorista
|
||||
nome = current_user.nome
|
||||
|
||||
# Card 1 — Valor ESTIMADO (mês atual): entregas pagas × preco_entrega
|
||||
@estimado_inicio = Date.current.beginning_of_month
|
||||
@estimado_fim = Date.current
|
||||
entregas_mes = Entrega.pagas.da_conta_gade
|
||||
.do_motorista(nome)
|
||||
.no_periodo(Date.current.beginning_of_month, Date.current)
|
||||
.no_periodo(@estimado_inicio, @estimado_fim)
|
||||
.count
|
||||
@valor_estimado = entregas_mes * Configuracao.preco_entrega
|
||||
@entregas_mes = entregas_mes
|
||||
@@ -18,15 +20,26 @@ module Motorista
|
||||
# Card 2 — Valor CONSOLIDADO: soma das consolidações finalizadas
|
||||
@minhas_consolidacoes = Consolidacao.ativas
|
||||
.joins(:consolidacao_motoristas)
|
||||
.where(consolidacao_motoristas: { motorista_nome: nome })
|
||||
.where(consolidacao_motoristas: { motorista_nome: nome, arquivado_em: nil })
|
||||
.recentes
|
||||
.distinct
|
||||
|
||||
@valor_consolidado = ConsolidacaoMotorista
|
||||
@valor_consolidado = ConsolidacaoMotorista.ativos
|
||||
.joins(:consolidacao)
|
||||
.where(motorista_nome: nome,
|
||||
consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.sum(:valor_total)
|
||||
|
||||
# Aviso in-app: pagamentos confirmados nos últimos 7 dias (notificação na
|
||||
# própria página do motorista, além de WhatsApp/email).
|
||||
@pagamentos_recentes = ConsolidacaoMotorista
|
||||
.joins(:consolidacao)
|
||||
.where(motorista_nome: nome,
|
||||
consolidacoes: { status: :finalizada, deleted_at: nil })
|
||||
.where.not(pago_em: nil)
|
||||
.where('pago_em >= ?', 7.days.ago)
|
||||
.order(pago_em: :desc)
|
||||
.includes(:consolidacao)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# app/controllers/motorista/sessoes_controller.rb
|
||||
#
|
||||
# Login do motorista por PIN de 4 dígitos ou por QR Code (token do holerite).
|
||||
# Login do motorista por PIN de 4 dígitos ou por QR Code (token do extrato).
|
||||
# Não usa Devise password — fluxo simplificado para público de baixo nível técnico.
|
||||
#
|
||||
module Motorista
|
||||
@@ -9,34 +9,53 @@ module Motorista
|
||||
layout 'application'
|
||||
|
||||
# GET /motorista/login — tela com teclado de PIN
|
||||
def new; end
|
||||
# Se veio de um QR Code, o motorista já está pré-identificado (só falta o PIN).
|
||||
def new
|
||||
@motorista_pre = User.motorista.ativos.find_by(id: session[:motorista_qr_id])
|
||||
end
|
||||
|
||||
# POST /motorista/login — valida PIN
|
||||
def create
|
||||
pin = params[:pin].to_s.strip
|
||||
user = User.motorista.ativos.find_by(pin_code: pin)
|
||||
|
||||
if pin.length == 4 && user
|
||||
sign_in(user)
|
||||
AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User',
|
||||
entidade_id: user.id, request: request)
|
||||
redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome.split.first}!"
|
||||
else
|
||||
unless pin.length == 4 && user
|
||||
@motorista_pre = User.motorista.ativos.find_by(id: session[:motorista_qr_id])
|
||||
flash.now[:alert] = 'PIN não encontrado. Confira os 4 números e tente de novo.'
|
||||
render :new, status: :unprocessable_entity
|
||||
return render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# Segurança: se o login partiu de um QR Code, o PIN precisa ser do MESMO
|
||||
# motorista do QR — impede usar o QR Code de outra pessoa.
|
||||
qr_id = session[:motorista_qr_id]
|
||||
if qr_id.present? && qr_id != user.id
|
||||
@motorista_pre = User.motorista.ativos.find_by(id: qr_id)
|
||||
flash.now[:alert] = 'Este PIN não corresponde ao QR Code lido. Use o seu PIN.'
|
||||
return render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
session.delete(:motorista_qr_id)
|
||||
sign_in(user)
|
||||
session[:mostrar_splash] = true
|
||||
AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User',
|
||||
entidade_id: user.id,
|
||||
dados_novos: (qr_id.present? ? { via: 'qr_code+pin' } : nil),
|
||||
request: request)
|
||||
redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome.split.first}!"
|
||||
end
|
||||
|
||||
# GET /motorista/acesso/:token — login via QR Code do holerite
|
||||
# GET /motorista/acesso/:token — abre o login a partir do QR Code do extrato.
|
||||
# SEGURANÇA: o QR apenas identifica o motorista; ele ainda precisa digitar o
|
||||
# PIN de 4 dígitos para entrar (antes o QR logava direto, sem PIN).
|
||||
def acesso_qr
|
||||
user = User.motorista.ativos.find_by(login_token: params[:token])
|
||||
|
||||
if user
|
||||
sign_in(user)
|
||||
AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User',
|
||||
entidade_id: user.id, dados_novos: { via: 'qr_code' }, request: request)
|
||||
redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome.split.first}!"
|
||||
session[:motorista_qr_id] = user.id
|
||||
redirect_to motorista_login_path,
|
||||
notice: "Olá, #{user.nome.split.first}! Digite seu PIN de 4 dígitos para entrar."
|
||||
else
|
||||
session.delete(:motorista_qr_id)
|
||||
redirect_to motorista_login_path, alert: 'QR Code inválido ou expirado. Entre com seu PIN.'
|
||||
end
|
||||
end
|
||||
|
||||
152
app/controllers/operacoes_dashboard_controller.rb
Normal file
@@ -0,0 +1,152 @@
|
||||
# app/controllers/operacoes_dashboard_controller.rb
|
||||
#
|
||||
# Dashboard de análise de ENTREGAS por operação (separado do dashboard
|
||||
# financeiro). O operador escolhe uma operação (UBS Norte, EMAD, ...) e vê os
|
||||
# KPIs/insucessos/STS/mapa; também há os modos "global" (todas no período) e
|
||||
# "comparar" (uma operação vs outra). Os números vêm de Analytics::OperacaoMetricas.
|
||||
class OperacoesDashboardController < ApplicationController
|
||||
MODOS = %w[operacao global comparar].freeze
|
||||
|
||||
# Linhas por página da tabela espelho (planilha da operação)
|
||||
ESPELHO_POR_PAGINA = 15
|
||||
|
||||
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
|
||||
|
||||
def index
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@modo = MODOS.include?(params[:modo]) ? params[:modo] : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
@filtros, @chips = cross_filtros
|
||||
@data_filtro = parse_data(params[:f_data]) # clique no gráfico "Entregas por Dia"
|
||||
|
||||
case @modo
|
||||
when 'global'
|
||||
# Global: agrega TODAS as operações dentro da faixa de datas escolhida.
|
||||
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||
when 'comparar'
|
||||
# Comparar: cada operação inteira (sem filtro de data — só vale no Global).
|
||||
@op_a = Operacao.sanitizar([params[:op_a]]).first || @tabelas_validas[0]
|
||||
@op_b = Operacao.sanitizar([params[:op_b]]).first || @tabelas_validas[1]
|
||||
@metricas_a = montar([@op_a]) if @op_a
|
||||
@metricas_b = montar([@op_b]) if @op_b
|
||||
else
|
||||
# Operação única: a operação inteira (a data vem dos próprios dados).
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
|
||||
end
|
||||
end
|
||||
|
||||
# Planilha da operação (tabela espelho): página própria com busca e paginação.
|
||||
# Aceita os mesmos parâmetros do index (operacao/modo global/período/cross-
|
||||
# filters), então o botão no dashboard preserva o contexto atual.
|
||||
def planilha
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
@periodo_inicio, @periodo_fim = periodo_selecionado
|
||||
@modo = params[:modo] == 'global' ? 'global' : 'operacao'
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
@tabelas_validas = Operacao.nomes_validos
|
||||
@filtros, @chips = cross_filtros
|
||||
@data_filtro = parse_data(params[:f_data])
|
||||
|
||||
if @modo == 'global'
|
||||
@metricas = montar(@tabelas_validas, inicio: @periodo_inicio, fim: @periodo_fim, filtros: @filtros, data: @data_filtro)
|
||||
else
|
||||
@operacao = Operacao.sanitizar([params[:operacao]]).first || @tabelas_validas.first
|
||||
@metricas = montar([@operacao], filtros: @filtros, data: @data_filtro) if @operacao
|
||||
end
|
||||
|
||||
montar_tabela_espelho if @metricas
|
||||
end
|
||||
|
||||
# GET /dashboard/operacoes/planilha/baixar?operacao=gade_entregas_...
|
||||
# Baixa a planilha Entregas (.xlsx) da operação já preenchida com o resultado
|
||||
# do rastreio — o modelo que é entregue ao cliente.
|
||||
def baixar_planilha
|
||||
authorize :dashboard, :operacoes?
|
||||
|
||||
tabela = params[:operacao].to_s
|
||||
unless Operacao.valida?(tabela)
|
||||
return redirect_to(operacoes_planilha_path, alert: 'Selecione uma operação válida.')
|
||||
end
|
||||
|
||||
planilha = Analytics::PlanilhaEntregas.new(tabela)
|
||||
if planilha.linhas.empty?
|
||||
return redirect_to(operacoes_planilha_path(operacao: tabela),
|
||||
alert: "A operação #{planilha.label} não tem registros.")
|
||||
end
|
||||
|
||||
send_data Analytics::PlanilhaEntregasXlsx.new(planilha).gerar,
|
||||
filename: "Entregas #{planilha.label} #{Date.current.strftime('%m.%Y')}.xlsx",
|
||||
type: XLSX_MIME,
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Tabela espelho da planilha da operação: busca textual (q) + paginação (pg)
|
||||
# sobre as linhas já cross-filtradas. Ordena pelas mais recentes (checkout;
|
||||
# sem checkout vai para o fim).
|
||||
def montar_tabela_espelho
|
||||
@busca = params[:q].to_s.strip
|
||||
linhas = @metricas.buscar(@busca).sort_by { |r| r['checkout'].to_s }.reverse
|
||||
|
||||
@espelho_total = linhas.size
|
||||
@espelho_paginas = [(@espelho_total / ESPELHO_POR_PAGINA.to_f).ceil, 1].max
|
||||
@espelho_pagina = params[:pg].to_i.clamp(1, @espelho_paginas)
|
||||
@espelho_linhas = linhas[(@espelho_pagina - 1) * ESPELHO_POR_PAGINA, ESPELHO_POR_PAGINA] || []
|
||||
end
|
||||
|
||||
def montar(tabelas, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||
Analytics::OperacaoMetricas.new(tabelas: tabelas, inicio: inicio, fim: fim, filtros: filtros, data: data)
|
||||
end
|
||||
|
||||
# Cross-filter por clique nas tabelas. Cada param vira um filtro de coluna e um
|
||||
# "chip" removível na tela. Retorna [filtros (coluna=>valor), chips (p/ a view)].
|
||||
CROSS = {
|
||||
'f_driver' => { coluna: 'driver', rotulo: 'Motorista' },
|
||||
'f_sts' => { coluna: 'contact_name', rotulo: 'Unidade' },
|
||||
'f_status' => { coluna: 'status_gade', rotulo: 'Status' },
|
||||
'f_obs' => { coluna: 'observation', rotulo: 'Observação' },
|
||||
'f_resultado' => { coluna: 'status', rotulo: 'Resultado' }
|
||||
}.freeze
|
||||
|
||||
# Rótulos amigáveis para o filtro de Resultado (clique no donut "Insucessos %").
|
||||
RESULTADO_LABEL = { 'completed' => 'Completas', 'failed' => 'Falhas' }.freeze
|
||||
|
||||
def cross_filtros
|
||||
filtros = {}
|
||||
chips = []
|
||||
CROSS.each do |param, cfg|
|
||||
valor = params[param].to_s.strip
|
||||
next if valor.empty?
|
||||
filtros[cfg[:coluna]] = valor
|
||||
display = param == 'f_resultado' ? (RESULTADO_LABEL[valor] || valor) : valor
|
||||
chips << { param: param, valor: valor, display: display, rotulo: cfg[:rotulo] }
|
||||
end
|
||||
# Filtro por DIA (clique no gráfico "Entregas por Dia") — tratado à parte (data).
|
||||
if (dia = parse_data(params[:f_data]))
|
||||
chips << { param: 'f_data', valor: params[:f_data].to_s, display: dia.strftime('%d/%m/%Y'), rotulo: 'Dia' }
|
||||
end
|
||||
[filtros, chips]
|
||||
end
|
||||
|
||||
# Faixa de datas do filtro (params inicio/fim). Default: início do mês → hoje.
|
||||
# Espelha DashboardController#periodo_selecionado.
|
||||
def periodo_selecionado
|
||||
inicio = parse_data(params[:inicio]) || Date.current.beginning_of_month
|
||||
fim = parse_data(params[:fim]) || Date.current
|
||||
inicio, fim = fim, inicio if fim < inicio
|
||||
[inicio, fim]
|
||||
end
|
||||
|
||||
def parse_data(str)
|
||||
return nil if str.blank?
|
||||
Date.parse(str)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,7 @@ class Users::SessionsController < Devise::SessionsController
|
||||
protected
|
||||
|
||||
def after_sign_in_path_for(resource)
|
||||
session[:mostrar_splash] = true # dispara a animação de abertura uma vez
|
||||
case resource.role
|
||||
when 'motorista'
|
||||
motorista_dashboard_path
|
||||
@@ -45,6 +46,7 @@ class Users::SessionsController < Devise::SessionsController
|
||||
|
||||
if user&.ativo?
|
||||
sign_in(user)
|
||||
session[:mostrar_splash] = true
|
||||
AuditoriaLog.registrar(user: user, acao: 'login_pin', entidade: 'User', dados_novos: { detalhes: "" }, request: request)
|
||||
redirect_to motorista_dashboard_path, notice: "Bem-vindo, #{user.nome_display}!"
|
||||
else
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# app/helpers/application_helper.rb
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
# Opções [label, valor] para o select de perfil de usuário (form de usuário)
|
||||
def role_options_for_select
|
||||
User::ROLES_LABEL.map { |value, label| [label, value] }
|
||||
end
|
||||
|
||||
# Link de navegação com estado ativo
|
||||
def nav_link_to(label, path, **opts)
|
||||
active = current_page?(path)
|
||||
@@ -10,9 +17,9 @@ module ApplicationHelper
|
||||
link_to label, path, class: css
|
||||
end
|
||||
|
||||
# Formata moeda BR
|
||||
# Formata moeda BR (milhar com ".", decimais com ","): R$ 1.234,56
|
||||
def moeda(valor)
|
||||
"R$ #{"%.2f" % valor.to_f}".gsub('.', ',')
|
||||
number_to_currency(valor.to_f, unit: 'R$ ', separator: ',', delimiter: '.', precision: 2)
|
||||
end
|
||||
|
||||
# Badge de status de consolidação
|
||||
@@ -29,6 +36,20 @@ module ApplicationHelper
|
||||
end
|
||||
end
|
||||
|
||||
# Badge de status de pagamento de uma consolidação (derivado dos motoristas)
|
||||
def badge_pagamento(status)
|
||||
cfg = case status.to_s
|
||||
when 'pago' then { cor: 'bg-green-600 text-white', icone: '💰', label: 'Pago' }
|
||||
when 'parcial' then { cor: 'bg-yellow-500 text-black', icone: '⏳', label: 'Parcial' }
|
||||
when 'pendente' then { cor: 'bg-gray-700 text-gray-300', icone: '🕗', label: 'Pendente' }
|
||||
else { cor: 'bg-gray-800 text-gray-400', icone: '❓', label: status.to_s.humanize }
|
||||
end
|
||||
|
||||
tag.span class: "inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do
|
||||
"#{cfg[:icone]} #{cfg[:label]}"
|
||||
end
|
||||
end
|
||||
|
||||
# Badge de papel do usuário (admin/gerente/operador/motorista)
|
||||
def badge_role(role)
|
||||
cfg = case role.to_s
|
||||
@@ -36,6 +57,7 @@ module ApplicationHelper
|
||||
when 'gerente' then { cor: 'bg-orange-800 text-white' }
|
||||
when 'operador' then { cor: 'bg-gray-700 text-white' }
|
||||
when 'motorista' then { cor: 'bg-gray-900 text-orange-400 border border-orange-500' }
|
||||
when 'externo' then { cor: 'bg-blue-900 text-blue-200 border border-blue-500' }
|
||||
else { cor: 'bg-gray-800 text-gray-400' }
|
||||
end
|
||||
label = User::ROLES_LABEL[role.to_s] || role.to_s.humanize
|
||||
@@ -43,6 +65,51 @@ module ApplicationHelper
|
||||
tag.span label, class: "inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}"
|
||||
end
|
||||
|
||||
# Badge de status ativo/inativo do usuário
|
||||
def badge_status_usuario(ativo)
|
||||
cor = ativo ? 'bg-green-600 text-white' : 'bg-red-600 text-white'
|
||||
label = ativo ? 'Ativo' : 'Inativo'
|
||||
tag.span label, class: "inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold #{cor}"
|
||||
end
|
||||
|
||||
# Horário da última sincronização dos dados de operação.
|
||||
#
|
||||
# O backend atualiza os dados a cada 30 min, das 08h às 18h (horário de
|
||||
# Brasília). Como as tabelas externas de rastreio não têm um carimbo de
|
||||
# sincronização confiável, derivamos o último "slot" concluído a partir dessa
|
||||
# grade fixa. Usa Time.current (fuso do app), NUNCA Time.now — que pega o fuso
|
||||
# do container (UTC) e por isso mostrava o horário adiantado/errado.
|
||||
DADOS_SYNC_INICIO = 8 # abre às 08h
|
||||
DADOS_SYNC_FIM = 18 # fecha às 18h
|
||||
DADOS_SYNC_PASSO = 30 # minutos entre atualizações
|
||||
|
||||
def ultima_atualizacao_dados(agora = Time.current)
|
||||
agora = agora.in_time_zone
|
||||
abre = agora.change(hour: DADOS_SYNC_INICIO, min: 0)
|
||||
fecha = agora.change(hour: DADOS_SYNC_FIM, min: 0)
|
||||
|
||||
if agora < abre
|
||||
# Antes de abrir: a última atualização foi o fechamento do dia anterior.
|
||||
(abre - 1.day).change(hour: DADOS_SYNC_FIM, min: 0)
|
||||
elsif agora >= fecha
|
||||
fecha
|
||||
else
|
||||
# Arredonda para baixo ao múltiplo de 30 min dentro da janela.
|
||||
minuto = (agora.min / DADOS_SYNC_PASSO) * DADOS_SYNC_PASSO
|
||||
agora.change(min: minuto, sec: 0)
|
||||
end
|
||||
end
|
||||
|
||||
# Rótulo amigável: "às 14:30" (hoje) ou "em 05/07 às 18:00" (outro dia).
|
||||
def ultima_atualizacao_label(agora = Time.current)
|
||||
t = ultima_atualizacao_dados(agora)
|
||||
if t.to_date == agora.in_time_zone.to_date
|
||||
"às #{t.strftime('%H:%M')}"
|
||||
else
|
||||
"em #{t.strftime('%d/%m')} às #{t.strftime('%H:%M')}"
|
||||
end
|
||||
end
|
||||
|
||||
# Barra de progresso laranja
|
||||
def progress_bar(percentual, label: nil)
|
||||
pct = percentual.to_i.clamp(0, 100)
|
||||
|
||||
31
app/javascript/controllers/adicionar_motorista_controller.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// app/javascript/controllers/adicionar_motorista_controller.js
|
||||
// Stimulus — modal "Adicionar motorista" no Passo 1. Só abre/fecha o modal; o
|
||||
// formulário em si submete normalmente (Turbo), redirecionando de volta ao wizard.
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "input"]
|
||||
|
||||
abrir() {
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
if (this.hasInputTarget) this.inputTarget.focus()
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// Fecha ao clicar no fundo (fora do card)
|
||||
fundo(event) {
|
||||
if (event.target === this.modalTarget) this.fechar()
|
||||
}
|
||||
|
||||
// Preenche o input com o motorista escolhido na lista de previstos.
|
||||
selecionar(event) {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.value = event.currentTarget.dataset.nome
|
||||
this.inputTarget.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/javascript/controllers/apontamento_controller.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// app/javascript/controllers/apontamento_controller.js
|
||||
// Stimulus controller — Apontamento manual de entrega na consolidação.
|
||||
// Busca a NF na base de rastreio (SimpleRoute), exibe os dados para conferência
|
||||
// e cria o apontamento com o tipo (pilar) escolhido.
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "nf", "erro", "resultados", "confirmar", "tipoCheck"]
|
||||
static values = { buscarUrl: String, apontarUrl: String }
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────
|
||||
abrir() {
|
||||
this.reset()
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
this.nfTarget.focus()
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// Fecha ao clicar no fundo (fora do card)
|
||||
fundo(event) {
|
||||
if (event.target === this.modalTarget) this.fechar()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.nfTarget.value = ""
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.confirmarTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// ── Busca da NF ────────────────────────────────────────────
|
||||
async buscar(event) {
|
||||
event?.preventDefault()
|
||||
const nf = this.nfTarget.value.trim()
|
||||
if (!nf) { this.mostrarErro("Informe o número da nota."); return }
|
||||
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.resultadosTarget.innerHTML = `<p class="text-gray-400 text-sm">Buscando…</p>`
|
||||
this.confirmarTarget.classList.add("hidden")
|
||||
|
||||
try {
|
||||
const url = `${this.buscarUrlValue}?nf=${encodeURIComponent(nf)}`
|
||||
const resp = await fetch(url, { headers: { "Accept": "application/json" } })
|
||||
const data = await resp.json()
|
||||
|
||||
if (!data.ok) {
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.mostrarErro(data.erro || "Nota não encontrada.")
|
||||
return
|
||||
}
|
||||
this.renderResultados(data.entregas)
|
||||
} catch (e) {
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.mostrarErro("Erro ao buscar a nota. Tente novamente.")
|
||||
}
|
||||
}
|
||||
|
||||
renderResultados(entregas) {
|
||||
const cards = entregas.map((e, i) => {
|
||||
const pago = e.pago
|
||||
? `<span class="text-green-400">paga</span>`
|
||||
: `<span class="text-yellow-400">${e.status || "pendente"}</span>`
|
||||
return `
|
||||
<label class="flex gap-3 items-start bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 cursor-pointer hover:border-orange-500">
|
||||
<input type="radio" name="apontamento_entrega" value="${e.tracking_id}" ${i === 0 ? "checked" : ""} class="mt-1 accent-orange-500">
|
||||
<div class="text-sm">
|
||||
<p class="text-white font-bold">NF ${e.nf || "—"} · ${e.motorista || "sem motorista"}</p>
|
||||
<p class="text-gray-400">${e.veiculo || "—"} · ${e.local || "—"}</p>
|
||||
<p class="text-gray-500 text-xs">${e.data || "—"} · ${pago}</p>
|
||||
</div>
|
||||
</label>`
|
||||
}).join("")
|
||||
|
||||
this.resultadosTarget.innerHTML = cards
|
||||
this.confirmarTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
// ── Criar apontamento ──────────────────────────────────────
|
||||
async apontar(event) {
|
||||
event?.preventDefault()
|
||||
const selecionado = this.resultadosTarget.querySelector('input[name="apontamento_entrega"]:checked')
|
||||
if (!selecionado) { this.mostrarErro("Selecione uma entrega."); return }
|
||||
|
||||
const tipos = this.tipoCheckTargets.filter(c => c.checked).map(c => c.value)
|
||||
if (!tipos.length) { this.mostrarErro("Selecione ao menos uma classificação."); return }
|
||||
|
||||
const btn = event.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
const resp = await fetch(this.apontarUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({ tracking_id: selecionado.value, tipos: tipos })
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || data.ok === false) {
|
||||
this.mostrarErro(data.erro || "Não foi possível adicionar o apontamento.")
|
||||
return
|
||||
}
|
||||
// Recarrega para refletir motorista/valores atualizados.
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
this.mostrarErro("Erro ao adicionar o apontamento. Tente novamente.")
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
mostrarErro(msg) {
|
||||
this.erroTarget.textContent = msg
|
||||
this.erroTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
77
app/javascript/controllers/apontamento_manual_controller.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// app/javascript/controllers/apontamento_manual_controller.js
|
||||
// Stimulus — Apontamento 100% MANUAL (sem consultar a base de rastreio).
|
||||
// O admin informa motorista, NF (texto livre), observação/local e os pilares.
|
||||
// Usado quando o motorista foi ao local mas a NF ficou com outro motorista no
|
||||
// rastreio (a busca por NF nunca o encontraria).
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "motorista", "nf", "obs", "erro", "tipoCheck", "valorExtra", "veiculo"]
|
||||
static values = { apontarManualUrl: String }
|
||||
|
||||
abrir() {
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
this.motoristaTarget.focus()
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// Fecha ao clicar no fundo (fora do card)
|
||||
fundo(event) {
|
||||
if (event.target === this.modalTarget) this.fechar()
|
||||
}
|
||||
|
||||
async adicionar(event) {
|
||||
event?.preventDefault()
|
||||
|
||||
const motorista = this.motoristaTarget.value.trim()
|
||||
if (!motorista) { this.mostrarErro("Informe o motorista."); return }
|
||||
|
||||
const tipos = this.tipoCheckTargets.filter(c => c.checked).map(c => c.value)
|
||||
if (!tipos.length) { this.mostrarErro("Selecione ao menos uma classificação."); return }
|
||||
|
||||
// Valor customizado da Extraordinária (opcional)
|
||||
const valor = this.hasValorExtraTarget ? this.valorExtraTarget.value.trim() : ""
|
||||
|
||||
const btn = event.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
const resp = await fetch(this.apontarManualUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
motorista: motorista,
|
||||
nf: this.nfTarget.value.trim(),
|
||||
obs: this.obsTarget.value.trim(),
|
||||
tipos: tipos,
|
||||
valor: valor,
|
||||
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
|
||||
})
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || data.ok === false) {
|
||||
this.mostrarErro(data.erro || "Não foi possível adicionar o apontamento.")
|
||||
return
|
||||
}
|
||||
// Recarrega para refletir o motorista/valores atualizados.
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
this.mostrarErro("Erro ao adicionar o apontamento. Tente novamente.")
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
mostrarErro(msg) {
|
||||
this.erroTarget.textContent = msg
|
||||
this.erroTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
82
app/javascript/controllers/apontamento_termo_controller.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// app/javascript/controllers/apontamento_termo_controller.js
|
||||
// Stimulus — Entrega de termo. Lança um lote de N termos para o motorista da tela.
|
||||
// Entrega sem NF nem código de rastreio: só a quantidade (stepper − N +), a um
|
||||
// preço fixo configurável.
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "quantidade", "erro", "veiculo"]
|
||||
static values = { apontarTermoUrl: String, motorista: String }
|
||||
|
||||
abrir() {
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.quantidadeTarget.value = "1"
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// Fecha ao clicar no fundo (fora do card)
|
||||
fundo(event) {
|
||||
if (event.target === this.modalTarget) this.fechar()
|
||||
}
|
||||
|
||||
// Stepper
|
||||
mais() {
|
||||
this.quantidadeTarget.value = String(this.qtdAtual() + 1)
|
||||
}
|
||||
|
||||
menos() {
|
||||
this.quantidadeTarget.value = String(Math.max(1, this.qtdAtual() - 1))
|
||||
}
|
||||
|
||||
qtdAtual() {
|
||||
const n = parseInt(this.quantidadeTarget.value, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 1
|
||||
}
|
||||
|
||||
async adicionar(event) {
|
||||
event?.preventDefault()
|
||||
|
||||
const quantidade = this.qtdAtual()
|
||||
const motorista = this.motoristaValue.trim()
|
||||
if (!motorista) { this.mostrarErro("Motorista não identificado."); return }
|
||||
|
||||
const btn = event.currentTarget
|
||||
btn.disabled = true
|
||||
try {
|
||||
const resp = await fetch(this.apontarTermoUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
motorista: motorista,
|
||||
quantidade: quantidade,
|
||||
vehicle: this.hasVeiculoTarget ? this.veiculoTarget.value : ""
|
||||
})
|
||||
})
|
||||
const data = await resp.json()
|
||||
if (!resp.ok || data.ok === false) {
|
||||
this.mostrarErro(data.erro || "Não foi possível adicionar as entregas de termo.")
|
||||
return
|
||||
}
|
||||
// Recarrega para refletir o valor atualizado do motorista.
|
||||
window.location.reload()
|
||||
} catch (e) {
|
||||
this.mostrarErro("Erro ao adicionar as entregas de termo. Tente novamente.")
|
||||
} finally {
|
||||
btn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
mostrarErro(msg) {
|
||||
this.erroTarget.textContent = msg
|
||||
this.erroTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
45
app/javascript/controllers/auto_refresh_controller.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Atualiza o painel automaticamente, sem o usuário precisar apertar F5.
|
||||
//
|
||||
// A cada `interval` ms faz um Turbo.visit "replace" na própria URL: o Turbo
|
||||
// re-renderiza o corpo (KPIs, gráficos, mapa) e re-executa os <script> inline,
|
||||
// preservando os filtros — que vivem na query string. Pausa quando a aba está
|
||||
// oculta ou quando o usuário está com um campo em foco, e restaura a posição de
|
||||
// rolagem após cada atualização (para não "pular" para o topo).
|
||||
//
|
||||
// Uso:
|
||||
// <div data-controller="auto-refresh"
|
||||
// data-auto-refresh-interval-value="300000"> ... </div>
|
||||
export default class extends Controller {
|
||||
static values = { interval: { type: Number, default: 300000 } } // 5 min
|
||||
|
||||
connect() {
|
||||
// Restaura a rolagem salva antes da última atualização automática.
|
||||
const y = sessionStorage.getItem("autoRefreshScroll")
|
||||
if (y !== null) {
|
||||
window.scrollTo(0, parseInt(y, 10))
|
||||
sessionStorage.removeItem("autoRefreshScroll")
|
||||
}
|
||||
this.timer = setInterval(() => this.refresh(), this.intervalValue)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
|
||||
refresh() {
|
||||
// Não recarrega em aba oculta (economiza recursos) nem se o usuário estiver
|
||||
// digitando/selecionando algo (evita perder o que está fazendo).
|
||||
if (document.visibilityState !== "visible") return
|
||||
const ativo = document.activeElement
|
||||
if (ativo && ["INPUT", "SELECT", "TEXTAREA"].includes(ativo.tagName)) return
|
||||
|
||||
sessionStorage.setItem("autoRefreshScroll", String(window.scrollY))
|
||||
if (window.Turbo) {
|
||||
window.Turbo.visit(window.location.href, { action: "replace" })
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/javascript/controllers/ferramentas_controller.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// app/javascript/controllers/ferramentas_controller.js
|
||||
// Stimulus — "caixa de ferramentas": um botão que abre um menu suspenso com as
|
||||
// ações de lançamento (registrar entrega manual, entrega de termo). Cada item do
|
||||
// menu dispara o modal correspondente (controllers apontamento-manual /
|
||||
// apontamento-termo, que vivem no mesmo elemento pai).
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["menu"]
|
||||
|
||||
connect() {
|
||||
// Fecha o menu ao clicar fora dele.
|
||||
this._fora = (e) => { if (!this.element.contains(e.target)) this.fechar() }
|
||||
document.addEventListener("click", this._fora)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this._fora)
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
event.stopPropagation()
|
||||
this.menuTarget.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.menuTarget.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
92
app/javascript/controllers/nota_avulsa_controller.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// app/javascript/controllers/nota_avulsa_controller.js
|
||||
// Stimulus controller — "Nota avulsa": busca a NF na base de rastreio e, ao
|
||||
// prosseguir, o formulário cria JÁ uma consolidação avulsa para o motorista.
|
||||
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "nf", "erro", "resultados", "confirmar"]
|
||||
static values = { buscarUrl: String }
|
||||
|
||||
abrir() {
|
||||
this.reset()
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
this.nfTarget.focus()
|
||||
}
|
||||
|
||||
fechar() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
fundo(event) {
|
||||
if (event.target === this.modalTarget) this.fechar()
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.nfTarget.value = ""
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.confirmarTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// ── Busca da NF (Enter ou botão) ───────────────────────────
|
||||
async buscar(event) {
|
||||
event?.preventDefault()
|
||||
const nf = this.nfTarget.value.trim()
|
||||
if (!nf) { this.mostrarErro("Informe o número da nota."); return }
|
||||
|
||||
this.erroTarget.classList.add("hidden")
|
||||
this.resultadosTarget.innerHTML = `<p class="text-gray-400 text-sm">Buscando…</p>`
|
||||
this.confirmarTarget.classList.add("hidden")
|
||||
|
||||
try {
|
||||
const url = `${this.buscarUrlValue}?nf=${encodeURIComponent(nf)}`
|
||||
const resp = await fetch(url, { headers: { "Accept": "application/json" } })
|
||||
const data = await resp.json()
|
||||
|
||||
if (!data.ok) {
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.mostrarErro(data.erro || "Nota não encontrada.")
|
||||
return
|
||||
}
|
||||
this.renderResultados(data.entregas)
|
||||
} catch (e) {
|
||||
this.resultadosTarget.innerHTML = ""
|
||||
this.mostrarErro("Erro ao buscar a nota. Tente novamente.")
|
||||
}
|
||||
}
|
||||
|
||||
renderResultados(entregas) {
|
||||
const cards = entregas.map((e, i) => {
|
||||
const pago = e.pago
|
||||
? `<span class="text-green-400">paga</span>`
|
||||
: `<span class="text-yellow-400">${e.status || "pendente"}</span>`
|
||||
return `
|
||||
<label class="flex gap-3 items-start bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 cursor-pointer hover:border-orange-500">
|
||||
<input type="radio" name="tracking_id" value="${e.tracking_id}" ${i === 0 ? "checked" : ""} required class="mt-1 accent-orange-500">
|
||||
<div class="text-sm">
|
||||
<p class="text-white font-bold">NF ${e.nf || "—"} · ${e.motorista || "sem motorista"}</p>
|
||||
<p class="text-gray-400">${e.veiculo || "—"} · ${e.local || "—"}</p>
|
||||
<p class="text-gray-500 text-xs">${e.data || "—"} · ${pago}</p>
|
||||
</div>
|
||||
</label>`
|
||||
}).join("")
|
||||
|
||||
this.resultadosTarget.innerHTML = cards
|
||||
this.confirmarTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
// Garante que há uma entrega selecionada antes de enviar o formulário.
|
||||
validar(event) {
|
||||
const selecionado = this.resultadosTarget.querySelector('input[name="tracking_id"]:checked')
|
||||
if (!selecionado) {
|
||||
event.preventDefault()
|
||||
this.mostrarErro("Busque e selecione uma nota antes de criar.")
|
||||
}
|
||||
}
|
||||
|
||||
mostrarErro(msg) {
|
||||
this.erroTarget.textContent = msg
|
||||
this.erroTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,22 @@ import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"linha", "checkbox", "toggles", "barra", "percentual",
|
||||
"contadorClassificadas", "contadorTotal",
|
||||
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "valorTotal"
|
||||
"linha", "checkbox", "selecionarTodos", "busca", "buscaVazia",
|
||||
"resumo", "listaCol", "btnResumoLabel",
|
||||
"toggles", "barra", "barraManuais", "percentual", "chipVeiculo",
|
||||
"consolidarBtn", "consolidarHint",
|
||||
"contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo",
|
||||
"qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria",
|
||||
"qtdTermo", "qtdManuais", "valorTotal"
|
||||
]
|
||||
|
||||
static values = {
|
||||
url: String, // POST classificar
|
||||
massaUrl: String, // POST classificar_em_massa
|
||||
motorista: String,
|
||||
total: Number
|
||||
url: String, // POST classificar
|
||||
massaUrl: String, // POST classificar_em_massa
|
||||
motorista: String,
|
||||
total: Number,
|
||||
veiculos: Array, // veículo(s) selecionado(s) — filtro multi-seleção
|
||||
precoExtraordinaria: Number // preço padrão sugerido no prompt
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -33,14 +39,25 @@ export default class extends Controller {
|
||||
const tracking = btn.dataset.tracking
|
||||
const tipo = btn.dataset.tipo
|
||||
|
||||
// Pilar extraordinária: ao ATIVAR (botão ainda sem ring-2), pergunta o valor.
|
||||
const ativando = !btn.classList.contains("ring-2")
|
||||
let valor = null
|
||||
if (tipo === "extraordinaria" && ativando) {
|
||||
valor = this.pedirValor()
|
||||
if (valor === null) return // admin cancelou
|
||||
}
|
||||
|
||||
btn.disabled = true
|
||||
try {
|
||||
const resp = await this.post(this.urlValue, {
|
||||
tracking_id: tracking,
|
||||
tipo: tipo,
|
||||
motorista: this.motoristaValue
|
||||
motorista: this.motoristaValue,
|
||||
vehicle: this.veiculosValue,
|
||||
valor: valor
|
||||
})
|
||||
this.marcarToggleAtivo(tracking, tipo)
|
||||
// resp.ativo indica se o pilar ficou ligado ou desligado (toggle)
|
||||
this.setToggle(tracking, tipo, resp.ativo)
|
||||
this.atualizarResumo(resp)
|
||||
} catch (e) {
|
||||
alert("Erro ao classificar entrega. Tente novamente.")
|
||||
@@ -49,21 +66,49 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
// ── Oculta/mostra o resumo lateral (lista ocupa a largura toda) ──
|
||||
toggleResumo() {
|
||||
const escondido = this.resumoTarget.classList.toggle("hidden")
|
||||
if (this.hasListaColTarget) {
|
||||
this.listaColTarget.classList.toggle("lg:col-span-4", escondido)
|
||||
this.listaColTarget.classList.toggle("lg:col-span-3", !escondido)
|
||||
}
|
||||
if (this.hasBtnResumoLabelTarget)
|
||||
this.btnResumoLabelTarget.textContent = escondido ? "Mostrar resumo" : "Ocultar resumo"
|
||||
}
|
||||
|
||||
// ── Marcar selecionados como X ─────────────────────────────
|
||||
// ── Busca na listagem de notas (filtra os cards por NF/local/endereço/veículo) ──
|
||||
filtrarBusca(event) {
|
||||
const q = (event.currentTarget.value || "").trim().toLowerCase()
|
||||
let visiveis = 0
|
||||
this.linhaTargets.forEach(l => {
|
||||
const ok = q === "" || (l.dataset.busca || "").includes(q)
|
||||
l.classList.toggle("hidden", !ok)
|
||||
if (ok) visiveis++
|
||||
})
|
||||
if (this.hasBuscaVaziaTarget)
|
||||
this.buscaVaziaTarget.classList.toggle("hidden", q === "" || visiveis > 0)
|
||||
}
|
||||
|
||||
// ── Selecionar / desmarcar todos os checkboxes (só os visíveis na busca) ──
|
||||
selecionarTodos(event) {
|
||||
const marcar = event.currentTarget.checked
|
||||
this.checkboxTargets.forEach(c => {
|
||||
const linha = c.closest("[data-busca]")
|
||||
if (linha && linha.classList.contains("hidden")) return
|
||||
c.checked = marcar
|
||||
})
|
||||
}
|
||||
|
||||
// O pilar está ativo (ring-2) na entrega indicada?
|
||||
pilarAtivo(tracking, tipo) {
|
||||
const grupo = this.togglesTargets.find(t => t.dataset.tracking === tracking)
|
||||
if (!grupo) return false
|
||||
const btn = grupo.querySelector(`.toggle-btn[data-tipo="${tipo}"]`)
|
||||
return !!btn && btn.classList.contains("ring-2")
|
||||
}
|
||||
|
||||
// ── Aplicar/remover o pilar nos selecionados (toggle em massa) ──
|
||||
async marcarSelecionados(event) {
|
||||
const tipo = event.currentTarget.dataset.tipo
|
||||
const ids = this.checkboxTargets.filter(c => c.checked).map(c => c.value)
|
||||
@@ -73,13 +118,27 @@ export default class extends Controller {
|
||||
return
|
||||
}
|
||||
|
||||
// Toggle em massa: se TODAS as selecionadas já têm o pilar, remove de todas;
|
||||
// caso contrário, aplica em todas.
|
||||
const remover = ids.every(id => this.pilarAtivo(id, tipo))
|
||||
|
||||
let valor = null
|
||||
if (tipo === "extraordinaria" && !remover) {
|
||||
valor = this.pedirValor()
|
||||
if (valor === null) return
|
||||
}
|
||||
|
||||
const resp = await this.post(this.massaUrlValue, {
|
||||
tipo: tipo,
|
||||
motorista: this.motoristaValue,
|
||||
tracking_ids: ids
|
||||
vehicle: this.veiculosValue,
|
||||
tracking_ids: ids,
|
||||
acao: remover ? "remover" : "adicionar",
|
||||
valor: valor
|
||||
})
|
||||
ids.forEach(id => this.marcarToggleAtivo(id, tipo))
|
||||
ids.forEach(id => this.setToggle(id, tipo, !remover))
|
||||
this.checkboxTargets.forEach(c => c.checked = false)
|
||||
if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false
|
||||
this.atualizarResumo(resp)
|
||||
}
|
||||
|
||||
@@ -99,18 +158,23 @@ export default class extends Controller {
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
marcarToggleAtivo(tracking, tipo) {
|
||||
// Liga/desliga UM pilar (os demais pilares da entrega ficam como estão)
|
||||
setToggle(tracking, tipo, ativo) {
|
||||
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 btn = grupo.querySelector(`.toggle-btn[data-tipo="${tipo}"]`)
|
||||
if (btn) {
|
||||
btn.classList.toggle("ring-2", ativo)
|
||||
btn.classList.toggle("ring-green-400", ativo)
|
||||
btn.classList.toggle("scale-105", ativo)
|
||||
btn.classList.toggle("opacity-50", !ativo)
|
||||
}
|
||||
|
||||
// a linha está "classificada" se tiver qualquer pilar ligado
|
||||
const algumAtivo = grupo.querySelectorAll(".toggle-btn.ring-2").length > 0
|
||||
const linha = this.linhaTargets.find(l => l.dataset.tracking === tracking)
|
||||
if (linha) linha.dataset.classificada = "1"
|
||||
if (linha) linha.dataset.classificada = algumAtivo ? "1" : "0"
|
||||
}
|
||||
|
||||
atualizarResumo(dados) {
|
||||
@@ -119,27 +183,121 @@ export default class extends Controller {
|
||||
this.qtdRetiradaTarget.textContent = porTipo["retirada"] || 0
|
||||
this.qtdBonusTarget.textContent = porTipo["bonus"] || 0
|
||||
this.qtdDescontoTarget.textContent = porTipo["desconto"] || 0
|
||||
if (this.hasQtdExtraordinariaTarget)
|
||||
this.qtdExtraordinariaTarget.textContent = porTipo["extraordinaria"] || 0
|
||||
if (this.hasQtdTermoTarget)
|
||||
this.qtdTermoTarget.textContent = porTipo["termo"] || 0
|
||||
|
||||
const valor = dados.valor_total || 0
|
||||
this.valorTotalTarget.textContent =
|
||||
"R$ " + valor.toFixed(2).replace(".", ",")
|
||||
"R$ " + valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const classificadas = dados.classificadas || 0
|
||||
// Lançamentos manuais do motorista (apontamentos + termo): feedback visual.
|
||||
// Ficam fora do denominador de elegíveis (o gate de finalização não muda).
|
||||
const manuais = dados.manuais || 0
|
||||
if (this.hasQtdManuaisTarget) this.qtdManuaisTarget.textContent = manuais
|
||||
if (this.hasContadorManuaisTarget) this.contadorManuaisTarget.textContent = manuais
|
||||
if (this.hasManuaisInfoTarget) this.manuaisInfoTarget.classList.toggle("hidden", manuais <= 0)
|
||||
|
||||
// Barra/contador refletem o ESCOPO atual (veículo selecionado, se houver).
|
||||
const classificadas = (dados.classificadas_escopo ?? 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")
|
||||
// Barra com 2 segmentos, proporcionais ao total geral (elegíveis + manuais):
|
||||
// laranja/verde = elegíveis classificadas · azul = lançamentos manuais.
|
||||
const denom = total + manuais
|
||||
this.barraTarget.style.width = denom > 0 ? `${(classificadas / denom) * 100}%` : "0%"
|
||||
if (this.hasBarraManuaisTarget)
|
||||
this.barraManuaisTarget.style.width = denom > 0 ? `${(manuais / denom) * 100}%` : "0%"
|
||||
|
||||
// Cor do segmento elegível: verde quando todas as elegíveis estão classificadas.
|
||||
this.barraTarget.classList.toggle("bg-green-500", pct >= 100)
|
||||
this.barraTarget.classList.toggle("bg-orange-500", pct < 100)
|
||||
|
||||
// Botão "Consolidar este veículo" (só existe quando UM veículo está
|
||||
// selecionado e ainda não fechado): habilita quando o escopo chega a 100%.
|
||||
if (this.hasConsolidarBtnTarget) {
|
||||
const pronto = pct >= 100
|
||||
this.consolidarBtnTarget.disabled = !pronto
|
||||
this.consolidarBtnTarget.classList.toggle("bg-green-600", pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("hover:bg-green-500", pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("text-white", pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("cursor-pointer", pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("bg-[#2a2a2a]", !pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("text-gray-600", !pronto)
|
||||
this.consolidarBtnTarget.classList.toggle("cursor-not-allowed", !pronto)
|
||||
if (this.hasConsolidarHintTarget)
|
||||
this.consolidarHintTarget.textContent =
|
||||
pronto ? "Pronto para consolidar." : "Classifique 100% deste veículo para consolidar."
|
||||
}
|
||||
|
||||
if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status)
|
||||
}
|
||||
|
||||
// Atualiza os chips "fechar por veículo" (contador + estado fechado) a partir
|
||||
// do status devolvido pelo servidor.
|
||||
atualizarChips(status) {
|
||||
if (!this.hasChipVeiculoTarget) return
|
||||
const porVeiculo = {}
|
||||
status.forEach(v => { porVeiculo[v.vehicle] = v })
|
||||
|
||||
const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"]
|
||||
const VERDE_FECHADO = ["border-green-500", "text-green-400", "bg-green-500/10"]
|
||||
const VERDE_PRONTO = ["border-green-600/50", "text-green-400"]
|
||||
const ARQUIVADO = ["border-dashed", "border-[#3a3a3a]", "text-gray-500"]
|
||||
const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"]
|
||||
const TODAS = [...LARANJA, ...VERDE_FECHADO, ...VERDE_PRONTO, ...ARQUIVADO, ...NEUTRO]
|
||||
|
||||
this.chipVeiculoTargets.forEach(chip => {
|
||||
const dados = porVeiculo[chip.dataset.vehicle]
|
||||
if (!dados) return
|
||||
const feito = chip.querySelector("[data-chip-feito]")
|
||||
const total = chip.querySelector("[data-chip-total]")
|
||||
const check = chip.querySelector("[data-chip-check]")
|
||||
const lock = chip.querySelector("[data-chip-lock]")
|
||||
if (feito) feito.textContent = dados.classificadas
|
||||
if (total) total.textContent = dados.eligible
|
||||
if (check) check.textContent = dados.completo ? "✓" : ""
|
||||
if (lock) lock.classList.toggle("hidden", !dados.fechado)
|
||||
|
||||
// Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
|
||||
// pronto(verde) > arquivado(cinza) > neutro.
|
||||
chip.classList.remove(...TODAS)
|
||||
if (this.veiculosValue.includes(chip.dataset.vehicle)) {
|
||||
chip.classList.add(...LARANJA)
|
||||
} else if (dados.fechado) {
|
||||
chip.classList.add(...VERDE_FECHADO)
|
||||
} else if (dados.completo) {
|
||||
chip.classList.add(...VERDE_PRONTO)
|
||||
} else if (dados.arquivado) {
|
||||
chip.classList.add(...ARQUIVADO)
|
||||
} else {
|
||||
chip.classList.add(...NEUTRO)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
labelTipo(tipo) {
|
||||
return { entrega_normal: "Normal", retirada: "Retirada",
|
||||
bonus: "Bônus", desconto: "Desconto" }[tipo] || tipo
|
||||
bonus: "Bônus", desconto: "Desconto",
|
||||
extraordinaria: "Extraordinária" }[tipo] || tipo
|
||||
}
|
||||
|
||||
// Pergunta o valor da entrega extraordinária (pré-preenchido com o padrão).
|
||||
// Retorna o número (>= 0) ou null se o admin cancelar.
|
||||
pedirValor() {
|
||||
const padrao = (this.precoExtraordinariaValue || 0).toFixed(2).replace(".", ",")
|
||||
const entrada = prompt("Valor desta entrega extraordinária (R$):", padrao)
|
||||
if (entrada === null) return null
|
||||
const num = parseFloat(entrada.replace(",", "."))
|
||||
if (isNaN(num) || num < 0) {
|
||||
alert("Valor inválido. Informe um número maior ou igual a zero.")
|
||||
return null
|
||||
}
|
||||
return num
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,15 @@ class ConsolidacaoMailer < ApplicationMailer
|
||||
mail to: user.email,
|
||||
subject: "💰 Seu pagamento foi fechado — #{consolidacao.nome}"
|
||||
end
|
||||
|
||||
def pagamento_efetuado(user, consolidacao, consolidacao_motorista)
|
||||
@user = user
|
||||
@consolidacao = consolidacao
|
||||
@valor = consolidacao_motorista.valor_total
|
||||
@forma = consolidacao_motorista.forma_pagamento
|
||||
@link_painel = "https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
|
||||
mail to: user.email,
|
||||
subject: "✅ Seu pagamento foi efetuado — #{consolidacao.nome}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,8 @@ class Configuracao < ApplicationRecord
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
preco_extraordinaria
|
||||
preco_termo
|
||||
notificacao_whatsapp
|
||||
notificacao_email
|
||||
empresa_nome
|
||||
@@ -19,6 +21,8 @@ class Configuracao < ApplicationRecord
|
||||
preco_retirada
|
||||
preco_bonus
|
||||
preco_desconto
|
||||
preco_extraordinaria
|
||||
preco_termo
|
||||
].freeze
|
||||
|
||||
validates :chave, presence: true, inclusion: { in: CHAVES }, uniqueness: true
|
||||
@@ -46,13 +50,23 @@ class Configuracao < ApplicationRecord
|
||||
valor('preco_desconto').to_f
|
||||
end
|
||||
|
||||
def self.preco_extraordinaria
|
||||
valor('preco_extraordinaria').to_f
|
||||
end
|
||||
|
||||
def self.preco_termo
|
||||
valor('preco_termo').to_f
|
||||
end
|
||||
|
||||
# Mapa usado pelo DashboardController (Fase 3)
|
||||
def self.mapa_de_precos
|
||||
{
|
||||
entrega: preco_entrega,
|
||||
retirada: preco_retirada,
|
||||
bonus: preco_bonus,
|
||||
desconto: preco_desconto
|
||||
entrega: preco_entrega,
|
||||
retirada: preco_retirada,
|
||||
bonus: preco_bonus,
|
||||
desconto: preco_desconto,
|
||||
extraordinaria: preco_extraordinaria,
|
||||
termo: preco_termo
|
||||
}
|
||||
end
|
||||
|
||||
@@ -61,6 +75,8 @@ class Configuracao < ApplicationRecord
|
||||
'preco_retirada' => 'Retirada',
|
||||
'preco_bonus' => 'Bônus',
|
||||
'preco_desconto' => 'Desconto',
|
||||
'preco_extraordinaria' => 'Entrega Extraordinária',
|
||||
'preco_termo' => 'Entrega de Termo',
|
||||
'notificacao_whatsapp' => 'Notificação WhatsApp',
|
||||
'notificacao_email' => 'Notificação E-mail',
|
||||
'empresa_nome' => 'Nome da Empresa'
|
||||
@@ -71,6 +87,8 @@ class Configuracao < ApplicationRecord
|
||||
'preco_retirada' => '📦',
|
||||
'preco_bonus' => '⭐',
|
||||
'preco_desconto' => '⚠️',
|
||||
'preco_extraordinaria' => '✨',
|
||||
'preco_termo' => '📄',
|
||||
'notificacao_whatsapp' => '💬',
|
||||
'notificacao_email' => '✉️',
|
||||
'empresa_nome' => '🏢'
|
||||
@@ -90,7 +108,11 @@ class Configuracao < ApplicationRecord
|
||||
end
|
||||
|
||||
def valor_formatado
|
||||
return "R$ #{format('%.2f', valor.to_f).gsub('.', ',')}" if moeda?
|
||||
if moeda?
|
||||
inteiro, decimais = format('%.2f', valor.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
return "R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
valor
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,11 @@ class Consolidacao < ApplicationRecord
|
||||
|
||||
has_many :consolidacao_motoristas, dependent: :destroy
|
||||
has_many :consolidacao_entregas, dependent: :destroy
|
||||
has_many :consolidacao_veiculos, dependent: :destroy
|
||||
|
||||
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Mesmo
|
||||
# sentinel do controller e de ConsolidacaoVeiculo.
|
||||
SEM_VEICULO = ConsolidacaoVeiculo::SEM_VEICULO
|
||||
|
||||
# ── Enums ───────────────────────────────────────────────────
|
||||
enum status: { rascunho: 0, finalizada: 1, arquivada: 2 }
|
||||
@@ -30,35 +35,115 @@ class Consolidacao < ApplicationRecord
|
||||
scope :recentes, -> { order(created_at: :desc) }
|
||||
scope :no_periodo, ->(i, f) { where('data_inicio >= ? AND data_fim <= ?', i, f) }
|
||||
|
||||
# ── Filtro por status de pagamento (derivado dos motoristas) ─
|
||||
# pago_em NÃO-nulo conta como pago; COUNT(pago_em) ignora os NULL.
|
||||
scope :pagamento_pago, -> {
|
||||
where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id)
|
||||
.having('COUNT(*) > 0 AND COUNT(*) = COUNT(pago_em)')
|
||||
.select(:consolidacao_id))
|
||||
}
|
||||
scope :pagamento_pendente, -> {
|
||||
where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id)
|
||||
.having('COUNT(pago_em) = 0')
|
||||
.select(:consolidacao_id))
|
||||
}
|
||||
scope :pagamento_parcial, -> {
|
||||
where(id: ConsolidacaoMotorista.ativos.group(:consolidacao_id)
|
||||
.having('COUNT(pago_em) > 0 AND COUNT(pago_em) < COUNT(*)')
|
||||
.select(:consolidacao_id))
|
||||
}
|
||||
|
||||
# Consolidações que contêm QUALQUER das operações informadas (jsonb).
|
||||
# SEGURANÇA: os nomes passam por whitelist (Operacao.sanitizar) e por quote.
|
||||
scope :com_operacoes, ->(ops) {
|
||||
validas = Operacao.sanitizar(ops)
|
||||
next all if validas.empty?
|
||||
|
||||
lista = validas.map { |o| connection.quote(o) }.join(',')
|
||||
where("EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(operacoes, '[]'::jsonb)) e WHERE e IN (#{lista}))")
|
||||
}
|
||||
|
||||
# ── Métodos ─────────────────────────────────────────────────
|
||||
|
||||
# Arquivar é um soft-delete administrativo: deve SEMPRE funcionar, mesmo numa
|
||||
# consolidação zerada ou em rascunho com dados incompletos. Por isso usamos
|
||||
# update_columns — pula validações (periodo_valido, presença) e o callback
|
||||
# recalcular_valor_total, que não fazem sentido ao apenas mover para o arquivo.
|
||||
def arquivar!(user)
|
||||
update!(
|
||||
update_columns(
|
||||
deleted_at: Time.current,
|
||||
status: :arquivada,
|
||||
status: self.class.statuses[:arquivada],
|
||||
arquivado_por: user&.id,
|
||||
arquivado_em: Time.current
|
||||
arquivado_em: Time.current,
|
||||
updated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Restaura uma consolidação arquivada. Como o status original não é guardado,
|
||||
# inferimos: se já tinha sido finalizada (finalizado_em presente) volta para
|
||||
# :finalizada, senão para :rascunho. Limpa os campos de arquivamento.
|
||||
# Também via update_columns para não depender da validade do conteúdo.
|
||||
def reativar!(_user)
|
||||
update_columns(
|
||||
deleted_at: nil,
|
||||
status: self.class.statuses[finalizado_em? ? :finalizada : :rascunho],
|
||||
arquivado_por: nil,
|
||||
arquivado_em: nil,
|
||||
updated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# ── Pagamento (status derivado dos motoristas da consolidação) ──
|
||||
# :pendente (nenhum pago) · :pago (todos pagos) · :parcial (alguns).
|
||||
def status_pagamento
|
||||
total = consolidacao_motoristas.ativos.count
|
||||
return :pendente if total.zero?
|
||||
|
||||
pagos = consolidacao_motoristas.ativos.pagos.count
|
||||
return :pendente if pagos.zero?
|
||||
|
||||
pagos >= total ? :pago : :parcial
|
||||
end
|
||||
|
||||
def valor_pago
|
||||
consolidacao_motoristas.ativos.pagos.sum(:valor_total)
|
||||
end
|
||||
|
||||
def valor_pendente
|
||||
consolidacao_motoristas.ativos.pendentes.sum(:valor_total)
|
||||
end
|
||||
|
||||
def finalizar!(user)
|
||||
return false unless todas_entregas_classificadas?
|
||||
|
||||
update!(
|
||||
status: :finalizada,
|
||||
finalizado_por: user.id,
|
||||
finalizado_em: Time.current
|
||||
)
|
||||
transaction do
|
||||
# Finalizar a consolidação fecha (consolida) os veículos que ainda estavam
|
||||
# abertos — mantém o estado por veículo coerente com o todo finalizado.
|
||||
consolidacao_veiculos.ativos.abertos.update_all(
|
||||
fechado_em: Time.current, fechado_por: user.id, updated_at: Time.current
|
||||
)
|
||||
update!(
|
||||
status: :finalizada,
|
||||
finalizado_por: user.id,
|
||||
finalizado_em: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Só está completa quando CADA motorista tem todas as suas entregas elegíveis
|
||||
# classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas
|
||||
# pendentes quando outro tinha entregas a mais.)
|
||||
# Só está completa quando CADA motorista ativo está completo (nada por
|
||||
# classificar, descontados os veículos arquivados). Fechar veículo é um passo
|
||||
# independente; ao finalizar, os veículos abertos são consolidados juntos.
|
||||
def todas_entregas_classificadas?
|
||||
consolidacao_motoristas.all? do |cm|
|
||||
total = entregas_elegiveis_count(cm.motorista_nome)
|
||||
total.zero? || classificadas_count(cm.motorista_nome) >= total
|
||||
end
|
||||
consolidacao_motoristas.ativos.all? { |cm| motorista_completo?(cm.motorista_nome) }
|
||||
end
|
||||
|
||||
# Completude de UM motorista: não sobra entrega elegível por classificar. Os
|
||||
# veículos ARQUIVADOS saem da conta (suas entregas deixam de ser exigidas),
|
||||
# permitindo finalizar deixando um carro problemático de fora. Fechar um veículo
|
||||
# é um passo independente (lock) — a finalização em si fecha os que restarem.
|
||||
def motorista_completo?(motorista)
|
||||
total = entregas_elegiveis_count(motorista) - eligible_veiculos_arquivados(motorista)
|
||||
total.zero? || classificadas_count(motorista) >= total
|
||||
end
|
||||
|
||||
def percentual_classificado
|
||||
@@ -67,25 +152,252 @@ class Consolidacao < ApplicationRecord
|
||||
end
|
||||
|
||||
def total_entregas_estimadas
|
||||
@total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm|
|
||||
@total_entregas_estimadas ||= consolidacao_motoristas.ativos.sum do |cm|
|
||||
entregas_elegiveis_count(cm.motorista_nome)
|
||||
end
|
||||
end
|
||||
|
||||
# Entregas pagas elegíveis para um motorista no período, respeitando o filtro
|
||||
# de veículos da consolidação (quando houver). Fonte única usada pelo wizard,
|
||||
# pela revisão e pelo gate de finalização.
|
||||
# Entregas elegíveis para um motorista no período (atendidas: sucesso +
|
||||
# insucesso, pois o motorista foi ao local), respeitando o filtro de veículos
|
||||
# da consolidação (quando houver). Fonte única usada pelo wizard, pela revisão
|
||||
# e pelo gate de finalização.
|
||||
def entregas_elegiveis_count(motorista)
|
||||
Entrega.contar_pagas(
|
||||
Entrega.contar_atendidas(
|
||||
inicio: data_inicio,
|
||||
fim: data_fim,
|
||||
motorista: motorista,
|
||||
vehicle: vehicle_ids.presence
|
||||
vehicle: vehicle_ids.presence,
|
||||
operacoes: operacoes.presence
|
||||
)
|
||||
end
|
||||
|
||||
# Elegíveis de UM veículo específico do motorista (respeita filtros da
|
||||
# consolidação). O bucket "(sem veículo)" casa as entregas com vehicle NULL/vazio.
|
||||
def entregas_elegiveis_count_veiculo(motorista, vehicle)
|
||||
rel = Entrega.atendidas.da_conta_gade
|
||||
.no_periodo_checkout(data_inicio, data_fim)
|
||||
.do_motorista(motorista)
|
||||
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||||
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||||
rel = vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
|
||||
rel.count
|
||||
end
|
||||
|
||||
# Total de elegíveis dos veículos ARQUIVADOS do motorista — descontado do gate
|
||||
# para que arquivar um carro problemático libere a finalização. Só toca a base
|
||||
# externa quando existe algum veículo arquivado (mantém os testes isolados).
|
||||
def eligible_veiculos_arquivados(motorista)
|
||||
arquivados = consolidacao_veiculos.arquivados.where(motorista_nome: motorista)
|
||||
return 0 if arquivados.empty?
|
||||
|
||||
arquivados.sum { |cv| entregas_elegiveis_count_veiculo(motorista, cv.vehicle) }
|
||||
end
|
||||
|
||||
# Rótulos amigáveis das operações vinculadas (para exibição).
|
||||
def operacoes_label
|
||||
Array(operacoes).map { |t| Operacao.label(t) }
|
||||
end
|
||||
|
||||
# Entrega conta como classificada se tiver ≥1 pilar (distinct por tracking_id,
|
||||
# já que agora pode haver vários pilares por entrega).
|
||||
# Apontamentos manuais (manual: true) NÃO entram aqui: eles vivem fora do
|
||||
# conjunto elegível do período, então contá-los inflaria o progresso para
|
||||
# além de 100% (e quebraria o gate classificadas >= elegíveis).
|
||||
def classificadas_count(motorista)
|
||||
consolidacao_entregas.where(motorista_nome: motorista).count
|
||||
consolidacao_entregas.where(motorista_nome: motorista, manual: false)
|
||||
.distinct.count(:tracking_id)
|
||||
end
|
||||
|
||||
# Status por veículo do motorista: array de { vehicle, eligible, classificadas,
|
||||
# completo, fechado, arquivado }. `completo` = 100% classificado (pode consolidar);
|
||||
# `fechado` = veículo consolidado (🔒); `arquivado` = fora do gate. Une os veículos
|
||||
# com entregas elegíveis (rastreio) com os que só têm linha em consolidacao_veiculos
|
||||
# (arquivados / buckets manuais). Fonte única do filtro de carros e do wizard.
|
||||
def veiculos_do_motorista(motorista)
|
||||
return [] unless motorista.present?
|
||||
|
||||
rel = Entrega.atendidas.da_conta_gade
|
||||
.no_periodo_checkout(data_inicio, data_fim)
|
||||
.do_motorista(motorista)
|
||||
rel = rel.da_veiculo(vehicle_ids) if vehicle_ids.present?
|
||||
rel = rel.da_operacoes(operacoes) if operacoes.present?
|
||||
|
||||
por_veiculo = rel.pluck(:tracking_id, :vehicle).group_by { |(_id, v)| v.presence || SEM_VEICULO }
|
||||
classificadas = consolidacao_entregas.where(motorista_nome: motorista, manual: false)
|
||||
.distinct.pluck(:tracking_id).to_set
|
||||
estados = consolidacao_veiculos.where(motorista_nome: motorista).index_by(&:vehicle)
|
||||
|
||||
(por_veiculo.keys + estados.keys).uniq.map do |veiculo|
|
||||
ids = (por_veiculo[veiculo] || []).map(&:first)
|
||||
feito = ids.count { |id| classificadas.include?(id) }
|
||||
cv = estados[veiculo]
|
||||
{ vehicle: veiculo, eligible: ids.size, classificadas: feito,
|
||||
completo: ids.size.positive? && feito >= ids.size,
|
||||
fechado: cv&.fechado? || false,
|
||||
arquivado: cv&.arquivado? || false }
|
||||
end.sort_by { |v| v[:vehicle] }
|
||||
end
|
||||
|
||||
# Arquiva os veículos PENDENTES (com entregas elegíveis mas não 100% classificadas)
|
||||
# de todos os motoristas ativos, deixando-os de fora da finalização. Usado pelo
|
||||
# atalho "arquivar pendentes e finalizar" — os prontos/consolidados seguem no todo.
|
||||
# Retorna a lista "Motorista · Veículo" arquivada.
|
||||
def arquivar_veiculos_pendentes!(user)
|
||||
arquivados = []
|
||||
consolidacao_motoristas.ativos.each do |cm|
|
||||
veiculos_do_motorista(cm.motorista_nome).each do |v|
|
||||
next if v[:fechado] || v[:arquivado] || v[:completo] || v[:eligible].zero?
|
||||
|
||||
cv = consolidacao_veiculos.find_or_create_by!(motorista_nome: cm.motorista_nome, vehicle: v[:vehicle])
|
||||
cv.arquivar!(user)
|
||||
arquivados << "#{cm.motorista_nome} · #{v[:vehicle]}"
|
||||
end
|
||||
end
|
||||
arquivados
|
||||
end
|
||||
|
||||
# ── Apontamento manual ──────────────────────────────────────
|
||||
# Adiciona uma entrega "por fora" (NF que chegou depois) com um ou mais pilares
|
||||
# (tipos). O motorista vem da própria entrega e é incluído na consolidação se
|
||||
# ainda não estiver. Retorna o nome do motorista. Idempotente por (tracking, tipo).
|
||||
def adicionar_apontamento!(entrega, tipos, user)
|
||||
tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq
|
||||
raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty?
|
||||
|
||||
motorista = entrega.driver.to_s.strip
|
||||
raise ArgumentError, 'Entrega sem motorista — não é possível apontar.' if motorista.blank?
|
||||
|
||||
transaction do
|
||||
consolidacao_motoristas.find_or_create_by!(motorista_nome: motorista)
|
||||
|
||||
tipos_validos.each do |tipo|
|
||||
ce = consolidacao_entregas.find_or_initialize_by(tracking_id: entrega.tracking_id, tipo: tipo)
|
||||
next if ce.persisted? # pilar já existe — mantém
|
||||
|
||||
ce.assign_attributes(
|
||||
motorista_nome: motorista,
|
||||
valor_aplicado: ConsolidacaoEntrega.valor_para(tipo),
|
||||
manual: true,
|
||||
vehicle: entrega.try(:vehicle).presence, # snapshot do veículo da rastreio
|
||||
created_by: user&.id
|
||||
)
|
||||
ce.save!
|
||||
end
|
||||
|
||||
recalcular_motorista!(motorista)
|
||||
end
|
||||
|
||||
motorista
|
||||
end
|
||||
|
||||
# ── Apontamento 100% manual ─────────────────────────────────
|
||||
# Lança uma entrega à mão SEM consultar a base de rastreio. Caso de uso: um
|
||||
# motorista foi até o local mas a NF ficou registrada com outro motorista no
|
||||
# rastreio — então a busca por NF nunca o encontra, mas ele também recebe.
|
||||
# O motorista vem digitado (não de uma Entrega) e é incluído se ainda não
|
||||
# estiver. tracking_id é sintético (não há Entrega), e a NF/observação ficam
|
||||
# guardadas em nf_manual/obs_manual.
|
||||
def adicionar_apontamento_manual!(motorista:, tipos:, user:, nf: nil, obs: nil, valor_extra: nil, vehicle: nil)
|
||||
tipos_validos = Array(tipos).map(&:to_s).select { |t| ConsolidacaoEntrega.tipos.key?(t) }.uniq
|
||||
raise ArgumentError, 'Selecione ao menos uma classificação.' if tipos_validos.empty?
|
||||
|
||||
nome = motorista.to_s.strip
|
||||
raise ArgumentError, 'Informe o motorista do apontamento.' if nome.blank?
|
||||
|
||||
tracking = "MAN-#{SecureRandom.uuid}" # sintético: agrupa os pilares como UMA entrega
|
||||
|
||||
transaction do
|
||||
consolidacao_motoristas.find_or_create_by!(motorista_nome: nome)
|
||||
|
||||
tipos_validos.each do |tipo|
|
||||
valor = if tipo == 'extraordinaria' && valor_extra.present?
|
||||
valor_extra
|
||||
else
|
||||
ConsolidacaoEntrega.valor_para(tipo)
|
||||
end
|
||||
consolidacao_entregas.create!(
|
||||
tracking_id: tracking,
|
||||
tipo: tipo,
|
||||
motorista_nome: nome,
|
||||
valor_aplicado: valor,
|
||||
manual: true,
|
||||
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||||
nf_manual: nf.presence,
|
||||
obs_manual: obs.presence,
|
||||
created_by: user&.id
|
||||
)
|
||||
end
|
||||
|
||||
recalcular_motorista!(nome)
|
||||
end
|
||||
|
||||
nome
|
||||
end
|
||||
|
||||
# ── Entrega de termo ────────────────────────────────────────
|
||||
# Lança um LOTE de N "entregas de termo" — entregas sem NF nem código de rastreio
|
||||
# para atrelar (só motorista + quantidade, a um preço fixo configurável). Fica
|
||||
# numa única linha (manual: true): quantidade guarda o N e valor_aplicado guarda
|
||||
# o total do lote, então recalcular_motorista! e demais somas seguem inalteradas.
|
||||
def adicionar_termos!(motorista:, quantidade:, user:, vehicle: nil)
|
||||
nome = motorista.to_s.strip
|
||||
raise ArgumentError, 'Informe o motorista do termo.' if nome.blank?
|
||||
|
||||
qtd = quantidade.to_i
|
||||
raise ArgumentError, 'Informe uma quantidade válida de termos.' if qtd < 1
|
||||
|
||||
transaction do
|
||||
consolidacao_motoristas.find_or_create_by!(motorista_nome: nome)
|
||||
|
||||
consolidacao_entregas.create!(
|
||||
tracking_id: "TERMO-#{SecureRandom.uuid}", # sintético: não há Entrega/NF
|
||||
tipo: 'termo',
|
||||
motorista_nome: nome,
|
||||
quantidade: qtd,
|
||||
valor_aplicado: qtd * ConsolidacaoEntrega.valor_para('termo'), # total do lote
|
||||
manual: true,
|
||||
vehicle: vehicle.presence, # veículo escolhido no lançamento (Q3)
|
||||
created_by: user&.id
|
||||
)
|
||||
|
||||
recalcular_motorista!(nome)
|
||||
end
|
||||
|
||||
nome
|
||||
end
|
||||
|
||||
# Recalcula o valor de um motorista (soma dos pilares, desconto subtrai) e o
|
||||
# total da consolidação. Fonte única usada pelo wizard e pelos apontamentos.
|
||||
def recalcular_motorista!(nome)
|
||||
cm = consolidacao_motoristas.find_by(motorista_nome: nome)
|
||||
return unless cm
|
||||
|
||||
total = consolidacao_entregas.where(motorista_nome: nome).sum do |e|
|
||||
e.desconto? ? -e.valor_aplicado : e.valor_aplicado
|
||||
end
|
||||
cm.update_column(:valor_total, total)
|
||||
update_column(:valor_total, consolidacao_motoristas.ativos.sum(:valor_total))
|
||||
sincronizar_veiculos!(nome)
|
||||
end
|
||||
|
||||
# Mantém as linhas de consolidacao_veiculos do motorista em dia a partir dos
|
||||
# pilares já classificados (100% DB — não toca a base externa). Cria/atualiza uma
|
||||
# linha por veículo presente nas classificações e remove as que ficaram sem
|
||||
# pilares e ainda não foram fechadas/arquivadas. Veículos elegíveis mas 100% sem
|
||||
# classificar não geram linha aqui — o gate já os pega por classificadas < total.
|
||||
def sincronizar_veiculos!(nome)
|
||||
entregas = consolidacao_entregas.where(motorista_nome: nome)
|
||||
presentes = entregas.pluck(:vehicle).map { |v| v.presence || SEM_VEICULO }.uniq
|
||||
|
||||
presentes.each do |veic|
|
||||
cv = consolidacao_veiculos.find_or_initialize_by(motorista_nome: nome, vehicle: veic)
|
||||
cv.save! # before_save :recalcular_valor atualiza o valor_total do veículo
|
||||
end
|
||||
|
||||
consolidacao_veiculos.where(motorista_nome: nome)
|
||||
.where(fechado_em: nil, arquivado_em: nil)
|
||||
.where.not(vehicle: presentes)
|
||||
.destroy_all
|
||||
end
|
||||
|
||||
private
|
||||
@@ -96,6 +408,6 @@ class Consolidacao < ApplicationRecord
|
||||
end
|
||||
|
||||
def recalcular_valor_total
|
||||
self.valor_total = consolidacao_motoristas.sum(:valor_total)
|
||||
self.valor_total = consolidacao_motoristas.ativos.sum(:valor_total)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,24 +5,35 @@ class ConsolidacaoEntrega < ApplicationRecord
|
||||
|
||||
belongs_to :consolidacao
|
||||
|
||||
# Apontamentos manuais — entregas incluídas por fora do período/operação
|
||||
# (ex.: nota que chegou depois). Buscadas pela NF direto na SimpleRoute.
|
||||
scope :apontamentos, -> { where(manual: true) }
|
||||
|
||||
enum tipo: {
|
||||
entrega_normal: 0,
|
||||
retirada: 1,
|
||||
bonus: 2,
|
||||
desconto: 3
|
||||
desconto: 3,
|
||||
extraordinaria: 4,
|
||||
termo: 5
|
||||
}
|
||||
|
||||
TIPO_CORES = {
|
||||
'entrega_normal' => { bg: 'bg-orange-500', text: 'text-white', label: 'Entrega Normal' },
|
||||
'retirada' => { bg: 'bg-orange-800', text: 'text-white', label: 'Retirada' },
|
||||
'bonus' => { bg: 'bg-white border border-orange-500', text: 'text-black', label: 'Bônus' },
|
||||
'desconto' => { bg: 'bg-gray-900', text: 'text-white', label: 'Desconto' }
|
||||
'desconto' => { bg: 'bg-gray-900', text: 'text-white', label: 'Desconto' },
|
||||
'extraordinaria' => { bg: 'bg-purple-600', text: 'text-white', label: 'Entrega Extraordinária' },
|
||||
'termo' => { bg: 'bg-blue-600', text: 'text-white', label: 'Entrega de Termo' }
|
||||
}.freeze
|
||||
|
||||
validates :tracking_id, presence: true, uniqueness: { scope: :consolidacao_id }
|
||||
# Uma entrega pode ter vários pilares (Normal + Bônus + Retirada…),
|
||||
# mas não o mesmo pilar repetido.
|
||||
validates :tracking_id, presence: true, uniqueness: { scope: %i[consolidacao_id tipo] }
|
||||
validates :motorista_nome, presence: true
|
||||
validates :tipo, presence: true
|
||||
validates :valor_aplicado, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :quantidade, numericality: { greater_than: 0 }
|
||||
|
||||
# Busca a entrega original (leitura do banco existente)
|
||||
def entrega_original
|
||||
@@ -32,4 +43,18 @@ class ConsolidacaoEntrega < ApplicationRecord
|
||||
def tipo_cor
|
||||
TIPO_CORES[tipo] || TIPO_CORES['entrega_normal']
|
||||
end
|
||||
|
||||
# Preço configurado para um pilar (tipo). Fonte única usada na classificação
|
||||
# (controller) e no apontamento manual (Consolidacao#adicionar_apontamento!).
|
||||
def self.valor_para(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
|
||||
when 'extraordinaria' then Configuracao.preco_extraordinaria
|
||||
when 'termo' then Configuracao.preco_termo
|
||||
else 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ class ConsolidacaoMotorista < ApplicationRecord
|
||||
self.table_name = 'consolidacao_motoristas'
|
||||
|
||||
belongs_to :consolidacao
|
||||
belongs_to :pagador, class_name: 'User', foreign_key: :pago_por, optional: true
|
||||
belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true
|
||||
has_many :consolidacao_entregas,
|
||||
->(cm) { where(motorista_nome: cm.motorista_nome) },
|
||||
foreign_key: :consolidacao_id,
|
||||
@@ -15,6 +17,44 @@ class ConsolidacaoMotorista < ApplicationRecord
|
||||
|
||||
before_save :recalcular_valor
|
||||
|
||||
# ── Pagamento ───────────────────────────────────────────────
|
||||
scope :pagos, -> { where.not(pago_em: nil) }
|
||||
scope :pendentes, -> { where(pago_em: nil) }
|
||||
|
||||
# ── Arquivamento (reversível) ───────────────────────────────
|
||||
# Um motorista arquivado sai dos totais/pagamento/gate da consolidação, mas
|
||||
# os dados são mantidos e podem ser restaurados.
|
||||
scope :ativos, -> { where(arquivado_em: nil) }
|
||||
scope :arquivados, -> { where.not(arquivado_em: nil) }
|
||||
|
||||
def arquivado?
|
||||
arquivado_em.present?
|
||||
end
|
||||
|
||||
# update_columns: não dispara o before_save :recalcular_valor (que recalcula o
|
||||
# valor do PRÓPRIO motorista). O total da CONSOLIDAÇÃO é recalculado pelo
|
||||
# controller, que só soma os motoristas ativos.
|
||||
def arquivar!(user)
|
||||
update_columns(arquivado_em: Time.current, arquivado_por: user&.id, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def desarquivar!(_user)
|
||||
update_columns(arquivado_em: nil, arquivado_por: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def pago?
|
||||
pago_em.present?
|
||||
end
|
||||
|
||||
def marcar_pago!(user, forma:, nota_fiscal:)
|
||||
update!(pago_em: Time.current, pago_por: user&.id, forma_pagamento: forma,
|
||||
nota_fiscal: nota_fiscal)
|
||||
end
|
||||
|
||||
def cancelar_pagamento!(_user)
|
||||
update!(pago_em: nil, pago_por: nil, forma_pagamento: nil, nota_fiscal: nil)
|
||||
end
|
||||
|
||||
def recalcular_valor
|
||||
# Fonte única de verdade: o valor_aplicado capturado no momento da
|
||||
# classificação (igual ao ConsolidacaoEntregasController#recalcular_motorista).
|
||||
|
||||
71
app/models/consolidacao_veiculo.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
# app/models/consolidacao_veiculo.rb
|
||||
# Unidade de FECHAMENTO por veículo dentro de uma consolidação. Espelha os padrões
|
||||
# de ConsolidacaoMotorista (arquivamento reversível + valor_total recalculado dos
|
||||
# pilares), mas o que ela controla é o estado "fechado/consolidado" de cada carro.
|
||||
# O PAGAMENTO continua no motorista (ConsolidacaoMotorista) — ver [[consolidacao]].
|
||||
class ConsolidacaoVeiculo < ApplicationRecord
|
||||
# Nome da tabela fixado (mesmo motivo dos outros: inflector pt-BR).
|
||||
self.table_name = 'consolidacao_veiculos'
|
||||
|
||||
# Bucket dos lançamentos sem veículo (termo/apontamento manual legado). Igual ao
|
||||
# sentinel usado no controller e na migração — NULL quebraria o índice único.
|
||||
SEM_VEICULO = '(sem veículo)'
|
||||
|
||||
belongs_to :consolidacao
|
||||
belongs_to :fechador, class_name: 'User', foreign_key: :fechado_por, optional: true
|
||||
belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true
|
||||
|
||||
validates :motorista_nome, presence: true
|
||||
validates :vehicle, presence: true
|
||||
validates :vehicle, uniqueness: { scope: %i[consolidacao_id motorista_nome] }
|
||||
|
||||
before_save :recalcular_valor
|
||||
|
||||
# ── Fechamento (consolidação do veículo, reversível) ────────
|
||||
scope :fechados, -> { where.not(fechado_em: nil) }
|
||||
scope :abertos, -> { where(fechado_em: nil) }
|
||||
|
||||
# ── Arquivamento (reversível) — sai do gate, mantém os dados ─
|
||||
scope :ativos, -> { where(arquivado_em: nil) }
|
||||
scope :arquivados, -> { where.not(arquivado_em: nil) }
|
||||
|
||||
def fechado?
|
||||
fechado_em.present?
|
||||
end
|
||||
|
||||
def arquivado?
|
||||
arquivado_em.present?
|
||||
end
|
||||
|
||||
# update_columns: não dispara before_save (o valor já está correto no fechamento).
|
||||
def fechar!(user)
|
||||
update_columns(fechado_em: Time.current, fechado_por: user&.id, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def reabrir!(_user)
|
||||
update_columns(fechado_em: nil, fechado_por: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def arquivar!(user)
|
||||
update_columns(arquivado_em: Time.current, arquivado_por: user&.id, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def desarquivar!(_user)
|
||||
update_columns(arquivado_em: nil, arquivado_por: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
# Soma dos pilares deste (motorista + veículo). Mesma regra do ConsolidacaoMotorista:
|
||||
# o valor_aplicado capturado na classificação é a fonte da verdade e o desconto
|
||||
# subtrai. O bucket "(sem veículo)" casa os pilares com vehicle NULL/vazio.
|
||||
def recalcular_valor
|
||||
self.valor_total = escopo_entregas
|
||||
.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def escopo_entregas
|
||||
rel = consolidacao.consolidacao_entregas.where(motorista_nome: motorista_nome)
|
||||
vehicle == SEM_VEICULO ? rel.where(vehicle: [nil, '']) : rel.where(vehicle: vehicle)
|
||||
end
|
||||
end
|
||||
@@ -13,39 +13,93 @@ class Entrega < ApplicationRecord
|
||||
true
|
||||
end
|
||||
|
||||
# Status que o SimpleRoute grava para entregas SEM SUCESSO (falha/insucesso).
|
||||
# AJUSTE aqui se o valor real for diferente (ex.: 'cancelled', 'not_delivered').
|
||||
STATUS_FALHA = %w[failed].freeze
|
||||
|
||||
# Status em que o motorista FOI até o local: sucesso ('completed') ou
|
||||
# insucesso ('failed'). Base da elegibilidade na consolidação — o motorista
|
||||
# se deslocou, então a entrega precisa constar para ser classificada, mesmo
|
||||
# que não tenha sido entregue com sucesso.
|
||||
STATUS_ATENDIDO = (%w[completed] + STATUS_FALHA).freeze
|
||||
|
||||
# ── Scopes ──────────────────────────────────────────────────
|
||||
scope :concluidas, -> { where(status: 'completed') }
|
||||
scope :com_checkout, -> { where.not(checkout: nil) }
|
||||
# Pagamento = entrega concluída com checkout registrado
|
||||
scope :pagas, -> { concluidas.com_checkout }
|
||||
scope :pendentes, -> { where.not(status: 'completed') }
|
||||
# Atendidas = motorista foi ao local (sucesso OU insucesso) com checkout.
|
||||
# É o conjunto elegível da consolidação (mais amplo que .pagas).
|
||||
scope :atendidas, -> { where(status: STATUS_ATENDIDO).com_checkout }
|
||||
# Falhadas = entregas que não tiveram sucesso (status de falha)
|
||||
scope :falhadas, -> { where(status: STATUS_FALHA) }
|
||||
# Pendentes = nem concluídas nem falhadas (ainda em aberto). Exclui as falhadas
|
||||
# para que entregues/pendentes/falhadas não se sobreponham no dashboard.
|
||||
scope :pendentes, -> { where.not(status: ['completed'] + STATUS_FALHA) }
|
||||
|
||||
# planned_date é timestamp: usamos o dia inteiro do limite superior para não
|
||||
# descartar as entregas do último dia do período (causava dashboard zerado e
|
||||
# subcontagem de elegíveis nas consolidações).
|
||||
scope :no_periodo, ->(inicio, fim) {
|
||||
where(planned_date: inicio.to_date..fim.to_date)
|
||||
where(planned_date: inicio.to_date.beginning_of_day..fim.to_date.end_of_day)
|
||||
}
|
||||
|
||||
# Período pela DATA REAL da entrega (checkout) — usada nas análises financeiras
|
||||
# ("quando a entrega foi concluída"). Comparação naïve com Date (intervalo
|
||||
# meio-aberto: >= início e < fim+1), SEM conversão de fuso — bate com a análise
|
||||
# feita direto no banco. Só faz sentido sobre entregas .pagas (checkout != nil).
|
||||
scope :no_periodo_checkout, ->(inicio, fim) {
|
||||
where('checkout >= ? AND checkout < ?', inicio.to_date, fim.to_date + 1)
|
||||
}
|
||||
|
||||
scope :do_motorista, ->(nome) {
|
||||
where(driver: nome)
|
||||
}
|
||||
|
||||
# Busca pela NF (reference_id). A coluna é numérica, então comparamos como
|
||||
# texto para aceitar a entrada do usuário de forma robusta. Usado no
|
||||
# apontamento manual de entregas (NF que chegou fora do período).
|
||||
scope :por_nf, ->(nf) {
|
||||
where('reference_id::text = ?', nf.to_s.strip)
|
||||
}
|
||||
|
||||
scope :da_veiculo, ->(vehicle) {
|
||||
where(vehicle: vehicle)
|
||||
}
|
||||
|
||||
# Restringe às entregas cujas NFs (reference_id) constam na(s) tabela(s) de
|
||||
# operação informada(s). O JOIN é o mesmo do script de gestão do cliente:
|
||||
# reference_id::text = <tabela_operacao>.nota_fiscal
|
||||
#
|
||||
# SEGURANÇA: o nome da tabela entra no SQL, então passa pela whitelist
|
||||
# (Operacao.sanitizar) e por quote_table_name. Nomes inválidos são descartados;
|
||||
# se nada sobrar, não filtra (all) — o filtro de operação é opcional.
|
||||
scope :da_operacoes, ->(tabelas) {
|
||||
validas = Operacao.sanitizar(tabelas)
|
||||
next all if validas.empty?
|
||||
|
||||
selects = validas.map { |t| "SELECT nota_fiscal FROM #{connection.quote_table_name(t)}" }
|
||||
where("reference_id::text IN (#{selects.join(' UNION ')})")
|
||||
}
|
||||
|
||||
scope :do_mes, ->(data = Date.current) {
|
||||
where(planned_date: data.beginning_of_month..data.end_of_month)
|
||||
}
|
||||
|
||||
# Filtra pela(s) conta(s) configurada(s) em DB_EXISTING_ACCOUNT_ID.
|
||||
# A coluna account_id é TEXTO e pode vir vazia, então comparamos como string.
|
||||
# A coluna account_id é TEXTO e pode vir vazia/NULL, então comparamos como string.
|
||||
# IMPORTANTE: o valor é uma LISTA separada por vírgula — NÃO um trecho de SQL.
|
||||
# Aceita: "95907" (uma) | "95907,12345" (várias) | "all" ou vazio (sem filtro).
|
||||
# Use "95907," (com vírgula) para incluir também os registros de conta vazia.
|
||||
# Use "95907," (vírgula no fim) para incluir também registros de conta vazia/NULL.
|
||||
scope :da_conta_gade, -> {
|
||||
contas = ENV.fetch('DB_EXISTING_ACCOUNT_ID', '95907').to_s.strip
|
||||
if contas.empty? || contas.casecmp?('all')
|
||||
all
|
||||
else
|
||||
where(account_id: contas.split(',', -1).map(&:strip))
|
||||
valores = contas.split(',', -1).map(&:strip)
|
||||
# Token vazio (ex.: "95907,") → inclui também as linhas sem conta (NULL).
|
||||
valores << nil if valores.any?(&:empty?)
|
||||
where(account_id: valores)
|
||||
end
|
||||
}
|
||||
|
||||
@@ -65,11 +119,25 @@ class Entrega < ApplicationRecord
|
||||
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
|
||||
end
|
||||
|
||||
# Contagem de entregas pagas para cálculo estimado
|
||||
def self.contar_pagas(inicio:, fim:, motorista: nil, vehicle: nil)
|
||||
base = pagas.da_conta_gade.no_periodo(inicio, fim)
|
||||
base = base.do_motorista(motorista) if motorista.present?
|
||||
base = base.da_veiculo(vehicle) if vehicle.present?
|
||||
# Veículos usados pelo(s) motorista(s) selecionado(s) no período — alimenta o
|
||||
# filtro dinâmico de veículos na criação de consolidação.
|
||||
def self.veiculos_de_motoristas(motoristas, inicio: nil, fim: nil)
|
||||
nomes = Array(motoristas).reject(&:blank?)
|
||||
return [] if nomes.empty?
|
||||
|
||||
base = da_conta_gade.where(driver: nomes)
|
||||
base = base.no_periodo(inicio, fim) if inicio.present? && fim.present?
|
||||
base.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:empty?)
|
||||
end
|
||||
|
||||
# Contagem de entregas ATENDIDAS (motorista foi ao local: sucesso + insucesso)
|
||||
# elegíveis para a consolidação.
|
||||
# Período pela DATA REAL da entrega (checkout) — ver no_periodo_checkout.
|
||||
def self.contar_atendidas(inicio:, fim:, motorista: nil, vehicle: nil, operacoes: nil)
|
||||
base = atendidas.da_conta_gade.no_periodo_checkout(inicio, fim)
|
||||
base = base.do_motorista(motorista) if motorista.present?
|
||||
base = base.da_veiculo(vehicle) if vehicle.present?
|
||||
base = base.da_operacoes(operacoes) if operacoes.present?
|
||||
base.count
|
||||
end
|
||||
|
||||
@@ -79,6 +147,21 @@ class Entrega < ApplicationRecord
|
||||
reference_id
|
||||
end
|
||||
|
||||
# Resumo para o apontamento manual (modal de busca de NF). Fonte única usada
|
||||
# pelos dois fluxos: apontar em consolidação existente e criar "nota avulsa".
|
||||
def resumo_apontamento
|
||||
{
|
||||
tracking_id: tracking_id,
|
||||
nf: numero_nf,
|
||||
motorista: driver,
|
||||
veiculo: vehicle,
|
||||
local: local,
|
||||
status: status,
|
||||
pago: elegivel_pagamento?,
|
||||
data: (checkout || planned_date)&.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def local
|
||||
contact_name.presence || address
|
||||
end
|
||||
@@ -87,6 +170,11 @@ class Entrega < ApplicationRecord
|
||||
status == 'completed'
|
||||
end
|
||||
|
||||
# Insucesso: o motorista foi ao local mas a entrega não foi concluída.
|
||||
def falhada?
|
||||
STATUS_FALHA.include?(status)
|
||||
end
|
||||
|
||||
def checkout_registrado?
|
||||
checkout.present?
|
||||
end
|
||||
|
||||
130
app/models/operacao.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# app/models/operacao.rb
|
||||
#
|
||||
# Operações são as tabelas EXTERNAS que o cliente carrega no banco, uma por
|
||||
# operação/mês (ex.: "gade_entregas_ubs_sul_leste_jun_2026"). Cada tabela tem a
|
||||
# coluna `nota_fiscal`, que casa com db_reem_simplerout_2026.reference_id::text.
|
||||
#
|
||||
# Estas tabelas são SOMENTE LEITURA — apenas SELECT, nunca migration/INSERT/etc.
|
||||
#
|
||||
# Como o nome da tabela entra em SQL (FROM dinâmico), TODA entrada externa passa
|
||||
# por uma whitelist (#nomes_validos) antes de ser usada — proteção anti-injection.
|
||||
class Operacao
|
||||
PREFIXO = ENV.fetch('OPERACOES_TABLE_PREFIX', 'gade_entregas_').freeze
|
||||
|
||||
class << self
|
||||
# [{ tabela: 'gade_entregas_...', label: 'Ubs Sul Leste Jun 2026' }, ...]
|
||||
def todas
|
||||
nomes_validos.map { |t| { tabela: t, label: label(t) } }
|
||||
end
|
||||
|
||||
# Nomes reais de tabelas no schema public que batem com o prefixo.
|
||||
# Sem cache de processo: o cliente sobe novas tabelas de operação a qualquer
|
||||
# momento e elas precisam aparecer sem reiniciar o servidor (a consulta ao
|
||||
# catálogo é barata).
|
||||
def nomes_validos
|
||||
conn.select_values(
|
||||
"SELECT table_name FROM information_schema.tables " \
|
||||
"WHERE table_schema = 'public' AND table_name LIKE #{conn.quote("#{PREFIXO}%")} ORDER BY table_name"
|
||||
)
|
||||
end
|
||||
|
||||
# Interseção da lista recebida com as tabelas que existem de fato.
|
||||
def sanitizar(lista)
|
||||
Array(lista).map(&:to_s).uniq & nomes_validos
|
||||
end
|
||||
|
||||
def valida?(nome)
|
||||
nomes_validos.include?(nome.to_s)
|
||||
end
|
||||
|
||||
# Rótulo amigável: tira o prefixo e formata em CAIXA ALTA (para não parecer o
|
||||
# nome cru da tabela do banco).
|
||||
def label(tabela)
|
||||
tabela.to_s.delete_prefix(PREFIXO).tr('_', ' ').strip.upcase
|
||||
end
|
||||
|
||||
# Meses usados nos nomes das tabelas (gade_entregas_emad_fev_2026). Aceita a
|
||||
# abreviação (fev) E o nome por extenso (fevereiro) — o cliente usa os dois
|
||||
# (ex.: "..._ubs_norte_maio_2026"), então ambos precisam cair no mês certo.
|
||||
MESES = {
|
||||
'jan' => [1, 'Janeiro'], 'janeiro' => [1, 'Janeiro'],
|
||||
'fev' => [2, 'Fevereiro'], 'fevereiro' => [2, 'Fevereiro'],
|
||||
'mar' => [3, 'Março'], 'marco' => [3, 'Março'], 'março' => [3, 'Março'],
|
||||
'abr' => [4, 'Abril'], 'abril' => [4, 'Abril'],
|
||||
'mai' => [5, 'Maio'], 'maio' => [5, 'Maio'],
|
||||
'jun' => [6, 'Junho'], 'junho' => [6, 'Junho'],
|
||||
'jul' => [7, 'Julho'], 'julho' => [7, 'Julho'],
|
||||
'ago' => [8, 'Agosto'], 'agosto' => [8, 'Agosto'],
|
||||
'set' => [9, 'Setembro'], 'setembro' => [9, 'Setembro'],
|
||||
'out' => [10, 'Outubro'], 'outubro' => [10, 'Outubro'],
|
||||
'nov' => [11, 'Novembro'], 'novembro' => [11, 'Novembro'],
|
||||
'dez' => [12, 'Dezembro'], 'dezembro' => [12, 'Dezembro']
|
||||
}.freeze
|
||||
|
||||
# Agrupa as operações por mês/ano (extraído do nome da tabela), do mais recente
|
||||
# para o mais antigo; as que não têm mês reconhecível caem em "Outras".
|
||||
# Retorna um Array de [titulo_do_grupo, [{ tabela:, label: }, ...]].
|
||||
def agrupadas_por_mes
|
||||
grupos = Hash.new { |h, k| h[k] = [] }
|
||||
ordem = {}
|
||||
|
||||
todas.each do |op|
|
||||
g = grupo_de(op[:tabela])
|
||||
grupos[g[:titulo]] << { tabela: op[:tabela], label: g[:item] }
|
||||
ordem[g[:titulo]] ||= g[:ordem]
|
||||
end
|
||||
|
||||
grupos.each_value { |itens| itens.sort_by! { |i| i[:label] } }
|
||||
grupos.sort_by { |titulo, _| ordem[titulo] }.map { |titulo, itens| [titulo, itens] }
|
||||
end
|
||||
|
||||
# Dados agregados das entregas das operações informadas, para pré-preencher o
|
||||
# formulário de nova consolidação. Une as NFs de todas as tabelas válidas.
|
||||
def dados(tabelas)
|
||||
validas = sanitizar(tabelas)
|
||||
return vazio if validas.empty?
|
||||
|
||||
entregas = Entrega.da_conta_gade.da_operacoes(validas)
|
||||
pagas = entregas.pagas
|
||||
{
|
||||
motoristas: entregas.distinct.order(:driver).pluck(:driver).compact.reject(&:blank?),
|
||||
veiculos: entregas.distinct.order(:vehicle).pluck(:vehicle).compact.reject(&:blank?),
|
||||
# Período sugerido pela DATA REAL (checkout), igual ao filtro de
|
||||
# elegibilidade; cai para planned_date se ainda não houver pagas.
|
||||
inicio: (pagas.minimum(:checkout) || entregas.minimum(:planned_date))&.to_date,
|
||||
fim: (pagas.maximum(:checkout) || entregas.maximum(:planned_date))&.to_date
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrai { titulo:, item:, ordem: } do nome da tabela.
|
||||
# Ex.: gade_entregas_emad_fev_2026 → titulo "Fevereiro 2026", item "Emad".
|
||||
def grupo_de(tabela)
|
||||
tokens = tabela.to_s.delete_prefix(PREFIXO).split('_')
|
||||
idx = tokens.index { |t| MESES.key?(t.downcase) }
|
||||
|
||||
if idx
|
||||
num, nome = MESES[tokens[idx].downcase]
|
||||
ano = tokens[idx + 1] =~ /\A\d{4}\z/ ? tokens[idx + 1].to_i : nil
|
||||
resto = (tokens[0...idx] + tokens[(ano ? idx + 2 : idx + 1)..]).join(' ').strip
|
||||
{
|
||||
titulo: ano ? "#{nome} #{ano}" : nome,
|
||||
item: (resto.presence || nome).to_s.upcase,
|
||||
# mais recente primeiro; sem ano vai pro fim do bloco do mês
|
||||
ordem: [-(ano || 0), -num]
|
||||
}
|
||||
else
|
||||
{ titulo: 'Outras', item: label(tabela), ordem: [1, 0] }
|
||||
end
|
||||
end
|
||||
|
||||
def vazio
|
||||
{ motoristas: [], veiculos: [], inicio: nil, fim: nil }
|
||||
end
|
||||
|
||||
def conn
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,13 +7,16 @@ class User < ApplicationRecord
|
||||
:validatable
|
||||
|
||||
# ── Roles ──────────────────────────────────────────────────
|
||||
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3 }
|
||||
# externo: acesso somente-leitura ao dashboard (ex.: gerências de outras áreas
|
||||
# que só precisam visualizar os indicadores). Login normal por e-mail/senha.
|
||||
enum role: { admin: 0, gerente: 1, operador: 2, motorista: 3, externo: 4 }
|
||||
|
||||
ROLES_LABEL = {
|
||||
'admin' => 'Administrador',
|
||||
'gerente' => 'Gerente',
|
||||
'operador' => 'Operador',
|
||||
'motorista' => 'Motorista'
|
||||
'motorista' => 'Motorista',
|
||||
'externo' => 'Externo (só dashboard)'
|
||||
}.freeze
|
||||
|
||||
# ── Validações ──────────────────────────────────────────────
|
||||
@@ -27,6 +30,10 @@ class User < ApplicationRecord
|
||||
|
||||
validate :pin_unico_para_motoristas, if: :motorista?
|
||||
|
||||
# E-mail em branco vira NULL: evita colisão no índice único quando vários
|
||||
# motoristas são criados sem e-mail (no Postgres NULLs são distintos entre si).
|
||||
before_validation :normalizar_email_em_branco
|
||||
|
||||
# Motoristas podem não ter e-mail (login via PIN)
|
||||
def email_required?
|
||||
!motorista?
|
||||
@@ -58,13 +65,17 @@ class User < ApplicationRecord
|
||||
admin? || gerente? || operador?
|
||||
end
|
||||
|
||||
# Token de login rápido via QR Code (holerite) — Fase 7/8
|
||||
# Token de login rápido via QR Code (extrato) — Fase 7/8
|
||||
def regenerar_login_token!
|
||||
update!(login_token: SecureRandom.urlsafe_base64(24))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalizar_email_em_branco
|
||||
self.email = nil if email.blank?
|
||||
end
|
||||
|
||||
def pin_unico_para_motoristas
|
||||
return if pin_code.blank?
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# app/policies/consolidacao_policy.rb
|
||||
class ConsolidacaoPolicy < ApplicationPolicy
|
||||
def index? = user.pode_consolidar?
|
||||
def show? = user.pode_consolidar?
|
||||
def create? = user.pode_consolidar?
|
||||
def index? = user.pode_consolidar?
|
||||
def arquivadas? = 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?
|
||||
|
||||
# Registrar/estornar pagamento: mesma régua de arquivar/excluir.
|
||||
def registrar_pagamento? = user.admin? || user.gerente?
|
||||
def cancelar_pagamento? = registrar_pagamento?
|
||||
|
||||
private
|
||||
|
||||
# Operador não mexe em consolidação finalizada
|
||||
|
||||
@@ -7,4 +7,9 @@ class DashboardPolicy < Struct.new(:user, :dashboard)
|
||||
def index?
|
||||
user.admin? || user.gerente?
|
||||
end
|
||||
|
||||
# Dashboard de operações: todos menos motorista (mesma regra do dashboard HTML).
|
||||
def operacoes?
|
||||
!user.motorista?
|
||||
end
|
||||
end
|
||||
|
||||
11
app/policies/edicao_lancamento_policy.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/policies/edicao_lancamento_policy.rb
|
||||
#
|
||||
# Correção de lançamento grava DIRETO na base de produção do SimpliRoute —
|
||||
# operação sensível e irreversível. Restrita a admin.
|
||||
# Policy "headless": autorizada com o símbolo :edicao_lancamento (sem model).
|
||||
class EdicaoLancamentoPolicy < ApplicationPolicy
|
||||
def show? = admin?
|
||||
def buscar? = admin?
|
||||
def atualizar? = admin?
|
||||
def historico? = admin?
|
||||
end
|
||||
9
app/policies/planilha_simpli_route_policy.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# app/policies/planilha_simpliroute_policy.rb
|
||||
#
|
||||
# Geração da planilha de carga do SimpliRoute — tarefa de preparação de operação.
|
||||
# Liberada para admin e gerente (quem prepara as cargas). Policy "headless"
|
||||
# (autorizada com o símbolo :planilha_simpliroute).
|
||||
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
|
||||
def show? = admin_ou_gerente?
|
||||
def baixar? = admin_ou_gerente?
|
||||
end
|
||||
308
app/services/analytics/operacao_metricas.rb
Normal file
@@ -0,0 +1,308 @@
|
||||
# app/services/analytics/operacao_metricas.rb
|
||||
#
|
||||
# Núcleo de dados do "Dashboard de Operações".
|
||||
#
|
||||
# Espelha a query de gestão do cliente: pega o ÚLTIMO status de cada NF em
|
||||
# db_reem_simplerout_2026 (ROW_NUMBER por reference_id, checkout desc) e faz
|
||||
# INNER JOIN com a(s) tabela(s) de operação (gade_entregas_*) por
|
||||
# reference_id::text = nota_fiscal. Depois agrega tudo em Ruby para alimentar os
|
||||
# painéis (KPIs, insucessos %, índices de falha, status, motoristas, STS, por dia
|
||||
# e o mapa de calor).
|
||||
#
|
||||
# SEGURANÇA: os nomes das tabelas de operação só entram no SQL depois de passar
|
||||
# pela whitelist (Operacao.sanitizar) + connection.quote_table_name — mesmo padrão
|
||||
# anti-injection usado em Entrega.da_operacoes. As tabelas são SOMENTE LEITURA.
|
||||
module Analytics
|
||||
class OperacaoMetricas
|
||||
# Centro padrão do mapa quando não há coordenadas (Grande São Paulo).
|
||||
SP_CENTRO = [-23.55, -46.63].freeze
|
||||
|
||||
# inicio/fim são OPCIONAIS: quando nil, não há filtro de data e o conjunto é a
|
||||
# própria operação inteira (visão de operação única). O filtro de período só é
|
||||
# usado na visão Global.
|
||||
#
|
||||
# filtros: Hash de cross-filter (coluna => valor) aplicado em memória — clicar
|
||||
# numa célula da tabela (motorista/STS/status/observação) refiltra TODO o
|
||||
# dashboard por aquele valor. Ex.: { 'driver' => 'Carlos' }.
|
||||
def initialize(tabelas:, inicio: nil, fim: nil, filtros: {}, data: nil)
|
||||
@tabelas = Operacao.sanitizar(Array(tabelas))
|
||||
@inicio = inicio&.to_date
|
||||
@fim = fim&.to_date
|
||||
@filtros = (filtros || {}).reject { |_, v| v.to_s.strip.empty? }
|
||||
@data = data&.to_date # filtro por DIA (clique no gráfico "Entregas por Dia")
|
||||
end
|
||||
|
||||
def operacoes_label
|
||||
@tabelas.map { |t| Operacao.label(t) }.join(', ')
|
||||
end
|
||||
|
||||
# Linhas após o cross-filter — base de TODAS as agregações/KPIs.
|
||||
def linhas
|
||||
@linhas ||= begin
|
||||
base = @filtros.empty? ? registros
|
||||
: registros.select { |r| @filtros.all? { |col, val| r[col].to_s == val.to_s } }
|
||||
@data ? base.select { |r| data_de(r['checkout']) == @data } : base
|
||||
end
|
||||
end
|
||||
|
||||
# Campos pesquisáveis da tabela espelho (planilha da operação).
|
||||
BUSCA_CAMPOS = %w[reference_id driver vehicle status observation contact_name
|
||||
address nome_completo endereco_completo status_gade operacao].freeze
|
||||
|
||||
# Busca textual da tabela espelho: filtra as linhas (já pós cross-filter)
|
||||
# por qualquer campo exibido na planilha.
|
||||
def buscar(texto)
|
||||
termo = texto.to_s.strip.downcase
|
||||
return linhas if termo.empty?
|
||||
linhas.select { |r| BUSCA_CAMPOS.any? { |c| r[c].to_s.downcase.include?(termo) } }
|
||||
end
|
||||
|
||||
# Taxas (úteis no comparativo).
|
||||
def taxa_sucesso
|
||||
pct(sucesso, total)
|
||||
end
|
||||
|
||||
def taxa_insucesso
|
||||
pct(recusas, total)
|
||||
end
|
||||
|
||||
# Faixa real das entregas carregadas (pela data de checkout) — para exibir o
|
||||
# período natural da operação no cabeçalho/painel.
|
||||
def data_inicio
|
||||
datas_checkout.min
|
||||
end
|
||||
|
||||
def data_fim
|
||||
datas_checkout.max
|
||||
end
|
||||
|
||||
# Linhas cruas (Array de Hash com chave string) — base de todas as agregações.
|
||||
def registros
|
||||
@registros ||= carregar
|
||||
end
|
||||
|
||||
def vazio?
|
||||
registros.empty?
|
||||
end
|
||||
|
||||
# ── KPIs ─────────────────────────────────────────────────────
|
||||
def total
|
||||
linhas.size
|
||||
end
|
||||
|
||||
def sucesso
|
||||
linhas.count { |r| r['status'] == 'completed' }
|
||||
end
|
||||
|
||||
def recusas
|
||||
linhas.count { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
end
|
||||
|
||||
# Em aberto: nem concluídas nem falhadas.
|
||||
def pendentes
|
||||
total - sucesso - recusas
|
||||
end
|
||||
|
||||
# Donut "Insucessos %": completas vs falhas (sobre o total).
|
||||
def insucessos_pct
|
||||
{
|
||||
completed: sucesso,
|
||||
failed: recusas,
|
||||
pct_completed: pct(sucesso, total),
|
||||
pct_failed: pct(recusas, total)
|
||||
}
|
||||
end
|
||||
|
||||
# Falhas agrupadas por observação (motivo), % sobre o total de falhas.
|
||||
def indices_falha
|
||||
falhas = linhas.select { |r| Entrega::STATUS_FALHA.include?(r['status']) }
|
||||
total_falhas = falhas.size
|
||||
falhas.group_by { |r| r['observation'].presence || 'SEM OBSERVAÇÃO' }
|
||||
.map { |obs, rows| { observation: obs, total: rows.size, pct: pct(rows.size, total_falhas) } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
# Contagem por status da tabela gade (RECORRENTE/NOVO/...).
|
||||
def por_status_gade
|
||||
linhas.group_by { |r| r['status_gade'].presence || '—' }
|
||||
.map { |st, rows| { status: st, total: rows.size } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
# NFs por motorista (todas as entregas), desc.
|
||||
def por_motorista
|
||||
contagem(linhas, 'driver')
|
||||
end
|
||||
|
||||
# Entregas CONCLUÍDAS por unidade (contact_name), desc.
|
||||
def por_sts
|
||||
contagem(linhas.select { |r| r['status'] == 'completed' }, 'contact_name')
|
||||
end
|
||||
|
||||
# Por dia (DATE do checkout): completas vs falhas — gráfico de barras.
|
||||
def por_dia
|
||||
por_data = Hash.new { |h, k| h[k] = { completed: 0, failed: 0 } }
|
||||
linhas.each do |r|
|
||||
d = data_de(r['checkout'])
|
||||
next unless d
|
||||
if r['status'] == 'completed'
|
||||
por_data[d][:completed] += 1
|
||||
elsif Entrega::STATUS_FALHA.include?(r['status'])
|
||||
por_data[d][:failed] += 1
|
||||
end
|
||||
end
|
||||
datas = por_data.keys.sort
|
||||
{
|
||||
labels: datas.map { |d| d.strftime('%d/%m/%Y') },
|
||||
completed: datas.map { |d| por_data[d][:completed] },
|
||||
failed: datas.map { |d| por_data[d][:failed] }
|
||||
}
|
||||
end
|
||||
|
||||
# Pontos [lat, lng] das entregas ATENDIDAS (sucesso + falhas) para o heatmap.
|
||||
# Prefere as coordenadas de CHECKOUT (saída); cai para as de CHECKIN.
|
||||
def pontos_mapa
|
||||
@pontos_mapa ||= linhas.filter_map do |r|
|
||||
next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
|
||||
lat = num(r['checkout_latitude']) || num(r['latitude'])
|
||||
lng = num(r['checkout_longitude']) || num(r['longitude'])
|
||||
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||
[lat, lng]
|
||||
end
|
||||
end
|
||||
|
||||
def centro_mapa
|
||||
pontos_mapa.first || SP_CENTRO
|
||||
end
|
||||
|
||||
# Limite de marcadores no modo "Pontos" (Leaflet fica pesado com milhares).
|
||||
MAX_PONTOS = 2000
|
||||
|
||||
# Pontos detalhados (modo "Pontos" do mapa) — um por entrega ATENDIDA
|
||||
# (sucesso OU insucesso: óbito e demais pilares de falha), com coordenada de
|
||||
# CHECKOUT (ou planejada como fallback). Inclui dados para o popup (NF,
|
||||
# destinatário, endereço, unidade, motorista, veículo, data, status, motivo).
|
||||
def pontos_detalhados
|
||||
@pontos_detalhados ||= linhas.filter_map do |r|
|
||||
next unless Entrega::STATUS_ATENDIDO.include?(r['status'])
|
||||
lat = num(r['checkout_latitude']) || num(r['latitude'])
|
||||
lng = num(r['checkout_longitude']) || num(r['longitude'])
|
||||
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||
{
|
||||
lat: lat, lng: lng,
|
||||
nf: r['reference_id'],
|
||||
nome: r['nome_completo'].presence || r['contact_name'].presence || '—',
|
||||
endereco: r['endereco_completo'].presence || r['address'].presence,
|
||||
unidade: r['contact_name'],
|
||||
motorista: r['driver'],
|
||||
veiculo: r['vehicle'],
|
||||
checkout: data_de(r['checkout'])&.strftime('%d/%m/%Y'),
|
||||
sucesso: r['status'] == 'completed',
|
||||
motivo: r['observation'].presence,
|
||||
foto: foto_url(r['foto_da_fachada'])
|
||||
}
|
||||
end.first(MAX_PONTOS)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def datas_checkout
|
||||
@datas_checkout ||= linhas.filter_map { |r| data_de(r['checkout']) }
|
||||
end
|
||||
|
||||
def contagem(rows, coluna)
|
||||
rows.group_by { |r| r[coluna].presence || '—' }
|
||||
.map { |nome, grupo| { nome: nome, total: grupo.size } }
|
||||
.sort_by { |h| -h[:total] }
|
||||
end
|
||||
|
||||
def pct(parte, total)
|
||||
return 0.0 if total.to_i.zero?
|
||||
(parte.to_f / total * 100).round(2)
|
||||
end
|
||||
|
||||
def num(valor)
|
||||
return nil if valor.nil? || valor.to_s.strip.empty?
|
||||
Float(valor)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def data_de(valor)
|
||||
return nil if valor.nil?
|
||||
return valor.to_date if valor.respond_to?(:to_date)
|
||||
Date.parse(valor.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
# Só aceita URL http(s) — evita injetar lixo no <img src> do popup.
|
||||
def foto_url(valor)
|
||||
url = valor.to_s.strip
|
||||
url.match?(%r{\Ahttps?://}i) ? url : nil
|
||||
end
|
||||
|
||||
def carregar
|
||||
return [] if @tabelas.empty?
|
||||
|
||||
conn = ActiveRecord::Base.connection
|
||||
|
||||
unions = @tabelas.map do |tabela|
|
||||
gade = conn.quote_table_name(tabela)
|
||||
label = conn.quote(Operacao.label(tabela))
|
||||
<<~SQL.strip
|
||||
SELECT #{label} AS operacao,
|
||||
r.reference_id, r.driver, r.vehicle, r.status, r.observation, r.contact_name, r.address,
|
||||
r.checkout, r.planned_date, r.foto_da_fachada,
|
||||
r.latitude, r.longitude,
|
||||
r.checkout_latitude, r.checkout_longitude,
|
||||
#{selects_gade(conn, tabela)}
|
||||
FROM ultimo r
|
||||
INNER JOIN #{gade} g ON r.reference_id::text = g.nota_fiscal
|
||||
WHERE r.rn = 1#{filtro_periodo(conn)}
|
||||
SQL
|
||||
end
|
||||
|
||||
# Sem filtro de conta: o INNER JOIN com a tabela da operação (gade_entregas_*)
|
||||
# já restringe aos dados do cliente. Mantém a query idêntica à de gestão.
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
#{unions.join("\nUNION ALL\n")}
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# Colunas extras da tabela gade que só existem em ALGUMAS operações (ex.:
|
||||
# ubs_norte). Quando a coluna não existe, devolve NULL com o mesmo alias — assim
|
||||
# o UNION entre operações continua consistente. (A única coluna garantida em
|
||||
# toda tabela gade é nota_fiscal.) As chaves são uma whitelist fixa (não entram
|
||||
# dados do usuário no SQL).
|
||||
GADE_OPCIONAIS = { 'status' => 'status_gade', 'nome_completo' => 'nome_completo',
|
||||
'endereco_completo' => 'endereco_completo' }.freeze
|
||||
|
||||
def selects_gade(conn, tabela)
|
||||
colunas = conn.columns(tabela).map(&:name)
|
||||
GADE_OPCIONAIS.map do |origem, apelido|
|
||||
colunas.include?(origem) ? "g.#{origem} AS #{apelido}" : "CAST(NULL AS text) AS #{apelido}"
|
||||
end.join(", ")
|
||||
end
|
||||
|
||||
# Filtro de período OPCIONAL — usado só na visão Global (inicio/fim presentes).
|
||||
# Concluídas/falhas pela DATA REAL (checkout); pendentes (sem checkout) pela
|
||||
# data planejada. Espelha Entrega.no_periodo_checkout + no_periodo.
|
||||
def filtro_periodo(conn)
|
||||
return '' unless @inicio && @fim
|
||||
ini = conn.quote(@inicio)
|
||||
fim_excl = conn.quote(@fim + 1)
|
||||
fim_dia = conn.quote(@fim.end_of_day)
|
||||
" AND ((r.checkout >= #{ini} AND r.checkout < #{fim_excl})" \
|
||||
" OR (r.checkout IS NULL AND r.planned_date >= #{ini} AND r.planned_date <= #{fim_dia}))"
|
||||
end
|
||||
end
|
||||
end
|
||||
242
app/services/analytics/planilha_entregas.rb
Normal file
@@ -0,0 +1,242 @@
|
||||
# app/services/analytics/planilha_entregas.rb
|
||||
#
|
||||
# Monta os dados da "planilha Entregas" entregue ao cliente (modelo
|
||||
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx"), com as 3 abas do modelo:
|
||||
#
|
||||
# RESUMO — totais/performance e quadro por coordenadoria (EMAD e UBS),
|
||||
# com os motivos de "Não Entregue" agregados do rastreio.
|
||||
# ENTREGAS — planilha do cliente (A..V da tabela gade_entregas_*) com as
|
||||
# colunas de resultado W..Z preenchidas pelo rastreio (o
|
||||
# preenchimento que hoje é feito manualmente).
|
||||
# SimpliRoute — dump cru do rastreio (todas as visitas das NFs da operação).
|
||||
#
|
||||
# SEGURANÇA: tabela passa pela whitelist (Operacao.sanitizar) + quote_table_name;
|
||||
# colunas são whitelist fixa e as ausentes viram NULL (mesmo padrão do
|
||||
# OperacaoMetricas). Bases SOMENTE LEITURA.
|
||||
module Analytics
|
||||
class PlanilhaEntregas
|
||||
# Colunas da tabela gade (snake_case) -> cabeçalho exato do modelo (A..V).
|
||||
# Coluna que não existir em alguma operação sai vazia, preservando o layout.
|
||||
COLUNAS_GADE = {
|
||||
'ordem' => 'Ordem',
|
||||
'operacao' => 'Operação',
|
||||
'coordenadoria' => 'Coordenadoria',
|
||||
'supervisao' => 'Supervisão',
|
||||
'unidade' => 'Unidade',
|
||||
'data_cadastro' => 'Data Cadastro',
|
||||
'cartao_sus' => 'Cartão SUS',
|
||||
'nome_completo' => 'Nome Completo',
|
||||
'endereco_completo' => 'Endereço Completo',
|
||||
'nome_responsavel' => 'Nome Responsável',
|
||||
'telefones' => 'Telefones',
|
||||
'area_de_risco' => 'Área de Risco',
|
||||
'numero_ativo' => 'Numero Ativo',
|
||||
'num_serie_base' => 'Num Serie Base',
|
||||
'num_serie_pulverizador' => 'Num Serie Pulverizador',
|
||||
'observacoes' => 'Observações',
|
||||
'endereco_sem_complemento' => 'Endereço Sem Complemento',
|
||||
'complemento' => 'Complemento',
|
||||
'latitude' => 'Latitude',
|
||||
'longitude' => 'Longitude',
|
||||
'status' => 'STATUS',
|
||||
'nota_fiscal' => 'NOTA FISCAL'
|
||||
}.freeze
|
||||
|
||||
# Cabeçalho completo A..Z da aba ENTREGAS (gade + resultado do rastreio).
|
||||
CABECALHO = (COLUNAS_GADE.values + ['STATUS', 'ENTREGA', 'DATA OCORRÊNCIA', 'OCORRÊNCIA']).freeze
|
||||
|
||||
# Aba SimpliRoute: cabeçalho exato do modelo -> colunas candidatas na tabela
|
||||
# de rastreio (a primeira que existir é usada; nenhuma existente = vazia).
|
||||
RASTREIO_COLUNAS = {
|
||||
'Tracking ID' => %w[tracking_id],
|
||||
'Referência ID' => %w[reference_id],
|
||||
'Data prevista' => %w[planned_date],
|
||||
'Motorista' => %w[driver],
|
||||
'Co-pilotos' => %w[copilots co_pilotos copilotos],
|
||||
'Veículo' => %w[vehicle],
|
||||
'Título' => %w[title titulo],
|
||||
'Endereço' => %w[address],
|
||||
'ETA' => %w[eta],
|
||||
'ETD' => %w[etd],
|
||||
'Checkin' => %w[checkin],
|
||||
'Checkout' => %w[checkout],
|
||||
'Responsável' => %w[receiver responsavel responsible],
|
||||
'Tempo de serviço estimado' => %w[estimated_service_time tempo_de_servico_estimado duration],
|
||||
'Tempo de serviço realme' => %w[real_service_time tempo_de_servico_real],
|
||||
'Antecipação' => %w[early antecipacao anticipation],
|
||||
'Atraso' => %w[delay atraso],
|
||||
'Latitude' => %w[latitude],
|
||||
'Longitude' => %w[longitude],
|
||||
'Checkout latitude' => %w[checkout_latitude],
|
||||
'Checkout longitude' => %w[checkout_longitude],
|
||||
'Load' => %w[load load_1 carga],
|
||||
'Load 2' => %w[load_2],
|
||||
'Load 3' => %w[load_3],
|
||||
'Load 4' => %w[load_4],
|
||||
'Estado' => %w[status],
|
||||
'Comentários' => %w[checkout_comment comments comentarios comment],
|
||||
'Observações' => %w[observation observacoes],
|
||||
'Janela de horário Inicial 1' => %w[window_start window_start_1 janela_inicial_1],
|
||||
'Janela de horário Final 1' => %w[window_end window_end_1 janela_final_1],
|
||||
'Janela de horário Inicial 2' => %w[window_start_2 janela_inicial_2],
|
||||
'Janela de horário Final 2' => %w[window_end_2 janela_final_2],
|
||||
'Habilidades necessárias' => %w[skills_required habilidades_necessarias],
|
||||
'Habilidades adicionais' => %w[skills_optional habilidades_adicionais],
|
||||
'Notas' => %w[notes notas],
|
||||
'Nome de contato' => %w[contact_name],
|
||||
'Telefone de contato' => %w[contact_phone telefone_de_contato],
|
||||
'Correio eletrônico de contato' => %w[contact_email correio_eletronico_de_contato],
|
||||
'ID da rota' => %w[route_id id_da_rota],
|
||||
'ID da conta' => %w[account_id],
|
||||
'Nome da conta' => %w[account_name nome_da_conta],
|
||||
'Nota Fiscal' => %w[nota_fiscal],
|
||||
'Termo de Recebimento' => %w[termo_de_recebimento],
|
||||
'Foto da Fachada' => %w[foto_da_fachada],
|
||||
'Relatório de visita' => %w[relatorio_de_visita visit_report],
|
||||
'Número de Série da Base (Aparelho)' => %w[numero_de_serie_da_base_aparelho num_serie_base numero_serie_base]
|
||||
}.freeze
|
||||
|
||||
# Quadro do RESUMO — linhas fixas do modelo.
|
||||
COORDENADORIAS = ['CRS Sudeste', 'CRS Sul', 'CRS Leste', 'CRS Norte', 'CRS Oeste', 'CRS Centro'].freeze
|
||||
MOTIVOS = ['Óbito', 'Responsável Ausente', 'Endereço não localizado', 'Recusa',
|
||||
'Paciente não reside no local', 'Outros'].freeze
|
||||
GRUPOS = %w[EMAD UBS].freeze
|
||||
|
||||
MESES_PT = %w[JANEIRO FEVEREIRO MARÇO ABRIL MAIO JUNHO JULHO AGOSTO SETEMBRO OUTUBRO NOVEMBRO DEZEMBRO].freeze
|
||||
|
||||
attr_reader :tabela
|
||||
|
||||
def initialize(tabela)
|
||||
@tabela = Operacao.sanitizar([tabela]).first
|
||||
raise ArgumentError, "Operação inválida: #{tabela}" unless @tabela
|
||||
end
|
||||
|
||||
def label
|
||||
Operacao.label(@tabela)
|
||||
end
|
||||
|
||||
# ── Aba ENTREGAS ─────────────────────────────────────────────
|
||||
# Array de hashes: chaves de COLUNAS_GADE + rastreio_status/checkout/observation.
|
||||
# ORDER BY g.ctid preserva a ordem física de importação — a mesma ordem da
|
||||
# planilha original do cliente (tabelas gade são read-only após o import).
|
||||
def linhas
|
||||
@linhas ||= begin
|
||||
gade = conn.quote_table_name(@tabela)
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
SELECT #{selects_gade},
|
||||
r.status AS rastreio_status,
|
||||
r.checkout AS rastreio_checkout,
|
||||
r.observation AS rastreio_observation
|
||||
FROM #{gade} g
|
||||
LEFT JOIN ultimo r ON r.rn = 1 AND r.reference_id::text = g.nota_fiscal
|
||||
ORDER BY g.ctid
|
||||
SQL
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
end
|
||||
|
||||
# ── Aba SimpliRoute ──────────────────────────────────────────
|
||||
# Todas as visitas do rastreio para as NFs da operação (inclui repetidas,
|
||||
# como no dump original do SimpliRoute).
|
||||
def linhas_rastreio
|
||||
gade = conn.quote_table_name(@tabela)
|
||||
sql = <<~SQL
|
||||
SELECT r.*
|
||||
FROM #{conn.quote_table_name(Entrega.table_name)} r
|
||||
WHERE r.reference_id::text IN (SELECT nota_fiscal FROM #{gade} WHERE nota_fiscal IS NOT NULL)
|
||||
ORDER BY r.reference_id, r.checkout NULLS LAST
|
||||
SQL
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# { cabeçalho do modelo => coluna real do rastreio (ou nil) }.
|
||||
def colunas_rastreio
|
||||
existentes = conn.columns(Entrega.table_name).map(&:name)
|
||||
RASTREIO_COLUNAS.transform_values { |cands| cands.find { |c| existentes.include?(c) } }
|
||||
end
|
||||
|
||||
# ── Aba RESUMO ───────────────────────────────────────────────
|
||||
# Agrega as linhas da ENTREGAS por grupo (EMAD/UBS) e coordenadoria:
|
||||
# previsão/entregue/não entregue/pendente + motivos de "Não Entregue".
|
||||
def resumo
|
||||
grupos = GRUPOS.index_with { Hash.new { |h, k| h[k] = novo_bloco } }
|
||||
|
||||
linhas.each do |l|
|
||||
bloco = grupos[grupo_de(l)][coordenadoria_de(l)]
|
||||
bloco[:previsao] += 1
|
||||
status = l['rastreio_status'].to_s
|
||||
if status == 'completed'
|
||||
bloco[:entregue] += 1
|
||||
elsif Entrega::STATUS_FALHA.include?(status)
|
||||
bloco[:nao_entregue] += 1
|
||||
bloco[:motivos][motivo_de(l['rastreio_observation'])] += 1
|
||||
else
|
||||
bloco[:pendente] += 1
|
||||
end
|
||||
end
|
||||
|
||||
total_prev = linhas.size
|
||||
total_real = grupos.values.sum { |coords| coords.values.sum { |b| b[:entregue] } }
|
||||
{
|
||||
mes: "#{MESES_PT[Date.current.month - 1]}/#{Date.current.year}",
|
||||
total_previsto: total_prev,
|
||||
total_realizado: total_real,
|
||||
performance: (total_prev.zero? ? nil : total_real.to_f / total_prev),
|
||||
grupos: grupos
|
||||
}
|
||||
end
|
||||
|
||||
# Bloco zerado do quadro RESUMO (também usado pelo gerador xlsx).
|
||||
def novo_bloco
|
||||
{ previsao: 0, entregue: 0, nao_entregue: 0, pendente: 0, motivos: Hash.new(0) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conn
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
|
||||
# SELECT das colunas whitelisted; ausentes viram NULL com o mesmo alias.
|
||||
def selects_gade
|
||||
existentes = conn.columns(@tabela).map(&:name)
|
||||
COLUNAS_GADE.keys.map do |c|
|
||||
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
# Grupo do quadro (EMAD/UBS): coluna "operacao" da linha; sem ela, deduz do
|
||||
# nome/label da operação. Default UBS (grupo mais comum no modelo).
|
||||
def grupo_de(linha)
|
||||
texto = I18n.transliterate("#{linha['operacao']} #{@tabela} #{label}").upcase
|
||||
texto.include?('EMAD') ? 'EMAD' : 'UBS'
|
||||
end
|
||||
|
||||
# Casa a coordenadoria da linha com as fixas do modelo (sem acento/caixa);
|
||||
# valores fora da lista entram como estão (viram linha extra no quadro).
|
||||
def coordenadoria_de(linha)
|
||||
bruto = linha['coordenadoria'].to_s.strip
|
||||
return COORDENADORIAS.first if bruto.empty? # sem coluna: agrega na 1ª (Sudeste)
|
||||
|
||||
chave = I18n.transliterate(bruto).upcase
|
||||
COORDENADORIAS.find { |c| I18n.transliterate(c).upcase == chave } || bruto
|
||||
end
|
||||
|
||||
# Classifica a observação do insucesso nos motivos fixos do RESUMO.
|
||||
def motivo_de(observacao)
|
||||
o = I18n.transliterate(observacao.to_s).downcase
|
||||
return 'Óbito' if o.include?('obito')
|
||||
return 'Responsável Ausente' if o.include?('ausente')
|
||||
return 'Endereço não localizado' if o.include?('localizado') || o.include?('endereco')
|
||||
return 'Recusa' if o.include?('recus')
|
||||
return 'Paciente não reside no local' if o.include?('reside')
|
||||
'Outros'
|
||||
end
|
||||
end
|
||||
end
|
||||
211
app/services/analytics/planilha_entregas_xlsx.rb
Normal file
@@ -0,0 +1,211 @@
|
||||
# app/services/analytics/planilha_entregas_xlsx.rb
|
||||
#
|
||||
# Gera o binário .xlsx da planilha Entregas do cliente com as 3 abas do modelo
|
||||
# "Entregas SUDESTE MM.AAAA_FINAL.xlsx": RESUMO, ENTREGAS e SimpliRoute.
|
||||
# Os dados vêm de Analytics::PlanilhaEntregas.
|
||||
#
|
||||
# As cores/estilos foram extraídos do próprio arquivo modelo (styles.xml):
|
||||
# ENTREGAS A..T azul 1155CC e U..Z vermelho C00000 (texto branco, negrito);
|
||||
# RESUMO com quadros em 002060/0070C0/C00000/A5A5A5; SimpliRoute sem estilo.
|
||||
# (Os gráficos embutidos do modelo não são replicados.)
|
||||
require 'caxlsx'
|
||||
|
||||
module Analytics
|
||||
class PlanilhaEntregasXlsx
|
||||
def initialize(planilha)
|
||||
@planilha = planilha
|
||||
end
|
||||
|
||||
# String binária do .xlsx.
|
||||
def gerar
|
||||
pkg = Axlsx::Package.new
|
||||
wb = pkg.workbook
|
||||
definir_estilos(wb)
|
||||
|
||||
aba_resumo(wb)
|
||||
aba_entregas(wb)
|
||||
aba_simpliroute(wb)
|
||||
|
||||
pkg.to_stream.read
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def definir_estilos(wb)
|
||||
st = wb.styles
|
||||
borda = { style: :thin, color: 'FF808080', edges: %i[left right top bottom] }
|
||||
centro = { horizontal: :center, vertical: :center }
|
||||
|
||||
# Cabeçalhos/civis do RESUMO
|
||||
@titulo_mes = st.add_style(b: true)
|
||||
@titulo_sec = st.add_style(b: true, sz: 16, u: true)
|
||||
@rotulo_kpi = st.add_style(b: true, sz: 10)
|
||||
@valor_kpi = st.add_style(b: true, sz: 10, border: { style: :thin, color: 'FF000000', edges: [:bottom] })
|
||||
@valor_pct = st.add_style(b: true, sz: 10, format_code: '0.0%',
|
||||
border: { style: :thin, color: 'FF000000', edges: [:bottom] })
|
||||
|
||||
@h_navy = st.add_style(bg_color: '002060', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
|
||||
@h_azul = st.add_style(bg_color: '0070C0', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
|
||||
@h_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true, sz: 11, border: borda, alignment: centro)
|
||||
@h_cinza = st.add_style(bg_color: 'A5A5A5', fg_color: 'FFFFFF', sz: 10, border: borda,
|
||||
alignment: centro.merge(wrap_text: true))
|
||||
@h_motivo = st.add_style(b: true, sz: 10, border: borda, alignment: centro.merge(wrap_text: true))
|
||||
|
||||
@celula = st.add_style(sz: 11, border: borda)
|
||||
@cel_num = st.add_style(sz: 11, border: borda, alignment: centro)
|
||||
|
||||
# Status das Entregas / Conferência Documentos (verde "bom", laranja "em
|
||||
# andamento", azul "a iniciar" — como no modelo)
|
||||
@st_verde = st.add_style(bg_color: 'C6EFCE', fg_color: '006100', b: true, sz: 10, border: borda, alignment: centro)
|
||||
@st_laranja = st.add_style(bg_color: 'FCE4D6', fg_color: 'ED7D31', sz: 10, border: borda, alignment: centro)
|
||||
@st_iniciar = st.add_style(fg_color: '0070C0', sz: 10, border: borda, alignment: centro)
|
||||
|
||||
@perf_rotulo = st.add_style(i: true)
|
||||
@perf_pct = st.add_style(i: true, format_code: '0.0%')
|
||||
|
||||
# Cabeçalho da ENTREGAS
|
||||
@cab_azul = st.add_style(bg_color: '1155CC', fg_color: 'FFFFFF', b: true)
|
||||
@cab_verm = st.add_style(bg_color: 'C00000', fg_color: 'FFFFFF', b: true)
|
||||
end
|
||||
|
||||
# ── RESUMO ───────────────────────────────────────────────────
|
||||
# Mesma disposição do modelo: mês, totais/performance e um quadro por grupo
|
||||
# (EMAD e UBS) com as coordenadorias fixas + motivos de "Não Entregue".
|
||||
# ("Cordenadoria" sem o primeiro "o" é verbatim do modelo.)
|
||||
def aba_resumo(wb)
|
||||
resumo = @planilha.resumo
|
||||
wb.add_worksheet(name: 'RESUMO') do |s|
|
||||
s.add_row []
|
||||
s.add_row ['', resumo[:mes]], style: [nil, @titulo_mes]
|
||||
s.add_row []
|
||||
s.add_row ['', 'Total Previsto:', resumo[:total_previsto]], style: [nil, @rotulo_kpi, @valor_kpi]
|
||||
s.add_row ['', 'Total Realizado:', resumo[:total_realizado]], style: [nil, @rotulo_kpi, @valor_kpi]
|
||||
s.add_row ['', 'Performance:', resumo[:performance] || ''],
|
||||
style: [nil, @rotulo_kpi, (resumo[:performance] ? @valor_pct : @valor_kpi)]
|
||||
|
||||
PlanilhaEntregas::GRUPOS.each { |grupo| quadro_grupo(s, grupo, resumo[:grupos][grupo]) }
|
||||
|
||||
s.column_widths 3, 18, 10, 10, 12, 14, 14, 3, 10, 12, 14, 10, 14, 10
|
||||
end
|
||||
end
|
||||
|
||||
def quadro_grupo(s, grupo, coords)
|
||||
s.add_row []
|
||||
s.add_row ['', grupo], style: [nil, @titulo_sec]
|
||||
|
||||
# "Informações" (F:G) e 'Motivos de "Não Entregue"' (I:N) mescladas
|
||||
s.add_row ['', '', '', '', '', 'Informações', '', '', 'Motivos de "Não Entregue"', '', '', '', '', ''],
|
||||
style: [nil, nil, nil, nil, nil, @h_motivo, @h_motivo, nil, *Array.new(6, @h_motivo)]
|
||||
r = s.rows.size
|
||||
s.merge_cells("F#{r}:G#{r}")
|
||||
s.merge_cells("I#{r}:N#{r}")
|
||||
|
||||
s.add_row ['', 'Cordenadoria', 'Previsão', 'Entregue', 'Não Entregue',
|
||||
'Status das Entregas', 'Conferência Documentos', '',
|
||||
*PlanilhaEntregas::MOTIVOS],
|
||||
style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, @h_cinza, @h_cinza, nil,
|
||||
*Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
|
||||
|
||||
# Coordenadorias fixas do modelo + eventuais extras vindas dos dados.
|
||||
nomes = PlanilhaEntregas::COORDENADORIAS + (coords.keys - PlanilhaEntregas::COORDENADORIAS)
|
||||
total = @planilha.novo_bloco
|
||||
|
||||
nomes.each do |nome|
|
||||
b = coords[nome] || @planilha.novo_bloco
|
||||
total[:previsao] += b[:previsao]
|
||||
total[:entregue] += b[:entregue]
|
||||
total[:nao_entregue] += b[:nao_entregue]
|
||||
total[:pendente] += b[:pendente]
|
||||
b[:motivos].each { |m, q| total[:motivos][m] += q }
|
||||
|
||||
s.add_row ['', nome, b[:previsao], b[:entregue], b[:nao_entregue],
|
||||
(b[:pendente].zero? ? 'Concluído' : 'Em andamento'), 'A iniciar', '',
|
||||
*PlanilhaEntregas::MOTIVOS.map { |m| b[:motivos][m] }],
|
||||
style: [nil, @celula, @cel_num, @cel_num, @cel_num,
|
||||
(b[:pendente].zero? ? @st_verde : @st_laranja), @st_iniciar, nil,
|
||||
*Array.new(PlanilhaEntregas::MOTIVOS.size, @cel_num)]
|
||||
end
|
||||
|
||||
s.add_row ['', 'Total', total[:previsao], total[:entregue], total[:nao_entregue], '', '', '',
|
||||
*PlanilhaEntregas::MOTIVOS.map { |m| total[:motivos][m] }],
|
||||
style: [nil, @h_navy, @h_azul, @h_navy, @h_verm, nil, nil, nil,
|
||||
*Array.new(PlanilhaEntregas::MOTIVOS.size, @h_motivo)]
|
||||
|
||||
if total[:previsao].positive?
|
||||
s.add_row ['', 'Performance %', 1,
|
||||
total[:entregue].to_f / total[:previsao],
|
||||
total[:nao_entregue].to_f / total[:previsao]],
|
||||
style: [nil, @perf_rotulo, @perf_pct, @perf_pct, @perf_pct]
|
||||
else
|
||||
s.add_row ['', 'Performance %', '', '', ''], style: [nil, @perf_rotulo]
|
||||
end
|
||||
end
|
||||
|
||||
# ── ENTREGAS ─────────────────────────────────────────────────
|
||||
# A..V direto da tabela gade (texto); W..Z do rastreio:
|
||||
# W STATUS (completed/failed...), X ENTREGA (Sim/Não), Y DATA OCORRÊNCIA
|
||||
# (data real do checkout), Z OCORRÊNCIA (motivo do insucesso).
|
||||
# Cabeçalho como no modelo: A..T azul, U..Z (resultado) vermelho.
|
||||
def aba_entregas(wb)
|
||||
linhas = @planilha.linhas
|
||||
azuis = PlanilhaEntregas::COLUNAS_GADE.size - 2 # A..T
|
||||
wb.add_worksheet(name: 'ENTREGAS') do |sheet|
|
||||
sheet.add_row(PlanilhaEntregas::CABECALHO,
|
||||
style: Array.new(azuis, @cab_azul) +
|
||||
Array.new(PlanilhaEntregas::CABECALHO.size - azuis, @cab_verm))
|
||||
linhas.each do |l|
|
||||
valores, tipos = linha_entrega(l)
|
||||
sheet.add_row(valores, types: tipos)
|
||||
end
|
||||
sheet.auto_filter = "A1:Z#{linhas.size + 1}"
|
||||
end
|
||||
end
|
||||
|
||||
def linha_entrega(l)
|
||||
status_r = l['rastreio_status'].to_s
|
||||
entregue = if status_r == 'completed'
|
||||
'Sim'
|
||||
elsif Entrega::STATUS_FALHA.include?(status_r)
|
||||
'Não'
|
||||
else
|
||||
''
|
||||
end
|
||||
data_ocorrencia = data_de(l['rastreio_checkout'])
|
||||
|
||||
valores = PlanilhaEntregas::COLUNAS_GADE.keys.map { |c| l[c].to_s } +
|
||||
[status_r, entregue, data_ocorrencia || '', l['rastreio_observation'].to_s]
|
||||
tipos = Array.new(PlanilhaEntregas::COLUNAS_GADE.size + 2, :string) +
|
||||
[(data_ocorrencia ? :date : :string), :string]
|
||||
[valores, tipos]
|
||||
end
|
||||
|
||||
# ── SimpliRoute ──────────────────────────────────────────────
|
||||
# Dump cru do rastreio (todas as visitas das NFs da operação), com o
|
||||
# cabeçalho exato do modelo — sem estilo, como no original; cabeçalho sem
|
||||
# coluna correspondente sai vazio.
|
||||
def aba_simpliroute(wb)
|
||||
colunas = @planilha.colunas_rastreio
|
||||
linhas = @planilha.linhas_rastreio
|
||||
wb.add_worksheet(name: 'SimpliRoute') do |sheet|
|
||||
sheet.add_row(colunas.keys)
|
||||
linhas.each do |r|
|
||||
sheet.add_row(colunas.values.map { |col| col ? texto(r[col]) : '' },
|
||||
types: Array.new(colunas.size, :string))
|
||||
end
|
||||
sheet.auto_filter = "A1:#{Axlsx.col_ref(colunas.size - 1)}#{linhas.size + 1}"
|
||||
end
|
||||
end
|
||||
|
||||
def texto(valor)
|
||||
valor.respond_to?(:strftime) ? valor.strftime('%d/%m/%Y %H:%M') : valor.to_s
|
||||
end
|
||||
|
||||
def data_de(valor)
|
||||
return nil if valor.nil?
|
||||
return valor.to_date if valor.respond_to?(:to_date)
|
||||
Date.parse(valor.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,12 +9,17 @@ class NotificacaoService
|
||||
new(consolidacao).notificar_todos
|
||||
end
|
||||
|
||||
# Avisa UM motorista de que o pagamento dele foi efetuado.
|
||||
def self.notificar_pagamento(consolidacao, consolidacao_motorista)
|
||||
new(consolidacao).enviar_aviso_pagamento(consolidacao_motorista)
|
||||
end
|
||||
|
||||
def initialize(consolidacao)
|
||||
@consolidacao = consolidacao
|
||||
end
|
||||
|
||||
def notificar_todos
|
||||
@consolidacao.consolidacao_motoristas.each do |cm|
|
||||
@consolidacao.consolidacao_motoristas.ativos.each do |cm|
|
||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
next unless user
|
||||
|
||||
@@ -23,6 +28,14 @@ class NotificacaoService
|
||||
end
|
||||
end
|
||||
|
||||
def enviar_aviso_pagamento(cm)
|
||||
user = User.motorista.ativos.find_by('LOWER(nome) = ?', cm.motorista_nome.downcase)
|
||||
return unless user
|
||||
|
||||
enviar_whatsapp_pagamento(user, cm) if whatsapp_ativo?
|
||||
enviar_email_pagamento(user, cm) if email_ativo? && user.email.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def whatsapp_ativo?
|
||||
@@ -34,13 +47,20 @@ class NotificacaoService
|
||||
Configuracao.valor('notificacao_email') == 'true'
|
||||
end
|
||||
|
||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
||||
def moeda(v)
|
||||
inteiro, decimais = format('%.2f', v.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
"R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
|
||||
def mensagem(user, cm)
|
||||
valor = "R$ #{format('%.2f', cm.valor_total)}".gsub('.', ',')
|
||||
valor = moeda(cm.valor_total)
|
||||
"Olá #{user.nome.split.first}! 🚚\n" \
|
||||
"Seu pagamento de entregas foi fechado.\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{valor}\n" \
|
||||
"Acesse seu painel para baixar o holerite: " \
|
||||
"Acesse seu painel para baixar o extrato: " \
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
@@ -65,4 +85,37 @@ class NotificacaoService
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha email #{user.email}: #{e.message}")
|
||||
end
|
||||
|
||||
# ── Aviso de pagamento efetuado ─────────────────────────────
|
||||
def mensagem_pagamento(user, cm)
|
||||
valor = moeda(cm.valor_total)
|
||||
"Olá #{user.nome.split.first}! ✅\n" \
|
||||
"Seu pagamento foi efetuado.\n" \
|
||||
"📋 #{@consolidacao.nome}\n" \
|
||||
"💰 Valor: #{valor}\n" \
|
||||
"Acesse seu painel para conferir: " \
|
||||
"https://#{ENV.fetch('APP_HOST', 'localhost:3000')}/motorista"
|
||||
end
|
||||
|
||||
def enviar_whatsapp_pagamento(user, cm)
|
||||
return if user.telefone.blank?
|
||||
|
||||
require 'twilio-ruby'
|
||||
cliente = Twilio::REST::Client.new(ENV['TWILIO_ACCOUNT_SID'], ENV['TWILIO_AUTH_TOKEN'])
|
||||
cliente.messages.create(
|
||||
from: ENV['TWILIO_WHATSAPP_FROM'],
|
||||
to: "whatsapp:#{user.telefone}",
|
||||
body: mensagem_pagamento(user, cm)
|
||||
)
|
||||
Rails.logger.info("[Notificacao] WhatsApp pagamento enviado para #{user.nome}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha WhatsApp pagamento #{user.nome}: #{e.message}")
|
||||
end
|
||||
|
||||
def enviar_email_pagamento(user, cm)
|
||||
ConsolidacaoMailer.pagamento_efetuado(user, @consolidacao, cm).deliver_later
|
||||
Rails.logger.info("[Notificacao] Email pagamento agendado para #{user.email}")
|
||||
rescue => e
|
||||
Rails.logger.error("[Notificacao] Falha email pagamento #{user.email}: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,8 +9,16 @@ module Pdf
|
||||
PRETO = '0A0A0A'
|
||||
CINZA = '6B7280'
|
||||
|
||||
# Fonte TrueType com suporte total a UTF-8. As fontes embutidas do Prawn
|
||||
# (AFM/Helvetica) só aceitam o charset Windows-1252 e estouram
|
||||
# Prawn::Errors::IncompatibleStringEncoding em caracteres como "✓".
|
||||
FONTS_DIR = Rails.root.join('app', 'assets', 'fonts')
|
||||
|
||||
def initialize
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 60, 40])
|
||||
# Margem inferior folgada (72) para o rodapé repetido (desenhado em y=20)
|
||||
# não sobrepor o conteúdo.
|
||||
@pdf = Prawn::Document.new(page_size: 'A4', margin: [40, 40, 72, 40])
|
||||
registrar_fonte_utf8
|
||||
end
|
||||
|
||||
def render
|
||||
@@ -22,6 +30,24 @@ module Pdf
|
||||
|
||||
private
|
||||
|
||||
# Registra a família DejaVu Sans (TTF) e a define como fonte padrão.
|
||||
# Como os PDFs só usam normal e :bold, mapeamos italic/bold_italic para
|
||||
# as variantes existentes, evitando erro caso :italic seja solicitado.
|
||||
def registrar_fonte_utf8
|
||||
normal = FONTS_DIR.join('DejaVuSans.ttf').to_s
|
||||
bold = FONTS_DIR.join('DejaVuSans-Bold.ttf').to_s
|
||||
|
||||
@pdf.font_families.update(
|
||||
'DejaVu' => {
|
||||
normal: normal,
|
||||
bold: bold,
|
||||
italic: normal,
|
||||
bold_italic: bold
|
||||
}
|
||||
)
|
||||
@pdf.font 'DejaVu'
|
||||
end
|
||||
|
||||
def cabecalho
|
||||
@pdf.fill_color PRETO
|
||||
@pdf.fill_rectangle [-40, @pdf.cursor + 40], @pdf.bounds.width + 80, 70
|
||||
@@ -41,18 +67,60 @@ module Pdf
|
||||
end
|
||||
|
||||
def rodape
|
||||
# canvas usa coordenadas absolutas da página, então o rodapé é desenhado
|
||||
# DENTRO da margem inferior (y=36), abaixo da área de conteúdo. Antes ele
|
||||
# ficava em y=20 do bounds — dentro do fluxo do conteúdo — e o texto da
|
||||
# tabela acabava sobrepondo o "documento interno".
|
||||
@pdf.repeat(:all) do
|
||||
@pdf.bounding_box [0, 20], width: @pdf.bounds.width do
|
||||
@pdf.canvas do
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Gerado em #{Time.current.strftime('%d/%m/%Y %H:%M')} · Reem Transporte — documento interno",
|
||||
size: 8, align: :center
|
||||
@pdf.text_box "Gerado em #{Time.current.strftime('%d/%m/%Y %H:%M')} · Reem Transporte — documento interno",
|
||||
at: [40, 36], width: @pdf.bounds.width - 80,
|
||||
size: 8, align: :center
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Moeda BR com separador de milhar: R$ 1.234,56
|
||||
def moeda(v)
|
||||
"R$ #{format('%.2f', v.to_f)}".gsub('.', ',')
|
||||
inteiro, decimais = format('%.2f', v.to_f).split('.')
|
||||
inteiro = inteiro.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse
|
||||
"R$ #{inteiro},#{decimais}"
|
||||
end
|
||||
|
||||
# Bloco de assinaturas lado a lado: motorista (esquerda) e administrador
|
||||
# (direita). Garante espaço criando nova página se o rodapé estiver perto.
|
||||
def assinaturas(motorista: nil)
|
||||
@pdf.start_new_page if @pdf.cursor < 100
|
||||
@pdf.move_down 36
|
||||
|
||||
metade = (@pdf.bounds.width - 40) / 2.0
|
||||
topo = @pdf.cursor
|
||||
|
||||
@pdf.stroke_color '000000'
|
||||
@pdf.line_width 0.7
|
||||
@pdf.stroke_horizontal_line 0, metade, at: topo
|
||||
@pdf.stroke_horizontal_line metade + 40, @pdf.bounds.width, at: topo
|
||||
|
||||
@pdf.move_down 8
|
||||
linha_nome = @pdf.cursor
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.text_box(motorista.presence || 'Motorista',
|
||||
at: [0, linha_nome], width: metade, size: 9, style: :bold, align: :center)
|
||||
@pdf.text_box('Reem Transporte',
|
||||
at: [metade + 40, linha_nome], width: metade, size: 9, style: :bold, align: :center)
|
||||
|
||||
@pdf.move_down 14
|
||||
linha_rotulo = @pdf.cursor
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text_box('Assinatura do Motorista',
|
||||
at: [0, linha_rotulo], width: metade, size: 8, align: :center)
|
||||
@pdf.text_box('Assinatura do Administrador',
|
||||
at: [metade + 40, linha_rotulo], width: metade, size: 8, align: :center)
|
||||
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.move_down 24
|
||||
end
|
||||
|
||||
def secao(titulo)
|
||||
|
||||
186
app/services/pdf/extrato_pdf.rb
Normal file
@@ -0,0 +1,186 @@
|
||||
# app/services/pdf/extrato_pdf.rb
|
||||
#
|
||||
# Extrato: resumo do valor a pagar ao motorista, estilo contracheque,
|
||||
# com QR Code para login rápido no painel do motorista.
|
||||
#
|
||||
require 'rqrcode'
|
||||
|
||||
module Pdf
|
||||
class ExtratoPdf < BasePdf
|
||||
VERDE = '22C55E'
|
||||
CINZA_BG = 'E5E7EB'
|
||||
|
||||
def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: nil,
|
||||
pago_em: nil, forma_pagamento: nil, nota_fiscal: nil)
|
||||
super()
|
||||
@consolidacao = consolidacao
|
||||
@motorista = motorista
|
||||
@user_motorista = user_motorista
|
||||
@app_host = app_host || ENV.fetch('APP_HOST', 'localhost:3000')
|
||||
@pago_em = pago_em
|
||||
@forma_pagamento = forma_pagamento
|
||||
@nota_fiscal = nota_fiscal
|
||||
@entregas = consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "EXTRATO DE PAGAMENTO — ENTREGAS"
|
||||
|
||||
# Bloco de identificação (estilo contracheque)
|
||||
info = [
|
||||
['Motorista', @motorista],
|
||||
['Consolidação', @consolidacao.nome],
|
||||
['Período', "#{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}"],
|
||||
['Emissão', Time.current.strftime('%d/%m/%Y')]
|
||||
]
|
||||
info << ['Nota Fiscal', @nota_fiscal] if @nota_fiscal.present?
|
||||
@pdf.table(info, width: @pdf.bounds.width, cell_style: { size: 10, padding: [4, 8] }) do |t|
|
||||
t.columns(0).font_style = :bold
|
||||
t.columns(0).background_color = 'F3F4F6'
|
||||
t.columns(0).width = 120
|
||||
end
|
||||
|
||||
desenhar_selo_pagamento
|
||||
|
||||
secao "PROVENTOS E DESCONTOS"
|
||||
|
||||
# `quantidade` (default 1) conta as entregas de termo lançadas em lote;
|
||||
# para os demais pilares equivale ao número de linhas.
|
||||
resumo = @entregas.group(:tipo).sum(:quantidade)
|
||||
# Lista os quatro pilares sempre (Normal, Retirada, Bônus, Desconto),
|
||||
# mesmo zerados, para o motorista ver o descritivo completo.
|
||||
por_tipo = ConsolidacaoEntrega::TIPO_CORES.map do |tipo, cfg|
|
||||
qtd = resumo[tipo] || 0
|
||||
valor = qtd.zero? ? 0 : @entregas.where(tipo: tipo).sum(:valor_aplicado)
|
||||
unit = qtd.zero? ? 0 : (valor / qtd)
|
||||
sinal = tipo == 'desconto' ? '-' : '+'
|
||||
[cfg[:label], qtd.to_s, moeda(unit), "#{sinal} #{moeda(valor)}"]
|
||||
end
|
||||
|
||||
linhas = [['Descrição', 'Qtd', 'Unit.', 'Valor']] + por_tipo
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 10, padding: [4, 8] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(1..3).align = :right
|
||||
end
|
||||
|
||||
total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
|
||||
# Caixa do total — destaque laranja
|
||||
@pdf.move_down 12
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.fill_rounded_rectangle [0, @pdf.cursor], @pdf.bounds.width, 50, 8
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.text_box 'VALOR LÍQUIDO A RECEBER',
|
||||
at: [16, @pdf.cursor - 10], size: 10, style: :bold
|
||||
@pdf.text_box moeda(total),
|
||||
at: [16, @pdf.cursor - 24], size: 22, style: :bold
|
||||
# >= altura da caixa (50) para a próxima linha não a sobrepor.
|
||||
@pdf.move_down 56
|
||||
|
||||
# Detalhamento: total de entregas e veículos atendidos no período
|
||||
total_entregas = @entregas.count
|
||||
veiculos = @entregas.map { |e| e.entrega_original&.vehicle }.compact.uniq
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Total de entregas: #{total_entregas}", size: 9
|
||||
@pdf.text "Veículos atendidos: #{veiculos.any? ? veiculos.join(', ') : '—'}", size: 9
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.move_down 4
|
||||
|
||||
# QR Code de login rápido. O Prawn não pagina imagem sozinho: se faltar
|
||||
# espaço, garantimos uma nova página para o QR não sumir no rodapé.
|
||||
# Motorista sem conta (sem login_token) não tem painel para acessar —
|
||||
# mostramos um aviso no lugar do QR, em vez de omitir a seção.
|
||||
if @user_motorista&.login_token.present?
|
||||
@pdf.start_new_page if @pdf.cursor < 180
|
||||
desenhar_qrcode
|
||||
else
|
||||
@pdf.start_new_page if @pdf.cursor < 120
|
||||
desenhar_aviso_sem_acesso
|
||||
end
|
||||
|
||||
assinaturas(motorista: @motorista)
|
||||
end
|
||||
|
||||
# Selo de pagamento: faixa verde "PAGO" (com data/forma) ou cinza "PENDENTE".
|
||||
def desenhar_selo_pagamento
|
||||
@pdf.move_down 10
|
||||
pago = @pago_em.present?
|
||||
cor = pago ? VERDE : CINZA_BG
|
||||
texto = if pago
|
||||
via = @forma_pagamento.present? ? " · #{@forma_pagamento.to_s.capitalize}" : ''
|
||||
"✓ PAGO em #{@pago_em.strftime('%d/%m/%Y')}#{via}"
|
||||
else
|
||||
"PAGAMENTO PENDENTE"
|
||||
end
|
||||
|
||||
@pdf.fill_color cor
|
||||
@pdf.fill_rounded_rectangle [0, @pdf.cursor], 250, 26, 5
|
||||
@pdf.fill_color(pago ? 'FFFFFF' : '374151')
|
||||
@pdf.text_box texto, at: [12, @pdf.cursor - 8], width: 238, size: 10, style: :bold
|
||||
@pdf.fill_color '000000'
|
||||
# >= altura da caixa (26) para o conteúdo seguinte não a sobrepor.
|
||||
@pdf.move_down 32
|
||||
end
|
||||
|
||||
# Monta a URL respeitando o esquema do APP_HOST. Se vier sem http(s)://
|
||||
# (ex.: "100.75.222.23:3000") assume http — produção em IP:porta normalmente
|
||||
# não tem TLS. Defina APP_HOST com "https://" se o servidor usar HTTPS.
|
||||
def url_acesso
|
||||
raw = @app_host.to_s.strip
|
||||
protocolo = raw.start_with?('https://') ? 'https' : 'http'
|
||||
host = raw.sub(%r{\Ahttps?://}, '').chomp('/')
|
||||
"#{protocolo}://#{host}/motorista/acesso/#{@user_motorista.login_token}"
|
||||
end
|
||||
|
||||
def desenhar_qrcode
|
||||
url = url_acesso
|
||||
qr = RQRCode::QRCode.new(url)
|
||||
png = qr.as_png(size: 220, border_modules: 2)
|
||||
|
||||
arquivo = Tempfile.new(['qr', '.png'])
|
||||
arquivo.binmode
|
||||
arquivo.write(png.to_s)
|
||||
arquivo.rewind
|
||||
|
||||
secao "ACESSO RÁPIDO AO SEU PAINEL"
|
||||
|
||||
qr_lado = 95
|
||||
topo = @pdf.cursor
|
||||
# Posição absoluta (at:) NÃO move o cursor — evita o cálculo frágil de
|
||||
# move_up/move_down que sobrepunha o QR ao texto/assinaturas. Depois
|
||||
# avançamos o cursor manualmente pela altura da imagem (quadrada).
|
||||
@pdf.image arquivo.path, at: [0, topo], width: qr_lado
|
||||
@pdf.text_box "Escaneie este QR Code com a câmera do celular para entrar " \
|
||||
"no seu painel e acompanhar seus pagamentos.\n\n" \
|
||||
"Ou acesse: #{@app_host.to_s.sub(%r{\Ahttps?://}, '').chomp('/')}/motorista\ne use seu PIN de 4 dígitos.",
|
||||
at: [qr_lado + 20, topo], width: @pdf.bounds.width - qr_lado - 20, size: 9
|
||||
@pdf.move_down qr_lado + 8
|
||||
ensure
|
||||
arquivo&.close
|
||||
arquivo&.unlink
|
||||
end
|
||||
|
||||
# Exibido quando o motorista não tem conta de acesso (sem login_token).
|
||||
# Sem conta não há painel para o QR apontar, então avisamos no lugar.
|
||||
def desenhar_aviso_sem_acesso
|
||||
secao "ACESSO AO SEU PAINEL"
|
||||
|
||||
altura = 56
|
||||
topo = @pdf.cursor
|
||||
@pdf.fill_color 'FEF3C7' # amarelo claro (aviso)
|
||||
@pdf.fill_rounded_rectangle [0, topo], @pdf.bounds.width, altura, 6
|
||||
@pdf.fill_color '92400E' # âmbar escuro (texto)
|
||||
@pdf.text_box "Cadastro de acesso ainda não disponível para este motorista.\n" \
|
||||
"Solicite ao administrador para receber seu PIN de 4 dígitos e o " \
|
||||
"QR Code de acesso ao painel do motorista.",
|
||||
at: [12, topo - 12], width: @pdf.bounds.width - 24, size: 10
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.move_down altura + 8
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,97 +0,0 @@
|
||||
# app/services/pdf/holerite_pdf.rb
|
||||
#
|
||||
# Holerite: resumo do valor a pagar ao motorista, estilo contracheque,
|
||||
# com QR Code para login rápido no painel do motorista.
|
||||
#
|
||||
require 'rqrcode'
|
||||
|
||||
module Pdf
|
||||
class HoleritePdf < BasePdf
|
||||
def initialize(consolidacao:, motorista:, user_motorista: nil, app_host: nil)
|
||||
super()
|
||||
@consolidacao = consolidacao
|
||||
@motorista = motorista
|
||||
@user_motorista = user_motorista
|
||||
@app_host = app_host || ENV.fetch('APP_HOST', 'localhost:3000')
|
||||
@entregas = consolidacao.consolidacao_entregas.where(motorista_nome: motorista)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "HOLERITE DE PAGAMENTO — ENTREGAS"
|
||||
|
||||
# Bloco de identificação (estilo contracheque)
|
||||
info = [
|
||||
['Motorista', @motorista],
|
||||
['Consolidação', @consolidacao.nome],
|
||||
['Período', "#{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}"],
|
||||
['Emissão', Time.current.strftime('%d/%m/%Y')]
|
||||
]
|
||||
@pdf.table(info, width: @pdf.bounds.width, cell_style: { size: 10, padding: [5, 8] }) do |t|
|
||||
t.columns(0).font_style = :bold
|
||||
t.columns(0).background_color = 'F3F4F6'
|
||||
t.columns(0).width = 120
|
||||
end
|
||||
|
||||
secao "PROVENTOS E DESCONTOS"
|
||||
|
||||
resumo = @entregas.group(:tipo).count
|
||||
por_tipo = ConsolidacaoEntrega::TIPO_CORES.map do |tipo, cfg|
|
||||
qtd = resumo[tipo] || 0
|
||||
next if qtd.zero?
|
||||
valor = @entregas.where(tipo: tipo).sum(:valor_aplicado)
|
||||
sinal = tipo == 'desconto' ? '-' : '+'
|
||||
[cfg[:label], qtd.to_s, "#{sinal} #{moeda(valor)}"]
|
||||
end.compact
|
||||
|
||||
linhas = [['Descrição', 'Qtd', 'Valor']] + por_tipo
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 10, padding: [6, 8] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(1..2).align = :right
|
||||
end
|
||||
|
||||
total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
|
||||
# Caixa do total — destaque laranja
|
||||
@pdf.move_down 16
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.fill_rounded_rectangle [0, @pdf.cursor], @pdf.bounds.width, 50, 8
|
||||
@pdf.fill_color '000000'
|
||||
@pdf.text_box 'VALOR LÍQUIDO A RECEBER',
|
||||
at: [16, @pdf.cursor - 10], size: 10, style: :bold
|
||||
@pdf.text_box moeda(total),
|
||||
at: [16, @pdf.cursor - 24], size: 22, style: :bold
|
||||
@pdf.move_down 64
|
||||
|
||||
# QR Code de login rápido
|
||||
desenhar_qrcode if @user_motorista&.login_token.present?
|
||||
end
|
||||
|
||||
def desenhar_qrcode
|
||||
url = "https://#{@app_host}/motorista/acesso/#{@user_motorista.login_token}"
|
||||
qr = RQRCode::QRCode.new(url)
|
||||
png = qr.as_png(size: 220, border_modules: 2)
|
||||
|
||||
arquivo = Tempfile.new(['qr', '.png'])
|
||||
arquivo.binmode
|
||||
arquivo.write(png.to_s)
|
||||
arquivo.rewind
|
||||
|
||||
secao "ACESSO RÁPIDO AO SEU PAINEL"
|
||||
@pdf.image arquivo.path, width: 110, position: :left
|
||||
@pdf.move_up 95
|
||||
@pdf.text_box "Escaneie este QR Code com a câmera do celular para entrar\n" \
|
||||
"no seu painel e acompanhar seus pagamentos.\n\n" \
|
||||
"Ou acesse: #{@app_host}/motorista\ne use seu PIN de 4 dígitos.",
|
||||
at: [130, @pdf.cursor], width: @pdf.bounds.width - 130, size: 9
|
||||
@pdf.move_down 100
|
||||
ensure
|
||||
arquivo&.close
|
||||
arquivo&.unlink
|
||||
end
|
||||
end
|
||||
end
|
||||
104
app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
# app/services/pdf/relatorio_financeiro_consolidacao_pdf.rb
|
||||
#
|
||||
# Relatório Financeiro de uma consolidação: resumo por motorista (pago x
|
||||
# pendente), composição por tipo de lançamento e totais. Para o admin imprimir
|
||||
# para conferência e histórico.
|
||||
#
|
||||
module Pdf
|
||||
class RelatorioFinanceiroConsolidacaoPdf < BasePdf
|
||||
VERDE = '22C55E'
|
||||
|
||||
def initialize(consolidacao:)
|
||||
super()
|
||||
@consolidacao = consolidacao
|
||||
@motoristas = consolidacao.consolidacao_motoristas.ativos.order(:motorista_nome)
|
||||
@entregas = consolidacao.consolidacao_entregas
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "RELATÓRIO FINANCEIRO — CONSOLIDAÇÃO"
|
||||
|
||||
@pdf.text "Consolidação: #{@consolidacao.nome}", size: 11, style: :bold
|
||||
@pdf.text "Período: #{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}", size: 10
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Status: #{@consolidacao.status.humanize} · Pagamento: #{@consolidacao.status_pagamento.to_s.humanize}", size: 10
|
||||
@pdf.fill_color '000000'
|
||||
|
||||
secao "POR MOTORISTA (#{@motoristas.size})"
|
||||
tabela_motoristas
|
||||
|
||||
secao "COMPOSIÇÃO POR TIPO"
|
||||
tabela_por_tipo
|
||||
|
||||
@pdf.start_new_page if @pdf.cursor < 140
|
||||
secao "TOTAIS"
|
||||
totais
|
||||
end
|
||||
|
||||
def tabela_motoristas
|
||||
if @motoristas.any?
|
||||
linhas = [['#', 'Motorista', 'Pagamento', 'Forma', 'Valor']]
|
||||
@motoristas.each_with_index do |cm, i|
|
||||
pagamento = cm.pago? ? "Pago em #{cm.pago_em.strftime('%d/%m/%Y')}" : 'Pendente'
|
||||
linhas << [
|
||||
(i + 1).to_s,
|
||||
cm.motorista_nome,
|
||||
pagamento,
|
||||
cm.forma_pagamento&.humanize || '—',
|
||||
moeda(cm.valor_total)
|
||||
]
|
||||
end
|
||||
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
column_widths: { 0 => 22, 4 => 80 },
|
||||
cell_style: { size: 8, padding: [4, 6] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(4).align = :right
|
||||
t.row(1..-1).borders = [:bottom]
|
||||
t.row(1..-1).border_color = 'DDDDDD'
|
||||
end
|
||||
else
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text 'Nenhum motorista nesta consolidação.', size: 10
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
|
||||
def tabela_por_tipo
|
||||
soma = @entregas.reorder(nil).group(:tipo).sum(:valor_aplicado)
|
||||
# `quantidade` (default 1) conta os termos lançados em lote como N entregas.
|
||||
qtd = @entregas.reorder(nil).group(:tipo).sum(:quantidade)
|
||||
|
||||
linhas = [['Tipo', 'Quantidade', 'Valor']]
|
||||
ConsolidacaoEntrega::TIPO_CORES.each do |tipo, cfg|
|
||||
valor = soma[tipo].to_f
|
||||
sinal = tipo == 'desconto' ? '-' : ''
|
||||
linhas << [cfg[:label], (qtd[tipo] || 0).to_s, "#{sinal}#{moeda(valor)}"]
|
||||
end
|
||||
|
||||
@pdf.table(linhas, width: 360, cell_style: { size: 9, padding: [4, 8] }) do |t|
|
||||
t.row(0).background_color = LARANJA
|
||||
t.row(0).text_color = '000000'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(2).align = :right
|
||||
end
|
||||
end
|
||||
|
||||
def totais
|
||||
linhas = [
|
||||
['Custo total', moeda(@consolidacao.valor_total)],
|
||||
['Pago', moeda(@consolidacao.valor_pago)],
|
||||
['A pagar', moeda(@consolidacao.valor_pendente)]
|
||||
]
|
||||
@pdf.table(linhas, width: 300, cell_style: { size: 11, padding: [6, 10] }) do |t|
|
||||
t.columns(0).font_style = :bold
|
||||
t.columns(1).align = :right
|
||||
t.row(1).text_color = VERDE
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
154
app/services/pdf/relatorio_financeiro_periodo_pdf.rb
Normal file
@@ -0,0 +1,154 @@
|
||||
# app/services/pdf/relatorio_financeiro_periodo_pdf.rb
|
||||
#
|
||||
# Relatório Financeiro consolidado de um período: espelha a visão financeira do
|
||||
# dashboard (KPIs, custo por operação/motorista/tipo e pagamentos) num PDF para
|
||||
# o admin imprimir para conferência e histórico. Recebe os agregados já
|
||||
# calculados pelo DashboardController (mesma fonte de números da tela).
|
||||
#
|
||||
module Pdf
|
||||
class RelatorioFinanceiroPeriodoPdf < BasePdf
|
||||
VERDE = '22C55E'
|
||||
|
||||
def initialize(periodo_inicio:, periodo_fim:, operacao_label:, dados:)
|
||||
super()
|
||||
@inicio = periodo_inicio
|
||||
@fim = periodo_fim
|
||||
@op = operacao_label
|
||||
@d = dados
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def corpo
|
||||
secao "RELATÓRIO FINANCEIRO — PERÍODO"
|
||||
|
||||
@pdf.text "Período: #{@inicio.strftime('%d/%m/%Y')} a #{@fim.strftime('%d/%m/%Y')}", size: 11, style: :bold
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Operação: #{@op}", size: 10
|
||||
@pdf.fill_color '000000'
|
||||
|
||||
secao "INDICADORES"
|
||||
kpis
|
||||
|
||||
secao "CUSTO POR OPERAÇÃO"
|
||||
tabela_pares(@d[:por_operacao], 'Operação')
|
||||
|
||||
secao "CUSTO POR MOTORISTA"
|
||||
tabela_pares(@d[:por_motorista], 'Motorista')
|
||||
|
||||
secao "COMPOSIÇÃO POR TIPO"
|
||||
tabela_por_tipo
|
||||
|
||||
@pdf.start_new_page if @pdf.cursor < 160
|
||||
secao "PAGAMENTOS REALIZADOS NO PERÍODO"
|
||||
tabela_pagamentos_feitos
|
||||
|
||||
secao "PAGAMENTOS PENDENTES"
|
||||
tabela_pagamentos_pendentes
|
||||
end
|
||||
|
||||
def kpis
|
||||
linhas = [
|
||||
['Custo total', moeda(@d[:custo_total])],
|
||||
['Ticket médio', moeda(@d[:ticket_medio])],
|
||||
['Pago', moeda(@d[:pago])],
|
||||
['A pagar', moeda(@d[:pendente])]
|
||||
]
|
||||
@pdf.table(linhas, width: 300, cell_style: { size: 11, padding: [6, 10] }) do |t|
|
||||
t.columns(0).font_style = :bold
|
||||
t.columns(1).align = :right
|
||||
t.row(2).text_color = VERDE
|
||||
end
|
||||
end
|
||||
|
||||
# pares: Array de [label, valor]
|
||||
def tabela_pares(pares, titulo_col)
|
||||
if pares.present?
|
||||
linhas = [[titulo_col, 'Valor']]
|
||||
pares.each { |label, valor| linhas << [label.to_s, moeda(valor)] }
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 9, padding: [4, 8] }) do |t|
|
||||
t.row(0).background_color = LARANJA
|
||||
t.row(0).text_color = '000000'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(1).align = :right
|
||||
t.row(1..-1).borders = [:bottom]
|
||||
t.row(1..-1).border_color = 'DDDDDD'
|
||||
end
|
||||
else
|
||||
sem_dados
|
||||
end
|
||||
end
|
||||
|
||||
def tabela_por_tipo
|
||||
if @d[:por_tipo].present?
|
||||
linhas = [['Tipo', 'Valor']]
|
||||
@d[:por_tipo].each { |h| linhas << [h[:label], moeda(h[:valor])] }
|
||||
@pdf.table(linhas, header: true, width: 360, cell_style: { size: 9, padding: [4, 8] }) do |t|
|
||||
t.row(0).background_color = LARANJA
|
||||
t.row(0).text_color = '000000'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(1).align = :right
|
||||
end
|
||||
else
|
||||
sem_dados
|
||||
end
|
||||
end
|
||||
|
||||
def tabela_pagamentos_feitos
|
||||
registros = @d[:pagamentos_feitos]
|
||||
if registros.present?
|
||||
linhas = [['Pago em', 'Motorista', 'Consolidação', 'Forma', 'Valor']]
|
||||
registros.each do |cm|
|
||||
linhas << [
|
||||
cm.pago_em.strftime('%d/%m/%Y'),
|
||||
cm.motorista_nome,
|
||||
cm.consolidacao.nome,
|
||||
cm.forma_pagamento&.humanize || '—',
|
||||
moeda(cm.valor_total)
|
||||
]
|
||||
end
|
||||
tabela_listagem(linhas)
|
||||
@pdf.move_down 6
|
||||
@pdf.fill_color VERDE
|
||||
@pdf.text "Total pago no período: #{moeda(@d[:pag_pago_valor])} · #{@d[:pag_pago_qtd]} pagamento(s)", size: 10, style: :bold
|
||||
@pdf.fill_color '000000'
|
||||
else
|
||||
sem_dados('Nenhum pagamento realizado neste período.')
|
||||
end
|
||||
end
|
||||
|
||||
def tabela_pagamentos_pendentes
|
||||
registros = @d[:pagamentos_pendentes]
|
||||
if registros.present?
|
||||
linhas = [['Motorista', 'Consolidação', 'Valor']]
|
||||
registros.each do |cm|
|
||||
linhas << [cm.motorista_nome, cm.consolidacao.nome, moeda(cm.valor_total)]
|
||||
end
|
||||
tabela_listagem(linhas)
|
||||
@pdf.move_down 6
|
||||
@pdf.text "Total a pagar: #{moeda(@d[:pag_pend_valor])} · #{@d[:pag_pend_qtd]} motorista(s)", size: 10, style: :bold
|
||||
else
|
||||
sem_dados('Nenhuma pendência no período.')
|
||||
end
|
||||
end
|
||||
|
||||
def tabela_listagem(linhas)
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
cell_style: { size: 8, padding: [4, 6] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(-1).align = :right
|
||||
t.row(1..-1).borders = [:bottom]
|
||||
t.row(1..-1).border_color = 'DDDDDD'
|
||||
end
|
||||
end
|
||||
|
||||
def sem_dados(msg = 'Sem dados no período.')
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text msg, size: 10
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -22,39 +22,53 @@ module Pdf
|
||||
@pdf.text "Consolidação: #{@consolidacao.nome}", size: 11, style: :bold
|
||||
@pdf.text "Motorista: #{@motorista}", size: 11
|
||||
@pdf.text "Período: #{@consolidacao.data_inicio.strftime('%d/%m/%Y')} a #{@consolidacao.data_fim.strftime('%d/%m/%Y')}", size: 10
|
||||
@pdf.text "Status: #{@consolidacao.status.humanize}", size: 10, color: CINZA
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text "Status: #{@consolidacao.status.humanize}", size: 10
|
||||
@pdf.fill_color '000000'
|
||||
|
||||
secao "ENTREGAS (#{@entregas.size})"
|
||||
|
||||
if @entregas.any?
|
||||
linhas = [['#', 'Tracking', 'NF / Local', 'Tipo', 'Valor']]
|
||||
linhas = [['#', 'Veículo', 'NF', 'Endereço', 'Tipo', 'Valor']]
|
||||
@entregas.each_with_index do |e, i|
|
||||
original = e.entrega_original
|
||||
linhas << [
|
||||
(i + 1).to_s,
|
||||
e.tracking_id.to_s.first(14),
|
||||
original ? "NF #{original.numero_nf} · #{original.local.to_s.first(30)}" : '—',
|
||||
e.tipo_cor[:label],
|
||||
original&.vehicle.presence || (e.manual? ? 'manual' : '—'),
|
||||
original ? "NF #{original.numero_nf}" : (e.nf_manual.present? ? "NF #{e.nf_manual}" : '—'),
|
||||
original ? original.address.to_s : (e.obs_manual.presence || '—'),
|
||||
e.termo? && e.quantidade > 1 ? "#{e.tipo_cor[:label]} ×#{e.quantidade}" : e.tipo_cor[:label],
|
||||
(e.desconto? ? '-' : '') + moeda(e.valor_aplicado)
|
||||
]
|
||||
end
|
||||
|
||||
# Larguras fixas para as colunas estreitas; a coluna 3 (Endereço) fica
|
||||
# automática e recebe o espaço restante, quebrando em várias linhas em
|
||||
# vez de truncar no meio da palavra.
|
||||
@pdf.table(linhas, header: true, width: @pdf.bounds.width,
|
||||
column_widths: { 0 => 22, 1 => 70, 2 => 60, 4 => 95, 5 => 60 },
|
||||
cell_style: { size: 8, padding: [4, 6] }) do |t|
|
||||
t.row(0).background_color = PRETO
|
||||
t.row(0).text_color = 'FFFFFF'
|
||||
t.row(0).font_style = :bold
|
||||
t.columns(4).align = :right
|
||||
t.columns(5).align = :right
|
||||
t.row(1..-1).borders = [:bottom]
|
||||
t.row(1..-1).border_color = 'DDDDDD'
|
||||
end
|
||||
else
|
||||
@pdf.text 'Nenhuma entrega classificada.', size: 10, color: CINZA
|
||||
@pdf.fill_color CINZA
|
||||
@pdf.text 'Nenhuma entrega classificada.', size: 10
|
||||
@pdf.fill_color '000000'
|
||||
end
|
||||
|
||||
# Totais por tipo
|
||||
# Totais por tipo — mantém RESUMO + Total + Assinaturas juntos, evitando
|
||||
# título órfão no pé da página e sobreposição com o rodapé.
|
||||
@pdf.start_new_page if @pdf.cursor < 200
|
||||
secao "RESUMO"
|
||||
resumo = @entregas.group(:tipo).count
|
||||
# reorder(nil): remove o .order(:created_at) da relação — senão o Postgres
|
||||
# recusa "GROUP BY tipo ORDER BY created_at" (PG::GroupingError → 500).
|
||||
# sum(:quantidade) (default 1) conta os termos lançados em lote como N entregas.
|
||||
resumo = @entregas.reorder(nil).group(:tipo).sum(:quantidade)
|
||||
total = @entregas.sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado }
|
||||
|
||||
resumo_linhas = [['Tipo', 'Quantidade']]
|
||||
@@ -72,6 +86,8 @@ module Pdf
|
||||
@pdf.fill_color LARANJA
|
||||
@pdf.text "TOTAL A PAGAR: #{moeda(total)}", size: 16, style: :bold
|
||||
@pdf.fill_color '000000'
|
||||
|
||||
assinaturas(motorista: @motorista)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
124
app/services/simpli_route/client.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
# app/services/simpli_route/client.rb
|
||||
#
|
||||
# Cliente HTTP da API do SimpliRoute (base de ESCRITA). A tabela de rastreio
|
||||
# local (model Entrega) é só leitura e sincroniza a partir daqui; para corrigir
|
||||
# um lançamento o ADM grava direto por este cliente.
|
||||
#
|
||||
# Usa Net::HTTP (stdlib) — o projeto não tem Faraday/HTTParty.
|
||||
#
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
|
||||
module SimpliRoute
|
||||
class Error < StandardError; end
|
||||
|
||||
# Erro esperado/apresentável ao usuário (NF não achada, ambiguidade, etc.).
|
||||
class NotFound < Error; end
|
||||
|
||||
class Client
|
||||
# Motivos de insucesso são fixos (todos type=failed). Cache curto porque
|
||||
# muda raríssimo, mas evita bater na API a cada abertura de tela.
|
||||
OBSERVATIONS_CACHE_KEY = 'simpliroute/observations'.freeze
|
||||
OBSERVATIONS_TTL = 1.hour
|
||||
|
||||
def initialize(token: SimpliRoute.token, base_url: SimpliRoute.base_url)
|
||||
raise Error, 'SIMPLIROUTE_TOKEN não configurado no ambiente.' if token.blank?
|
||||
|
||||
@token = token
|
||||
@base_uri = URI.parse(base_url)
|
||||
end
|
||||
|
||||
# Lista de motivos [{ 'id' => uuid, 'type' => 'failed', 'label' => 'ÓBITO' }, ...]
|
||||
def observations
|
||||
Rails.cache.fetch(OBSERVATIONS_CACHE_KEY, expires_in: OBSERVATIONS_TTL) do
|
||||
Array(get('/v1/routes/observations/'))
|
||||
end
|
||||
end
|
||||
|
||||
# Visita única (hash) pelo id numérico.
|
||||
def visita(id)
|
||||
get("/v1/routes/visits/#{id}/")
|
||||
end
|
||||
|
||||
# Todas as visitas de uma data (Array de hashes).
|
||||
def visitas_da_data(data)
|
||||
Array(get("/v1/routes/visits/?planned_date=#{data.to_date.iso8601}"))
|
||||
end
|
||||
|
||||
# Resolve o `id` numérico (usado na URL de escrita) a partir de uma Entrega
|
||||
# do espelho local, que só tem tracking_id (SR...) + reference_id (NF) +
|
||||
# planned_date. Casa pelo tracking_id; se não achar, tenta pela NF.
|
||||
# Levanta NotFound (nada) ou Error (ambiguidade sem tracking_id).
|
||||
def resolver_id(entrega)
|
||||
data = entrega.planned_date&.to_date
|
||||
raise NotFound, 'Entrega sem planned_date — impossível localizar na API.' if data.blank?
|
||||
|
||||
visitas = visitas_da_data(data)
|
||||
|
||||
if entrega.tracking_id.present?
|
||||
achada = visitas.find { |v| v['tracking_id'].to_s == entrega.tracking_id.to_s }
|
||||
return achada['id'] if achada
|
||||
end
|
||||
|
||||
nf = entrega.reference_id.to_s
|
||||
por_nf = visitas.select { |v| v['reference'].to_s == nf }
|
||||
raise NotFound, "Visita da NF #{nf} não encontrada na API na data #{data.strftime('%d/%m/%Y')}." if por_nf.empty?
|
||||
if por_nf.size > 1
|
||||
raise Error, "Mais de uma visita para a NF #{nf} em #{data.strftime('%d/%m/%Y')} — não é possível resolver com segurança."
|
||||
end
|
||||
|
||||
por_nf.first['id']
|
||||
end
|
||||
|
||||
# Atualiza campos da visita (PATCH — só o que vier em `attrs`). Retorna a
|
||||
# visita atualizada (hash). Preferimos PATCH ao checkout para não sobrescrever
|
||||
# assinatura/geo originais sem intenção.
|
||||
def atualizar_visita(id, attrs)
|
||||
patch("/v1/routes/visits/#{id}/", attrs)
|
||||
end
|
||||
|
||||
# Histórico da visita (best-effort — a API pode devolver []).
|
||||
def historico(id)
|
||||
Array(get("/v1/routes/visits/#{id}/history/"))
|
||||
rescue Error
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get(path)
|
||||
requisicao(Net::HTTP::Get.new(caminho(path)))
|
||||
end
|
||||
|
||||
def patch(path, body)
|
||||
req = Net::HTTP::Patch.new(caminho(path))
|
||||
req.body = body.to_json
|
||||
requisicao(req)
|
||||
end
|
||||
|
||||
def caminho(path)
|
||||
URI.join(@base_uri.to_s, path)
|
||||
end
|
||||
|
||||
def requisicao(req)
|
||||
req['Authorization'] = "Token #{@token}"
|
||||
req['Content-Type'] = 'application/json'
|
||||
req['Accept'] = 'application/json'
|
||||
|
||||
res = Net::HTTP.start(@base_uri.host, @base_uri.port, use_ssl: @base_uri.scheme == 'https',
|
||||
open_timeout: 10, read_timeout: 30) do |http|
|
||||
http.request(req)
|
||||
end
|
||||
|
||||
unless res.is_a?(Net::HTTPSuccess)
|
||||
raise Error, "SimpliRoute respondeu #{res.code}: #{res.body.to_s.truncate(300)}"
|
||||
end
|
||||
|
||||
res.body.present? ? JSON.parse(res.body) : nil
|
||||
rescue JSON::ParserError => e
|
||||
raise Error, "Resposta inválida do SimpliRoute: #{e.message}"
|
||||
rescue Net::OpenTimeout, Net::ReadTimeout
|
||||
raise Error, 'Tempo esgotado ao falar com o SimpliRoute. Tente de novo.'
|
||||
end
|
||||
end
|
||||
end
|
||||
173
app/services/simpli_route/planilha_carga.rb
Normal file
@@ -0,0 +1,173 @@
|
||||
# app/services/simpli_route/planilha_carga.rb
|
||||
#
|
||||
# Monta as linhas da planilha de carga do SimpliRoute a partir de UMA operação
|
||||
# vigente (tabela gade_entregas_*). Preenche Latitude/Longitude reaproveitando a
|
||||
# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço +
|
||||
# supervisão e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo.
|
||||
#
|
||||
# Tabelas de operação e rastreio são SOMENTE LEITURA (apenas SELECT).
|
||||
module SimpliRoute
|
||||
class PlanilhaCarga
|
||||
# Colunas da tabela de operação usadas (nem toda operação tem todas — as que
|
||||
# faltam viram NULL, no mesmo padrão do OperacaoMetricas#selects_gade).
|
||||
COLUNAS_OP = %w[
|
||||
nota_fiscal nome_completo endereco_sem_complemento complemento supervisao telefones
|
||||
].freeze
|
||||
|
||||
# Colunas que formam a chave de match com o histórico.
|
||||
COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento supervisao].freeze
|
||||
|
||||
# Mês (número) -> grafias aceitas nos nomes de tabela (abreviação e extenso).
|
||||
MESES_GRAFIAS = {
|
||||
1 => %w[jan janeiro], 2 => %w[fev fevereiro], 3 => %w[mar marco março],
|
||||
4 => %w[abr abril], 5 => %w[mai maio], 6 => %w[jun junho], 7 => %w[jul julho],
|
||||
8 => %w[ago agosto], 9 => %w[set setembro], 10 => %w[out outubro],
|
||||
11 => %w[nov novembro], 12 => %w[dez dezembro]
|
||||
}.freeze
|
||||
GRAFIA_PARA_NUM = MESES_GRAFIAS.each_with_object({}) do |(num, grafias), h|
|
||||
grafias.each { |g| h[g] = num }
|
||||
end.freeze
|
||||
|
||||
attr_reader :tabela
|
||||
|
||||
def initialize(tabela)
|
||||
@tabela = tabela.to_s
|
||||
raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela)
|
||||
end
|
||||
|
||||
# Nome amigável da operação (para o nome do arquivo).
|
||||
def label
|
||||
Operacao.label(@tabela)
|
||||
end
|
||||
|
||||
# A tabela do mês anterior foi encontrada? (para avisar quando não houver histórico)
|
||||
def mes_anterior_encontrado?
|
||||
tabela_anterior.present?
|
||||
end
|
||||
|
||||
# Array de hashes no formato das colunas da planilha (A..N).
|
||||
def linhas
|
||||
geo = lookup_geo
|
||||
registros_operacao.map do |r|
|
||||
lat, lng = geo[chave(r)] || [nil, nil]
|
||||
{
|
||||
titulo: "NF #{r['nota_fiscal']} - #{r['nome_completo']}".strip,
|
||||
endereco: r['endereco_sem_complemento'],
|
||||
carga: 1,
|
||||
janela_ini: '08:00',
|
||||
janela_fim: '18:00',
|
||||
tempo: 10,
|
||||
anotacoes: r['complemento'],
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
referencia: r['nota_fiscal'],
|
||||
contato: r['supervisao'],
|
||||
telefone: r['telefones']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conn
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
|
||||
# Linhas cruas da operação vigente.
|
||||
def registros_operacao
|
||||
sql = "SELECT #{selects(@tabela, COLUNAS_OP)} " \
|
||||
"FROM #{conn.quote_table_name(@tabela)} g " \
|
||||
"WHERE g.nota_fiscal IS NOT NULL"
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# SELECT com as colunas existentes; as ausentes viram NULL com o mesmo alias.
|
||||
# As chaves de COLUNAS_* são uma whitelist fixa (sem entrada do usuário no SQL).
|
||||
def selects(tabela, colunas)
|
||||
existentes = conn.columns(tabela).map(&:name)
|
||||
colunas.map do |c|
|
||||
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
# Hash { chave_normalizada => [lat, lng] } vindo do mês anterior.
|
||||
def lookup_geo
|
||||
prev = tabela_anterior
|
||||
return {} if prev.nil?
|
||||
|
||||
# Sem as colunas-chave não dá pra casar com segurança.
|
||||
cols_prev = conn.columns(prev).map(&:name)
|
||||
return {} unless COLUNAS_CHAVE.all? { |c| cols_prev.include?(c) }
|
||||
|
||||
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT reference_id, latitude, longitude,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{rastreio}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
SELECT #{selects(prev, COLUNAS_CHAVE)}, r.latitude AS lat, r.longitude AS lng
|
||||
FROM #{conn.quote_table_name(prev)} g
|
||||
INNER JOIN ultimo r ON r.reference_id::text = g.nota_fiscal AND r.rn = 1
|
||||
WHERE r.latitude IS NOT NULL AND r.longitude IS NOT NULL
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a.each_with_object({}) do |r, memo|
|
||||
lat = to_coord(r['lat'])
|
||||
lng = to_coord(r['lng'])
|
||||
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||
|
||||
memo[chave(r)] = [lat, lng] # o INNER JOIN já ordena pelo checkout mais recente
|
||||
end
|
||||
end
|
||||
|
||||
# Chave de match: nome | endereço | supervisão, normalizados.
|
||||
def chave(row)
|
||||
COLUNAS_CHAVE.map { |c| normaliza(row[c]) }.join('|')
|
||||
end
|
||||
|
||||
def normaliza(valor)
|
||||
valor.to_s.strip.gsub(/\s+/, ' ').upcase
|
||||
end
|
||||
|
||||
# Coordenada como Float (com PONTO). O rastreio pode trazer texto com vírgula
|
||||
# decimal ("-23,62"); coordenadas não usam separador de milhar, então trocar
|
||||
# a vírgula por ponto é seguro e garante a saída numérica (ponto) no .xlsx.
|
||||
def to_coord(valor)
|
||||
Float(valor.to_s.strip.tr(',', '.'))
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
# Nome da tabela do mês imediatamente anterior, da MESMA operação, se existir.
|
||||
def tabela_anterior
|
||||
return @tabela_anterior if defined?(@tabela_anterior)
|
||||
|
||||
@tabela_anterior = calcular_tabela_anterior
|
||||
end
|
||||
|
||||
def calcular_tabela_anterior
|
||||
tokens = @tabela.delete_prefix(Operacao::PREFIXO).split('_')
|
||||
idx = tokens.index { |t| GRAFIA_PARA_NUM.key?(t.downcase) }
|
||||
return nil if idx.nil?
|
||||
|
||||
num = GRAFIA_PARA_NUM[tokens[idx].downcase]
|
||||
tem_ano = tokens[idx + 1].to_s.match?(/\A\d{4}\z/)
|
||||
ano = tem_ano ? tokens[idx + 1].to_i : nil
|
||||
|
||||
prev_num = num == 1 ? 12 : num - 1
|
||||
prev_ano = ano && num == 1 ? ano - 1 : ano
|
||||
|
||||
validas = Operacao.nomes_validos
|
||||
MESES_GRAFIAS[prev_num].each do |grafia|
|
||||
novo = tokens.dup
|
||||
novo[idx] = grafia
|
||||
novo[idx + 1] = prev_ano.to_s if tem_ano
|
||||
candidato = Operacao::PREFIXO + novo.join('_')
|
||||
return candidato if validas.include?(candidato)
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
93
app/services/simpli_route/planilha_carga_xlsx.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
# app/services/simpli_route/planilha_carga_xlsx.rb
|
||||
#
|
||||
# Gera o binário .xlsx da carga do SimpliRoute a partir das linhas montadas pelo
|
||||
# PlanilhaCarga, replicando EXATAMENTE o cabeçalho do arquivo de referência
|
||||
# (Imagens para implantação/pt_br_example.xlsx) — 26 colunas A..Z.
|
||||
require 'caxlsx'
|
||||
|
||||
module SimpliRoute
|
||||
class PlanilhaCargaXlsx
|
||||
# Cabeçalho verbatim do exemplo (inclui os espaços duplos em A e B).
|
||||
CABECALHO = [
|
||||
"Titulo* Solicitado",
|
||||
"endereço completo* Solicitado",
|
||||
"Carga",
|
||||
"Janela de horário inicial",
|
||||
"Janela de horário final",
|
||||
"Tempo de serviço",
|
||||
"Anotações",
|
||||
"Latitude",
|
||||
"Longitude",
|
||||
"Identificação de referência",
|
||||
"Habilidade necessária",
|
||||
"Habilidade opcional",
|
||||
"Pessoa de contato",
|
||||
"Telefone de contato",
|
||||
"Janela de horário inicial 2",
|
||||
"Janela de horário final 2",
|
||||
"Capacidade 2",
|
||||
"Capacidade 3",
|
||||
"Prioridade",
|
||||
"SMS",
|
||||
"Correio eletrônico de contato",
|
||||
"Carga pick",
|
||||
"Carga pick 2",
|
||||
"Carga pick 3",
|
||||
"Data agendada",
|
||||
"Tipo de visita"
|
||||
].freeze
|
||||
|
||||
# Colunas O..Z (14ª em diante) ficam vazias — 12 células.
|
||||
VAZIAS_FINAIS = Array.new(CABECALHO.size - 14, '').freeze
|
||||
|
||||
def initialize(linhas)
|
||||
@linhas = linhas
|
||||
end
|
||||
|
||||
# String binária do .xlsx.
|
||||
def gerar
|
||||
pkg = Axlsx::Package.new
|
||||
pkg.workbook.add_worksheet(name: 'Carga') do |sheet|
|
||||
sheet.add_row(CABECALHO)
|
||||
@linhas.each do |l|
|
||||
valores, tipos = linha(l)
|
||||
sheet.add_row(valores, types: tipos)
|
||||
end
|
||||
end
|
||||
pkg.to_stream.read
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Monta [valores, tipos] de uma linha (A..Z). Lat/long numéricos quando há
|
||||
# match; D/E como texto ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo).
|
||||
def linha(l)
|
||||
lat = l[:latitude]
|
||||
lng = l[:longitude]
|
||||
valores = [
|
||||
l[:titulo].to_s, # A
|
||||
l[:endereco].to_s, # B
|
||||
l[:carga], # C
|
||||
l[:janela_ini], # D
|
||||
l[:janela_fim], # E
|
||||
l[:tempo], # F
|
||||
l[:anotacoes].to_s, # G
|
||||
(lat.nil? ? '' : lat), # H
|
||||
(lng.nil? ? '' : lng), # I
|
||||
l[:referencia].to_s, # J
|
||||
'', '', # K, L
|
||||
l[:contato].to_s, # M
|
||||
l[:telefone].to_s, # N
|
||||
*VAZIAS_FINAIS # O..Z
|
||||
]
|
||||
tipos = [
|
||||
:string, :string, :integer, :string, :string, :integer, :string,
|
||||
(lat.nil? ? :string : :float), # H
|
||||
(lng.nil? ? :string : :float), # I
|
||||
:string, :string, :string, :string, :string,
|
||||
*Array.new(VAZIAS_FINAIS.size, :string)
|
||||
]
|
||||
[valores, tipos]
|
||||
end
|
||||
end
|
||||
end
|
||||
422
app/views/admin/edicao_lancamentos/show.html.erb
Normal file
@@ -0,0 +1,422 @@
|
||||
<%# app/views/admin/edicao_lancamentos/show.html.erb %>
|
||||
<%# Card estilizado com foto da fachada, edição inline (click-to-edit, salva por
|
||||
campo direto na API do SimpliRoute) e histórico duplo (nosso AuditoriaLog +
|
||||
/history/ da API). Os dados do card chegam via fetch (endpoint buscar). %>
|
||||
<div class="space-y-6" id="edicao-app"
|
||||
data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>"
|
||||
data-atualizar-url="<%= atualizar_admin_edicao_lancamento_path %>"
|
||||
data-historico-url="<%= historico_admin_edicao_lancamento_path %>"
|
||||
data-csrf="<%= form_authenticity_token %>"
|
||||
data-motivos="<%= @motivos.to_json %>">
|
||||
|
||||
<%# Header %>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Editar Lançamento</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">Corrige status, motivo e dados de checkout de uma entrega direto no SimpliRoute</p>
|
||||
</div>
|
||||
|
||||
<%# Flash %>
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Aviso de sincronização %>
|
||||
<div class="p-4 bg-amber-900/20 border border-amber-500/40 rounded-xl text-amber-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">⚠️</span>
|
||||
<p>As alterações são gravadas no <strong>SimpliRoute na hora</strong> (cada campo salva ao confirmar ✓),
|
||||
mas o painel e as consolidações só refletem <strong>após a próxima sincronização</strong> da base de
|
||||
rastreio. Toda alteração fica registrada na auditoria.</p>
|
||||
</div>
|
||||
|
||||
<%# Busca por NF %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Buscar entrega pela NF</label>
|
||||
<div class="flex gap-2">
|
||||
<input id="campo-nf" type="text" inputmode="numeric" placeholder="Ex: 79774"
|
||||
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-4 py-3 text-white
|
||||
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<button id="btn-buscar" type="button"
|
||||
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px] whitespace-nowrap">
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
<p id="busca-erro" class="hidden mt-2 text-sm text-red-400"></p>
|
||||
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
|
||||
</div>
|
||||
|
||||
<%# Card do lançamento (preenchido via JS após a busca) %>
|
||||
<div id="card" class="hidden bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
|
||||
|
||||
<%# Header do card %>
|
||||
<div class="p-5 border-b border-white/5 flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 id="c-titulo" class="text-white font-bold text-lg truncate">—</h2>
|
||||
<span id="c-badge" class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-gray-800 text-gray-400">—</span>
|
||||
</div>
|
||||
<p id="c-endereco" class="text-gray-400 text-sm mt-1">—</p>
|
||||
<div class="flex flex-wrap gap-2 mt-2 text-xs text-gray-500">
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5">NF <span id="c-nf">—</span></span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5">Visita <span id="c-id">—</span></span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5">🚚 <span id="c-motorista">—</span></span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5">🚗 <span id="c-veiculo">—</span></span>
|
||||
<span class="px-2 py-0.5 rounded-full bg-white/5">📞 <span id="c-telefone">—</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-historico" type="button"
|
||||
class="px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#252525] border border-white/10 text-gray-200
|
||||
text-sm font-semibold rounded-xl transition-colors whitespace-nowrap">
|
||||
🕑 Histórico
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-0">
|
||||
|
||||
<%# Coluna esquerda — fotos %>
|
||||
<div class="p-5 border-b lg:border-b-0 lg:border-r border-white/5 space-y-3">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Foto da fachada</p>
|
||||
<div id="c-foto-wrap"></div>
|
||||
<div id="c-extras-wrap" class="hidden">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mt-4 mb-2">Fotos do SimpliRoute</p>
|
||||
<div id="c-extras" class="grid grid-cols-3 gap-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Coluna direita — campos click-to-edit %>
|
||||
<div class="lg:col-span-2 p-5">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
Dados do lançamento <span class="normal-case font-normal text-gray-600">— clique num valor para editar</span>
|
||||
</p>
|
||||
<div id="c-campos" class="grid sm:grid-cols-2 gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Modal de histórico %>
|
||||
<div id="modal-hist" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div id="modal-hist-overlay" class="absolute inset-0 bg-black/70"></div>
|
||||
<div class="relative bg-[#151515] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-4 border-b border-white/5">
|
||||
<h3 class="text-white font-bold">🕑 Histórico de alterações</h3>
|
||||
<button id="modal-hist-fechar" type="button"
|
||||
class="w-9 h-9 rounded-lg bg-white/5 hover:bg-white/10 text-gray-300">✕</button>
|
||||
</div>
|
||||
<div id="modal-hist-corpo" class="p-4 overflow-y-auto space-y-5 text-sm">
|
||||
<p class="text-gray-400">Carregando…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const app = document.getElementById('edicao-app');
|
||||
const URLS = {
|
||||
buscar: app.dataset.buscarUrl,
|
||||
atualizar: app.dataset.atualizarUrl,
|
||||
historico: app.dataset.historicoUrl
|
||||
};
|
||||
const CSRF = app.dataset.csrf;
|
||||
const MOTIVOS = JSON.parse(app.dataset.motivos || '[]');
|
||||
|
||||
let visita = null; // estado atual do lançamento carregado
|
||||
|
||||
// ── Config dos campos editáveis ─────────────────────────────
|
||||
const STATUS = {
|
||||
completed: { label: 'Completa', cls: 'bg-green-600 text-white', icone: '✅' },
|
||||
failed: { label: 'Falha', cls: 'bg-red-600 text-white', icone: '❌' },
|
||||
pending: { label: 'Pendente', cls: 'bg-yellow-500 text-black', icone: '🕗' },
|
||||
partial: { label: 'Parcial', cls: 'bg-blue-600 text-white', icone: '◐' },
|
||||
canceled: { label: 'Cancelada', cls: 'bg-gray-700 text-gray-300', icone: '🚫' }
|
||||
};
|
||||
const CAMPOS = [
|
||||
{ campo: 'status', label: 'Status', tipo: 'status' },
|
||||
{ campo: 'checkout_observation', label: 'Motivo (insucesso)', tipo: 'motivo' },
|
||||
{ campo: 'checkout_comment', label: 'Comentário do checkout', tipo: 'textarea', col2: true },
|
||||
{ campo: 'notes', label: 'Observações (notes)', tipo: 'textarea', col2: true },
|
||||
{ campo: 'checkout_time', label: 'Data/hora do checkout', tipo: 'datetime' },
|
||||
{ campo: 'checkout_latitude', label: 'Latitude', tipo: 'texto' },
|
||||
{ campo: 'checkout_longitude', label: 'Longitude', tipo: 'texto' }
|
||||
];
|
||||
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g,
|
||||
m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
|
||||
const motivoLabel = uuid => (MOTIVOS.find(m => m.id === uuid) || {}).label || null;
|
||||
|
||||
// ── Busca por NF ────────────────────────────────────────────
|
||||
const campoNf = document.getElementById('campo-nf');
|
||||
const erroEl = document.getElementById('busca-erro');
|
||||
const loadEl = document.getElementById('busca-loading');
|
||||
const cardEl = document.getElementById('card');
|
||||
|
||||
async function buscar() {
|
||||
const nf = campoNf.value.trim();
|
||||
erroEl.classList.add('hidden');
|
||||
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
|
||||
|
||||
loadEl.classList.remove('hidden');
|
||||
cardEl.classList.add('hidden');
|
||||
try {
|
||||
const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
|
||||
const data = await resp.json();
|
||||
if (!data.ok) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
|
||||
visita = data.visita;
|
||||
renderCard();
|
||||
cardEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
mostrarErroBusca('Erro de conexão ao buscar a NF.');
|
||||
} finally {
|
||||
loadEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
|
||||
|
||||
document.getElementById('btn-buscar').addEventListener('click', buscar);
|
||||
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
||||
|
||||
// ── Card ────────────────────────────────────────────────────
|
||||
function renderCard() {
|
||||
const v = visita;
|
||||
document.getElementById('c-titulo').textContent = v.titulo || '—';
|
||||
document.getElementById('c-endereco').textContent = v.endereco || '—';
|
||||
document.getElementById('c-nf').textContent = v.nf ?? '—';
|
||||
document.getElementById('c-id').textContent = v.id ?? '—';
|
||||
document.getElementById('c-motorista').textContent = v.motorista || '—';
|
||||
document.getElementById('c-veiculo').textContent = v.veiculo || '—';
|
||||
document.getElementById('c-telefone').textContent = v.telefone || '—';
|
||||
renderBadge();
|
||||
renderFotos();
|
||||
renderCampos();
|
||||
}
|
||||
|
||||
function renderBadge() {
|
||||
const cfg = STATUS[visita.status] || { label: visita.status || '—', cls: 'bg-gray-800 text-gray-400', icone: '❓' };
|
||||
const b = document.getElementById('c-badge');
|
||||
b.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold ' + cfg.cls;
|
||||
b.textContent = cfg.icone + ' ' + cfg.label;
|
||||
}
|
||||
|
||||
function renderFotos() {
|
||||
const wrap = document.getElementById('c-foto-wrap');
|
||||
if (visita.foto_fachada) {
|
||||
wrap.innerHTML =
|
||||
'<a href="' + esc(visita.foto_fachada) + '" target="_blank" rel="noopener" title="Abrir em tamanho real">' +
|
||||
'<img src="' + esc(visita.foto_fachada) + '" alt="Fachada" loading="lazy" ' +
|
||||
'class="w-full h-52 object-cover rounded-xl border border-white/10 hover:opacity-90 transition-opacity">' +
|
||||
'</a>';
|
||||
} else {
|
||||
wrap.innerHTML =
|
||||
'<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' +
|
||||
'text-gray-600 text-sm">📷 Sem foto da fachada para esta entrega</div>';
|
||||
}
|
||||
|
||||
const extras = [...(visita.pictures || [])];
|
||||
if (visita.signature) extras.push(visita.signature);
|
||||
const extrasWrap = document.getElementById('c-extras-wrap');
|
||||
const grid = document.getElementById('c-extras');
|
||||
if (extras.length) {
|
||||
grid.innerHTML = extras.map(u =>
|
||||
'<a href="' + esc(u) + '" target="_blank" rel="noopener">' +
|
||||
'<img src="' + esc(u) + '" loading="lazy" class="w-full h-16 object-cover rounded-lg border border-white/10 hover:opacity-90">' +
|
||||
'</a>').join('');
|
||||
extrasWrap.classList.remove('hidden');
|
||||
} else {
|
||||
extrasWrap.classList.add('hidden');
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Campos click-to-edit ────────────────────────────────────
|
||||
function valorExibicao(def) {
|
||||
const v = visita[def.campo];
|
||||
if (def.tipo === 'status') return (STATUS[v] || {}).label || v || '—';
|
||||
if (def.tipo === 'motivo') return v ? (motivoLabel(v) || v) : '— sem motivo —';
|
||||
if (def.tipo === 'datetime') {
|
||||
if (!v) return '—';
|
||||
const s = String(v);
|
||||
return s.slice(8, 10) + '/' + s.slice(5, 7) + '/' + s.slice(0, 4) + ' ' + s.slice(11, 16);
|
||||
}
|
||||
return (v === null || v === undefined || v === '') ? '—' : String(v);
|
||||
}
|
||||
|
||||
function renderCampos() {
|
||||
const grid = document.getElementById('c-campos');
|
||||
grid.innerHTML = '';
|
||||
CAMPOS.forEach(def => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'campo group bg-[#1a1a1a] border border-white/5 rounded-xl px-4 py-3 cursor-pointer ' +
|
||||
'hover:border-orange-500/50 transition-colors' + (def.col2 ? ' sm:col-span-2' : '');
|
||||
div.dataset.campo = def.campo;
|
||||
pintarExibicao(div, def);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function pintarExibicao(div, def, flash) {
|
||||
div.innerHTML =
|
||||
'<p class="text-xs text-gray-500 mb-0.5">' + esc(def.label) + '</p>' +
|
||||
'<div class="flex items-start justify-between gap-2">' +
|
||||
'<p class="texto-valor text-sm text-white break-words min-h-[20px]">' + esc(valorExibicao(def)) + '</p>' +
|
||||
'<span class="text-gray-600 group-hover:text-orange-400 text-sm" title="Editar">✏️</span>' +
|
||||
'</div>';
|
||||
div.onclick = () => abrirEdicao(div, def);
|
||||
if (flash) {
|
||||
div.classList.add('ring-2', 'ring-green-500/70');
|
||||
setTimeout(() => div.classList.remove('ring-2', 'ring-green-500/70'), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function abrirEdicao(div, def) {
|
||||
div.onclick = null;
|
||||
div.classList.remove('cursor-pointer');
|
||||
const atual = visita[def.campo];
|
||||
|
||||
let editorHtml;
|
||||
const base = 'w-full bg-[#111] border border-orange-500/60 rounded-lg px-2.5 py-2 text-sm text-white focus:outline-none';
|
||||
if (def.tipo === 'status') {
|
||||
editorHtml = '<select class="ed ' + base + '">' +
|
||||
Object.entries(STATUS).map(([v, c]) =>
|
||||
'<option value="' + v + '"' + (v === atual ? ' selected' : '') + '>' + c.label + '</option>').join('') +
|
||||
'</select>';
|
||||
} else if (def.tipo === 'motivo') {
|
||||
editorHtml = '<select class="ed ' + base + '">' +
|
||||
'<option value="">— sem motivo —</option>' +
|
||||
MOTIVOS.map(m =>
|
||||
'<option value="' + esc(m.id) + '"' + (m.id === atual ? ' selected' : '') + '>' + esc(m.label) + '</option>').join('') +
|
||||
'</select>';
|
||||
} else if (def.tipo === 'textarea') {
|
||||
editorHtml = '<textarea rows="2" class="ed ' + base + '">' + esc(atual ?? '') + '</textarea>';
|
||||
} else if (def.tipo === 'datetime') {
|
||||
const valor = atual ? String(atual).slice(0, 16) : '';
|
||||
editorHtml = '<input type="datetime-local" value="' + esc(valor) + '" class="ed ' + base + '">';
|
||||
} else {
|
||||
editorHtml = '<input type="text" value="' + esc(atual ?? '') + '" class="ed ' + base + '">';
|
||||
}
|
||||
|
||||
div.innerHTML =
|
||||
'<p class="text-xs text-orange-400 mb-1">' + esc(def.label) + '</p>' +
|
||||
editorHtml +
|
||||
'<div class="flex items-center gap-2 mt-2">' +
|
||||
'<button type="button" class="btn-ok px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg">✓ Salvar</button>' +
|
||||
'<button type="button" class="btn-cancelar px-3 py-1.5 bg-white/5 hover:bg-white/10 text-gray-300 text-xs rounded-lg">✕ Cancelar</button>' +
|
||||
'<span class="msg-salvando hidden text-xs text-gray-400">Salvando…</span>' +
|
||||
'</div>' +
|
||||
'<p class="msg-erro hidden mt-1.5 text-xs text-red-400"></p>';
|
||||
|
||||
const ed = div.querySelector('.ed');
|
||||
ed.focus();
|
||||
|
||||
const cancelar = () => pintarExibicao(div, def) || div.classList.add('cursor-pointer');
|
||||
div.querySelector('.btn-cancelar').onclick = e => { e.stopPropagation(); cancelar(); };
|
||||
div.querySelector('.btn-ok').onclick = e => { e.stopPropagation(); salvar(div, def, ed.value); };
|
||||
ed.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') cancelar();
|
||||
if (e.key === 'Enter' && def.tipo !== 'textarea') { e.preventDefault(); salvar(div, def, ed.value); }
|
||||
});
|
||||
}
|
||||
|
||||
async function salvar(div, def, valor) {
|
||||
const msgErro = div.querySelector('.msg-erro');
|
||||
const msgSalvando = div.querySelector('.msg-salvando');
|
||||
msgErro.classList.add('hidden');
|
||||
msgSalvando.classList.remove('hidden');
|
||||
try {
|
||||
const body = { visit_id: visita.id };
|
||||
body[def.campo] = valor;
|
||||
const resp = await fetch(URLS.atualizar, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': CSRF
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
msgErro.textContent = data.erro || 'Erro ao salvar.';
|
||||
msgErro.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
// Atualiza o estado local com o que a API confirmou (ou mantém o atual
|
||||
// quando o valor não mudou — campos: []).
|
||||
if (data.campos && data.campos.length) Object.assign(visita, data.visita);
|
||||
pintarExibicao(div, def, data.campos && data.campos.length > 0);
|
||||
div.classList.add('cursor-pointer');
|
||||
if (def.campo === 'status') renderBadge();
|
||||
} catch (e) {
|
||||
msgErro.textContent = 'Erro de conexão ao salvar.';
|
||||
msgErro.classList.remove('hidden');
|
||||
} finally {
|
||||
msgSalvando.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Histórico (modal) ───────────────────────────────────────
|
||||
const modal = document.getElementById('modal-hist');
|
||||
const corpo = document.getElementById('modal-hist-corpo');
|
||||
|
||||
function fecharModal() { modal.classList.add('hidden'); }
|
||||
document.getElementById('modal-hist-fechar').onclick = fecharModal;
|
||||
document.getElementById('modal-hist-overlay').onclick = fecharModal;
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') fecharModal(); });
|
||||
|
||||
document.getElementById('btn-historico').addEventListener('click', async () => {
|
||||
if (!visita) return;
|
||||
modal.classList.remove('hidden');
|
||||
corpo.innerHTML = '<p class="text-gray-400">Carregando…</p>';
|
||||
try {
|
||||
const resp = await fetch(URLS.historico + '?visit_id=' + encodeURIComponent(visita.id),
|
||||
{ headers: { 'Accept': 'application/json' } });
|
||||
const data = await resp.json();
|
||||
corpo.innerHTML = htmlHistorico(data);
|
||||
} catch (e) {
|
||||
corpo.innerHTML = '<p class="text-red-400">Erro ao carregar o histórico.</p>';
|
||||
}
|
||||
});
|
||||
|
||||
function nomeCampo(campo) {
|
||||
const def = CAMPOS.find(c => c.campo === campo);
|
||||
return def ? def.label : campo;
|
||||
}
|
||||
function fmtValor(campo, valor) {
|
||||
if (valor === null || valor === undefined || valor === '') return '—';
|
||||
if (campo === 'checkout_observation') return motivoLabel(valor) || valor;
|
||||
if (campo === 'status') return (STATUS[valor] || {}).label || valor;
|
||||
return String(valor);
|
||||
}
|
||||
|
||||
function htmlHistorico(data) {
|
||||
let html = '<div>' +
|
||||
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">📒 Nosso sistema (auditoria)</p>';
|
||||
const interno = (data.interno || []);
|
||||
if (!interno.length) {
|
||||
html += '<p class="text-gray-500">Nenhuma alteração registrada pelo sistema.</p>';
|
||||
} else {
|
||||
html += interno.map(l => {
|
||||
const mudancas = Object.keys(l.para || {}).map(campo =>
|
||||
'<li class="text-gray-300"><span class="text-gray-500">' + esc(nomeCampo(campo)) + ':</span> ' +
|
||||
'<span class="text-red-300/80 line-through">' + esc(fmtValor(campo, (l.de || {})[campo])) + '</span>' +
|
||||
' → <span class="text-green-300">' + esc(fmtValor(campo, l.para[campo])) + '</span></li>').join('');
|
||||
return '<div class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 mb-2">' +
|
||||
'<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">' +
|
||||
'<span>👤 ' + esc(l.usuario) + '</span><span>' + esc(l.quando) + '</span>' +
|
||||
'</div>' +
|
||||
'<ul class="space-y-0.5 text-xs">' + (mudancas || '<li class="text-gray-500">—</li>') + '</ul>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
html += '</div><div>' +
|
||||
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">🌐 SimpliRoute (API)</p>';
|
||||
const api = (data.api || []);
|
||||
if (!api.length) {
|
||||
html += '<p class="text-gray-500">Sem registros de histórico na API do SimpliRoute.</p>';
|
||||
} else {
|
||||
html += '<pre class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 text-xs text-gray-300 overflow-x-auto">' +
|
||||
esc(JSON.stringify(api, null, 2)) + '</pre>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
51
app/views/admin/planilhas_simpli_route/show.html.erb
Normal file
@@ -0,0 +1,51 @@
|
||||
<%# app/views/admin/planilhas_simpliroute/show.html.erb %>
|
||||
<div class="space-y-6">
|
||||
|
||||
<%# Header %>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Planilha SimpliRoute</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">Gera a planilha de carga (.xlsx) de uma operação vigente, já com lat/long do mês anterior</p>
|
||||
</div>
|
||||
|
||||
<%# Flash %>
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Explicação %>
|
||||
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">ℹ️</span>
|
||||
<p>A <strong>Latitude/Longitude</strong> é reaproveitada do <strong>mês anterior</strong> da mesma
|
||||
operação (casando nome + endereço + supervisão). Pacientes <strong>novos</strong> ou sem
|
||||
correspondência saem <strong>sem</strong> lat/long — o SimpliRoute geocodifica na importação.
|
||||
Se a operação não tiver mês anterior, a planilha sai inteira sem lat/long.</p>
|
||||
</div>
|
||||
|
||||
<%# Seleção + download %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
|
||||
<% if @operacoes_agrupadas.blank? %>
|
||||
<p class="text-gray-400 text-sm">Nenhuma operação encontrada.</p>
|
||||
<% else %>
|
||||
<%= form_with url: baixar_admin_planilha_simpli_route_path, method: :get, data: { turbo: false },
|
||||
class: 'flex flex-col sm:flex-row sm:items-end gap-3' do %>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Operação vigente</label>
|
||||
<select name="tabela" required
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>"><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px] whitespace-nowrap">
|
||||
📥 Baixar planilha
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,20 +134,21 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const roleSelect = document.querySelector('select[name="user[role]"]');
|
||||
const fieldsEmail = document.getElementById('fields-email');
|
||||
const fieldsPin = document.getElementById('fields-pin');
|
||||
// IIFE: evita "Identifier already declared" quando o Turbo re-executa o script
|
||||
// ao navegar (o que antes abortava o toggle e escondia o campo de PIN).
|
||||
(function () {
|
||||
const roleSelect = document.querySelector('select[name="user[role]"]');
|
||||
const fieldsEmail = document.getElementById('fields-email');
|
||||
const fieldsPin = document.getElementById('fields-pin');
|
||||
if (!roleSelect || !fieldsEmail || !fieldsPin) return;
|
||||
|
||||
function toggleFields() {
|
||||
if (roleSelect.value === 'motorista') {
|
||||
fieldsEmail.classList.add('hidden');
|
||||
fieldsPin.classList.remove('hidden');
|
||||
} else {
|
||||
fieldsEmail.classList.remove('hidden');
|
||||
fieldsPin.classList.add('hidden');
|
||||
function toggleFields() {
|
||||
const motorista = roleSelect.value === 'motorista';
|
||||
fieldsEmail.classList.toggle('hidden', motorista);
|
||||
fieldsPin.classList.toggle('hidden', !motorista);
|
||||
}
|
||||
}
|
||||
|
||||
roleSelect.addEventListener('change', toggleFields);
|
||||
toggleFields(); // run on load
|
||||
roleSelect.addEventListener('change', toggleFields);
|
||||
toggleFields(); // estado inicial
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -73,10 +73,10 @@
|
||||
<% end %>
|
||||
|
||||
<% if policy(usuario).toggle_ativo? %>
|
||||
<%= button_to toggle_ativo_usuario_path(usuario), method: :post,
|
||||
<%= button_to toggle_ativo_admin_usuario_path(usuario), method: :patch,
|
||||
class: "p-2 rounded-lg transition-colors #{usuario.ativo? ? 'text-green-400 hover:bg-green-900/30' : 'text-red-400 hover:bg-red-900/30'}",
|
||||
title: usuario.ativo? ? 'Desativar' : 'Ativar',
|
||||
data: { confirm: "#{usuario.ativo? ? 'Desativar' : 'Ativar'} #{usuario.nome}?" } do %>
|
||||
form: { data: { turbo_confirm: "#{usuario.ativo? ? 'Desativar' : 'Ativar'} #{usuario.nome}?" } } do %>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
@@ -88,7 +88,7 @@
|
||||
<%= button_to admin_usuario_path(usuario), method: :delete,
|
||||
class: 'p-2 text-gray-600 hover:text-red-400 hover:bg-red-900/20 rounded-lg transition-colors',
|
||||
title: 'Excluir',
|
||||
data: { confirm: "Excluir #{usuario.nome} permanentemente?" } do %>
|
||||
form: { data: { turbo_confirm: "Excluir #{usuario.nome} permanentemente?" } } do %>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
|
||||
@@ -6,18 +6,20 @@
|
||||
<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' %>
|
||||
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||
<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">
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 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']
|
||||
['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'],
|
||||
['extraordinaria', 'Extraordinária', 'bg-purple-600 text-white'],
|
||||
['termo', 'Termo', 'bg-blue-600 text-white']
|
||||
].each do |tipo, label, cores| %>
|
||||
<div class="<%= cores %> rounded-xl p-4 text-center">
|
||||
<p class="font-black text-3xl"><%= @resumo[tipo] || 0 %></p>
|
||||
@@ -30,30 +32,59 @@
|
||||
<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>
|
||||
<p class="text-gray-500 text-xs mt-1"><%= @total_classificadas %> entregas classificadas (<%= @total_pilares %> pilares)</p>
|
||||
</div>
|
||||
|
||||
<%# Seletor de itens por página %>
|
||||
<form method="get" action="<%= revisar_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||
class="flex items-center justify-end gap-2 mb-2">
|
||||
<input type="hidden" name="motorista" value="<%= @motorista %>">
|
||||
<label class="text-gray-400 text-sm">Itens por página</label>
|
||||
<select name="por_pagina" onchange="this.form.submit()"
|
||||
class="bg-[#1a1a1a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2 text-sm focus:border-orange-500 focus:outline-none cursor-pointer">
|
||||
<% [20, 50, 100].each do |n| %>
|
||||
<option value="<%= n %>" <%= 'selected' if @por_pagina == n %>><%= n %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<%# Detalhe das classificações %>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden mb-6">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden mb-4">
|
||||
<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">Veículo</th>
|
||||
<th class="text-left px-4 py-3">Endereço</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| %>
|
||||
<% endereco_manual = [c.nf_manual.present? ? "NF #{c.nf_manual}" : nil, c.obs_manual.presence].compact.join(' · ') %>
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-white font-mono text-xs"><%= c.tracking_id %></td>
|
||||
<td class="px-4 py-3 text-white text-xs"><%= c.entrega_original&.vehicle.presence || (c.manual? ? 'manual' : '—') %></td>
|
||||
<td class="px-4 py-3 text-gray-300 text-xs"><%= c.entrega_original&.address.presence || endereco_manual.presence || '—' %></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] %>
|
||||
<%= c.tipo_cor[:label] %><% if c.termo? && c.quantidade > 1 %> ×<%= c.quantidade %><% end %>
|
||||
</span>
|
||||
<% if c.manual? %>
|
||||
<span class="ml-1 bg-orange-500/15 text-orange-400 border border-orange-500/40 px-2 py-1 rounded text-xs font-bold">➕ Apontamento</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-bold <%= c.desconto? ? 'text-red-400' : 'text-white' %>">
|
||||
<%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<span><%= c.desconto? ? '-' : '' %><%= moeda(c.valor_aplicado) %></span>
|
||||
<% if c.manual? && !(@consolidacao.finalizada? && current_user.operador?) %>
|
||||
<%= button_to '✕',
|
||||
remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, id: c.id, motorista: @motorista),
|
||||
method: :delete,
|
||||
form: { data: { turbo_confirm: 'Remover este apontamento manual?' } },
|
||||
class: 'text-gray-500 hover:text-red-400 text-sm leading-none px-1',
|
||||
title: 'Remover apontamento' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -61,6 +92,37 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<%# Navegação de páginas %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<% pag = ->(p) { revisar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, por_pagina: @por_pagina, page: p) } %>
|
||||
<nav class="flex items-center justify-center gap-1.5 mb-6 flex-wrap">
|
||||
<% if @pagy.prev %>
|
||||
<%= link_to '‹', pag.call(@pagy.prev),
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% else %>
|
||||
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-gray-700">‹</span>
|
||||
<% end %>
|
||||
|
||||
<% @pagy.series.each do |item| %>
|
||||
<% if item == :gap %>
|
||||
<span class="px-2 text-gray-500">…</span>
|
||||
<% elsif item.is_a?(String) %>
|
||||
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg bg-orange-500 text-black font-bold"><%= item %></span>
|
||||
<% else %>
|
||||
<%= link_to item, pag.call(item),
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @pagy.next %>
|
||||
<%= link_to '›', pag.call(@pagy.next),
|
||||
class: 'min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-white hover:border-orange-500' %>
|
||||
<% else %>
|
||||
<span class="min-w-[40px] min-h-[40px] flex items-center justify-center rounded-lg border border-[#2a2a2a] text-gray-700">›</span>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% end %>
|
||||
|
||||
<%# Ações finais %>
|
||||
<div class="flex flex-col md:flex-row gap-3">
|
||||
<% if @proximo %>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<%# 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"
|
||||
<div class="max-w-6xl mx-auto lg:h-[calc(100vh-4rem)] lg:flex lg:flex-col" 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) }) %>">
|
||||
data-validacao-total-value="<%= @total_entregas %>"
|
||||
data-validacao-veiculos-value="<%= @veiculos_sel.to_json %>"
|
||||
data-validacao-preco-extraordinaria-value="<%= @precos[:extraordinaria] %>">
|
||||
|
||||
<%# Cabeçalho + controles fixos no topo (no desktop); só a lista de NFs rola. %>
|
||||
<div class="lg:shrink-0">
|
||||
|
||||
<%# 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="mb-3">
|
||||
<%= link_to '← Voltar (Passo 1)', wizard_consolidacao_path(@consolidacao),
|
||||
class: 'inline-flex items-center gap-1 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-[#2a2a2a] hover:border-orange-500 font-semibold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">🚚 <%= @motorista %></h1>
|
||||
@@ -21,31 +27,123 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Barra de progresso %>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-4">
|
||||
<%# Barra de progresso (reflete o veículo selecionado, se houver) %>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
||||
<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
|
||||
<% if @veiculos_sel.any? %>🚚 <%= @veiculos_sel.join(', ') %> — <% end %>
|
||||
<span data-validacao-target="contadorClassificadas"><%= @classificadas %></span>
|
||||
de <span data-validacao-target="contadorTotal"><%= @total_entregas %></span> entregas classificadas
|
||||
<span data-validacao-target="manuaisInfo" class="<%= 'hidden' unless @manuais.positive? %> text-blue-400 font-normal">·
|
||||
+<span data-validacao-target="contadorManuais"><%= @manuais %></span> manuais</span>
|
||||
</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>
|
||||
<%# Barra com 2 segmentos: laranja/verde = elegíveis classificadas · azul = lançamentos manuais %>
|
||||
<div class="w-full bg-[#0a0a0a] rounded-full h-3 flex overflow-hidden">
|
||||
<div data-validacao-target="barra" class="h-3 bg-orange-500 transition-all duration-300" style="width: 0%"></div>
|
||||
<div data-validacao-target="barraManuais" class="h-3 bg-blue-500 transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Fechar por veículo — filtra a tela por carro, mostra o estado (pronto/
|
||||
consolidado/arquivado) e permite CONSOLIDAR o veículo selecionado.
|
||||
Só aparece quando o motorista usou mais de um veículo no período. %>
|
||||
<% pode_fechar_veiculo = !(@consolidacao.finalizada? && current_user.operador?) %>
|
||||
<% if @veiculos_status.size > 1 %>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="text-gray-400 text-sm font-semibold">🚗 Fechar por veículo:</span>
|
||||
<span class="text-gray-600 text-xs">clique no nome para filtrar; consolide o carro quando estiver 100% (🔒)</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%# Chip "Todos" — limpa a seleção (mostra todos os carros) %>
|
||||
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista),
|
||||
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center
|
||||
#{@veiculos_sel.empty? ? 'border-orange-500 text-orange-400 bg-orange-500/10' : 'border-[#2a2a2a] text-gray-300 hover:border-orange-500'}" do %>
|
||||
Todos
|
||||
<% end %>
|
||||
|
||||
<% @veiculos_status.each do |v| %>
|
||||
<% selecionado = @veiculos_sel.include?(v[:vehicle]) %>
|
||||
<%# Alterna este veículo dentro da seleção atual (multi-seleção) %>
|
||||
<% nova_sel = selecionado ? (@veiculos_sel - [v[:vehicle]]) : (@veiculos_sel + [v[:vehicle]]) %>
|
||||
<%# Cor determinística: selecionado(laranja) > consolidado(verde+🔒) >
|
||||
pronto(verde) > arquivado(cinza tracejado) > neutro. %>
|
||||
<% cor = if selecionado then 'border-orange-500 text-orange-400 bg-orange-500/10'
|
||||
elsif v[:fechado] then 'border-green-500 text-green-400 bg-green-500/10'
|
||||
elsif v[:completo] then 'border-green-600/50 text-green-400'
|
||||
elsif v[:arquivado] then 'border-dashed border-[#3a3a3a] text-gray-500'
|
||||
else 'border-[#2a2a2a] text-gray-300 hover:border-orange-500' end %>
|
||||
<%= link_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: nova_sel.presence),
|
||||
data: { 'validacao-target': 'chipVeiculo', vehicle: v[:vehicle], eligible: v[:eligible] },
|
||||
class: "px-3 py-2 rounded-lg text-sm font-semibold border min-h-[40px] flex items-center gap-2 transition-colors #{cor}" do %>
|
||||
<span data-chip-lock class="<%= 'hidden' unless v[:fechado] %>">🔒</span>
|
||||
<span><%= v[:vehicle] %></span>
|
||||
<span class="text-xs <%= v[:completo] ? 'text-green-400' : 'text-gray-500' %>">
|
||||
<span data-chip-feito><%= v[:classificadas] %></span>/<span data-chip-total><%= v[:eligible] %></span>
|
||||
<span data-chip-check><%= '✓' if v[:completo] %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Ação de consolidação — opera sobre o veículo único selecionado. %>
|
||||
<% if pode_fechar_veiculo %>
|
||||
<% sel_unico = @veiculos_sel.size == 1 ? @veiculos_sel.first : nil %>
|
||||
<% status_unico = sel_unico && @veiculos_status.find { |v| v[:vehicle] == sel_unico } %>
|
||||
<div class="mt-3 pt-3 border-t border-[#2a2a2a]">
|
||||
<% if status_unico.nil? %>
|
||||
<p class="text-gray-500 text-xs">Selecione <strong>um</strong> veículo (clique no nome) para consolidá-lo separadamente.</p>
|
||||
<% elsif status_unico[:fechado] %>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-green-400 text-sm font-semibold">🔒 <%= sel_unico %> consolidado.</span>
|
||||
<%= button_to '🔓 Reabrir veículo',
|
||||
reabrir_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||
method: :post,
|
||||
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||
data: { turbo_confirm: "Reabrir o veículo #{sel_unico} para edição?" },
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<%# Habilita só quando o carro está 100% classificado; o JS liga/desliga
|
||||
conforme a barra do escopo selecionado chega (ou não) a 100%. %>
|
||||
<%= button_to '🔒 Consolidar este veículo',
|
||||
fechar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||
method: :post,
|
||||
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||
disabled: !status_unico[:completo],
|
||||
data: { 'validacao-target': 'consolidarBtn',
|
||||
turbo_confirm: "Consolidar o veículo #{sel_unico}? Você poderá reabrir depois." },
|
||||
class: "font-bold px-4 py-2 rounded-lg text-xs min-h-[40px] #{status_unico[:completo] ? 'bg-green-600 hover:bg-green-500 text-white cursor-pointer' : 'bg-[#2a2a2a] text-gray-600 cursor-not-allowed'}" %>
|
||||
<%= button_to '🗄️ Arquivar veículo',
|
||||
arquivar_veiculo_consolidacao_consolidacao_entregas_path(@consolidacao),
|
||||
method: :post,
|
||||
params: { motorista: @motorista, vehicle: sel_unico, vehicle_sel: @veiculos_sel },
|
||||
data: { turbo_confirm: "Arquivar o veículo #{sel_unico}? Ele sai da finalização (as entregas já classificadas continuam no pagamento)." },
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
<span class="text-gray-500 text-xs" data-validacao-target="consolidarHint">
|
||||
<%= status_unico[:completo] ? 'Pronto para consolidar.' : 'Classifique 100% deste veículo para consolidar.' %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# 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>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-3 mb-3 flex flex-wrap items-center gap-3">
|
||||
<label class="flex items-center gap-2 text-gray-300 text-sm cursor-pointer select-none">
|
||||
<input type="checkbox" data-validacao-target="selecionarTodos"
|
||||
data-action="change->validacao#selecionarTodos"
|
||||
class="rounded text-orange-500 bg-[#0a0a0a] border-[#2a2a2a] w-5 h-5">
|
||||
Selecionar todos
|
||||
</label>
|
||||
|
||||
<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>
|
||||
<div class="flex items-center gap-2 flex-wrap border-l border-[#2a2a2a] pl-3">
|
||||
<span class="text-gray-500 text-sm">Aplicar nos selecionados <span class="text-gray-600">(clique de novo p/ remover)</span>:</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"
|
||||
@@ -54,26 +152,212 @@
|
||||
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>
|
||||
<button data-action="click->validacao#marcarSelecionados" data-tipo="extraordinaria"
|
||||
class="bg-purple-600 text-white font-bold px-3 py-2 rounded-lg text-xs min-h-[44px]">Extra</button>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<%# Oculta/mostra o resumo lateral (mais largura para a lista) %>
|
||||
<button type="button" data-action="validacao#toggleResumo"
|
||||
class="text-gray-400 hover:text-white text-sm border border-[#2a2a2a] hover:border-orange-500 px-3 py-2 rounded-lg whitespace-nowrap">
|
||||
📊 <span data-validacao-target="btnResumoLabel">Ocultar resumo</span>
|
||||
</button>
|
||||
<%= link_to params[:apenas_pendentes] == '1' ? 'Mostrar todas' : 'Apenas não classificadas',
|
||||
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista,
|
||||
validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: @motorista, vehicle: @veiculos_sel.presence,
|
||||
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">
|
||||
<%# Caixa de ferramentas — concentra os lançamentos avulsos (registro manual de
|
||||
entrega e entrega de termo) num único botão com menu suspenso. Os modais e
|
||||
controllers existentes são reaproveitados; só o gatilho mudou. %>
|
||||
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
||||
<div class="relative mb-3"
|
||||
data-controller="ferramentas apontamento-manual apontamento-termo"
|
||||
data-apontamento-manual-apontar-manual-url-value="<%= apontar_manual_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||
data-apontamento-termo-apontar-termo-url-value="<%= apontar_termo_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||
data-apontamento-termo-motorista-value="<%= @motorista %>">
|
||||
|
||||
<%# ── Lista de entregas (3 colunas) ─────────────────── %>
|
||||
<div class="lg:col-span-3 space-y-2">
|
||||
<%# Botão único + menu suspenso %>
|
||||
<button type="button" data-action="ferramentas#toggle"
|
||||
class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px]">
|
||||
➕ Adicionar lançamento
|
||||
<span class="text-gray-500">▾</span>
|
||||
</button>
|
||||
|
||||
<div data-ferramentas-target="menu"
|
||||
class="hidden absolute z-40 mt-2 w-72 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-xl overflow-hidden">
|
||||
<button type="button" data-action="apontamento-manual#abrir ferramentas#fechar"
|
||||
class="w-full text-left px-4 py-3 hover:bg-[#2a2a2a] border-l-2 border-transparent hover:border-purple-500">
|
||||
<span class="block text-white font-semibold">📝 Registrar entrega manualmente</span>
|
||||
<span class="block text-gray-500 text-xs">fora da base de rastreio</span>
|
||||
</button>
|
||||
<button type="button" data-action="apontamento-termo#abrir ferramentas#fechar"
|
||||
class="w-full text-left px-4 py-3 hover:bg-[#2a2a2a] border-t border-[#2a2a2a] border-l-2 border-l-transparent hover:border-l-blue-500">
|
||||
<span class="block text-white font-semibold">📄 Entrega de termo</span>
|
||||
<span class="block text-gray-500 text-xs">sem nota/código — só quantidade</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%# Modal — registro manual de entrega %>
|
||||
<div data-apontamento-manual-target="modal" data-action="click->apontamento-manual#fundo"
|
||||
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-xl font-bold text-white">Registro manual de entrega</h2>
|
||||
<button type="button" data-action="apontamento-manual#fechar"
|
||||
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">Lançe uma entrega à mão, sem buscar na base de rastreio. Use quando o motorista foi ao local mas a nota ficou registrada com outro motorista.</p>
|
||||
|
||||
<p data-apontamento-manual-target="erro" class="hidden bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg px-3 py-2 text-sm mb-3"></p>
|
||||
|
||||
<%# Motorista (default = o motorista da tela; pode trocar) %>
|
||||
<label class="block text-gray-400 text-sm mb-1">Motorista</label>
|
||||
<input data-apontamento-manual-target="motorista" type="text" list="motoristas-consolidacao"
|
||||
value="<%= @motorista %>" placeholder="Nome do motorista"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none mb-3">
|
||||
<datalist id="motoristas-consolidacao">
|
||||
<% Array(@motoristas_consolidacao).each do |nome| %>
|
||||
<option value="<%= nome %>"></option>
|
||||
<% end %>
|
||||
</datalist>
|
||||
|
||||
<%# Veículo (Q3) — a qual carro este lançamento pertence (soma no balde dele) %>
|
||||
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
|
||||
<select data-apontamento-manual-target="veiculo"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none mb-3">
|
||||
<option value="">— sem veículo —</option>
|
||||
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
|
||||
<option value="<%= veic %>"><%= veic %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<%# NF + observação (texto livre) %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">NF <span class="text-gray-600">(opcional)</span></label>
|
||||
<input data-apontamento-manual-target="nf" type="text" inputmode="numeric" placeholder="Número da nota"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-sm mb-1">Observação / local <span class="text-gray-600">(opcional)</span></label>
|
||||
<input data-apontamento-manual-target="obs" type="text" placeholder="Ex.: UBS Sacomã"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Pilares (múltiplos) %>
|
||||
<label class="block text-gray-400 text-sm mb-2">Classificar como <span class="text-gray-600">(pode marcar mais de uma)</span></label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<% [['entrega_normal', '🚚 Entrega Normal', Configuracao.preco_entrega],
|
||||
['retirada', '📦 Retirada', Configuracao.preco_retirada],
|
||||
['bonus', '⭐ Bônus', Configuracao.preco_bonus],
|
||||
['desconto', '⚠️ Desconto', Configuracao.preco_desconto],
|
||||
['extraordinaria', '✨ Entrega Extraordinária', Configuracao.preco_extraordinaria]].each do |tipo, label, preco| %>
|
||||
<label class="flex items-center gap-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-3 py-3 cursor-pointer hover:border-purple-500 text-sm text-white">
|
||||
<input type="checkbox" value="<%= tipo %>" data-apontamento-manual-target="tipoCheck"
|
||||
<%= 'checked' if tipo == 'entrega_normal' %>
|
||||
class="accent-purple-500">
|
||||
<span><%= label %><br><span class="text-gray-500 text-xs"><%= moeda(preco) %></span></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Valor customizado da Extraordinária (opcional) %>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-400 text-sm mb-1">Valor da Extraordinária <span class="text-gray-600">(opcional — vazio usa o padrão)</span></label>
|
||||
<input data-apontamento-manual-target="valorExtra" type="text" inputmode="decimal" placeholder="<%= moeda(Configuracao.preco_extraordinaria) %>"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-purple-500 focus:outline-none">
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="apontamento-manual#adicionar"
|
||||
class="w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px]">
|
||||
Adicionar apontamento ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Modal — entrega de termo %>
|
||||
<div data-apontamento-termo-target="modal" data-action="click->apontamento-termo#fundo"
|
||||
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-md p-6">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-xl font-bold text-white">📄 Entrega de termo</h2>
|
||||
<button type="button" data-action="apontamento-termo#fechar"
|
||||
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">Entrega sem nota fiscal nem código de rastreio. Informe quantos termos foram entregues.</p>
|
||||
|
||||
<p data-apontamento-termo-target="erro" class="hidden bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg px-3 py-2 text-sm mb-3"></p>
|
||||
|
||||
<%# Motorista (contexto da tela) + preço unitário %>
|
||||
<div class="flex items-center justify-between bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-3 mb-4">
|
||||
<div>
|
||||
<p class="text-gray-500 text-xs">Motorista</p>
|
||||
<p class="text-white font-semibold">🚚 <%= @motorista %></p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-gray-500 text-xs">Preço por termo</p>
|
||||
<p class="text-blue-400 font-semibold"><%= moeda(Configuracao.preco_termo) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Veículo (Q3) — a qual carro estes termos pertencem %>
|
||||
<label class="block text-gray-400 text-sm mb-1">Veículo <span class="text-gray-600">(opcional)</span></label>
|
||||
<select data-apontamento-termo-target="veiculo"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-blue-500 focus:outline-none mb-4">
|
||||
<option value="">— sem veículo —</option>
|
||||
<% @veiculos_status.map { |v| v[:vehicle] }.reject { |x| x == '(sem veículo)' }.each do |veic| %>
|
||||
<option value="<%= veic %>"><%= veic %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<%# Stepper de quantidade %>
|
||||
<label class="block text-gray-400 text-sm mb-2">Quantidade de termos</label>
|
||||
<div class="flex items-center justify-center gap-3 mb-5">
|
||||
<button type="button" data-action="apontamento-termo#menos"
|
||||
class="w-12 h-12 rounded-lg bg-[#0a0a0a] border border-[#2a2a2a] hover:border-blue-500 text-white text-2xl font-bold leading-none">−</button>
|
||||
<input data-apontamento-termo-target="quantidade" type="text" inputmode="numeric" value="1" readonly
|
||||
class="w-24 text-center bg-[#0a0a0a] border border-[#2a2a2a] text-white text-2xl font-bold rounded-lg py-2.5 focus:outline-none">
|
||||
<button type="button" data-action="apontamento-termo#mais"
|
||||
class="w-12 h-12 rounded-lg bg-[#0a0a0a] border border-[#2a2a2a] hover:border-blue-500 text-white text-2xl font-bold leading-none">+</button>
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="apontamento-termo#adicionar"
|
||||
class="w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px]">
|
||||
Adicionar termos ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%# Fim do cabeçalho fixo — abaixo só a lista de NFs rola. %>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-3 lg:flex-1 lg:min-h-0">
|
||||
|
||||
<%# ── Lista de entregas (3 colunas) — área rolável ──── %>
|
||||
<div class="lg:col-span-3 space-y-2 lg:overflow-y-auto lg:pr-1" data-validacao-target="listaCol">
|
||||
<%# 🔍 Busca na listagem — filtra os cards por NF, local, endereço ou veículo %>
|
||||
<div class="sticky top-0 z-20 bg-[#0a0a0a] pb-2">
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">🔍</span>
|
||||
<input type="search" data-validacao-target="busca" data-action="input->validacao#filtrarBusca"
|
||||
placeholder="Buscar NF, local, endereço ou veículo…" autocomplete="off"
|
||||
class="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg pl-10 pr-3 py-2.5 text-sm text-gray-200 placeholder-gray-500 focus:border-orange-500 focus:outline-none">
|
||||
</div>
|
||||
<p data-validacao-target="buscaVazia" class="hidden text-gray-500 text-xs mt-2 px-1">Nenhuma nota encontrada para a busca.</p>
|
||||
</div>
|
||||
<% 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"
|
||||
<% tipos_ativos = (@classificacoes[e.tracking_id] || []).map(&:tipo) %>
|
||||
<div class="bg-[#1a1a1a] border <%= e.falhada? ? 'border-red-500/50' : 'border-[#2a2a2a]' %> rounded-xl p-4 flex flex-col lg:flex-row lg:items-center gap-3"
|
||||
data-validacao-target="linha" data-tracking="<%= e.tracking_id %>"
|
||||
data-classificada="<%= classificacao ? '1' : '0' %>">
|
||||
data-vehicle="<%= e.vehicle.presence || '(sem veículo)' %>"
|
||||
data-busca="<%= [e.numero_nf, e.local, e.address, e.vehicle].compact.join(' ').downcase %>"
|
||||
data-classificada="<%= tipos_ativos.any? ? '1' : '0' %>">
|
||||
|
||||
<%# Checkbox seleção em massa %>
|
||||
<input type="checkbox" data-validacao-target="checkbox" value="<%= e.tracking_id %>"
|
||||
@@ -83,25 +367,32 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-semibold truncate">
|
||||
NF <%= e.numero_nf %> · <%= e.local %>
|
||||
<% if e.falhada? %>
|
||||
<span class="ml-1 align-middle text-[10px] font-bold uppercase tracking-wide bg-red-500/20 text-red-400 border border-red-500/40 px-1.5 py-0.5 rounded">⚠️ Insucesso</span>
|
||||
<% else %>
|
||||
<span class="ml-1 align-middle text-[10px] font-bold uppercase tracking-wide bg-green-500/20 text-green-400 border border-green-500/40 px-1.5 py-0.5 rounded">✓ Entregue</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-gray-400 text-xs truncate">📍 <%= e.address.presence || 'sem endereço' %></p>
|
||||
<p class="text-gray-500 text-xs">
|
||||
📅 <%= e.planned_date %> · 🚚 <%= e.vehicle.presence || 'sem veículo' %>
|
||||
📅 <%= e.checkout&.strftime('%d/%m/%Y') || '—' %> · 🚚 <%= e.vehicle.presence || 'sem veículo' %>
|
||||
<% 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 %>">
|
||||
<div class="flex flex-wrap gap-1.5 lg:shrink-0" 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']
|
||||
['desconto', 'Desc.', 'bg-black text-white border border-gray-700'],
|
||||
['extraordinaria', 'Extra', 'bg-purple-600 text-white']
|
||||
].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' %>">
|
||||
<%= tipos_ativos.include?(tipo) ? 'ring-2 ring-green-400 scale-105' : 'opacity-50 hover:opacity-100' %>">
|
||||
<%= label %>
|
||||
</button>
|
||||
<% end %>
|
||||
@@ -116,9 +407,9 @@
|
||||
<% 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">
|
||||
<%# ── Resumo lateral (1 coluna) — rola junto se precisar ─────── %>
|
||||
<div class="lg:col-span-1 lg:overflow-y-auto" data-validacao-target="resumo">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5 space-y-4">
|
||||
<h3 class="text-white font-bold">📊 Resumo</h3>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
@@ -138,6 +429,19 @@
|
||||
<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 class="flex justify-between items-center">
|
||||
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-purple-600 rounded-full"></span> Extraordinária</span>
|
||||
<span class="text-white font-bold" data-validacao-target="qtdExtraordinaria">0</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="flex items-center gap-2"><span class="w-3 h-3 bg-blue-600 rounded-full"></span> Termo</span>
|
||||
<span class="text-white font-bold" data-validacao-target="qtdTermo">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[#2a2a2a] pt-4 flex justify-between items-center text-sm">
|
||||
<span class="flex items-center gap-2 text-blue-400"><span class="w-3 h-3 bg-blue-500 rounded-full"></span> Apontamentos manuais</span>
|
||||
<span class="text-blue-400 font-bold" data-validacao-target="qtdManuais"><%= @manuais %></span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[#2a2a2a] pt-4">
|
||||
@@ -150,6 +454,8 @@
|
||||
<p>📦 Retirada: <%= moeda(@precos[:retirada]) %></p>
|
||||
<p>⭐ Bônus: <%= moeda(@precos[:bonus]) %></p>
|
||||
<p>⚠️ Desconto: -<%= moeda(@precos[:desconto]) %></p>
|
||||
<p>✨ Extraordinária: <%= moeda(@precos[:extraordinaria]) %> (valor por entrega)</p>
|
||||
<p>📄 Termo: <%= moeda(@precos[:termo]) %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,9 +464,11 @@
|
||||
|
||||
<%# Estado inicial para o Stimulus %>
|
||||
<script type="application/json" id="validacao-estado-inicial">
|
||||
<% _todas = @classificacoes.values.flatten %>
|
||||
<%= 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
|
||||
classificadas: @classificadas,
|
||||
manuais: @manuais,
|
||||
por_tipo: _todas.group_by(&:tipo).transform_values { |arr| arr.sum(&:quantidade) },
|
||||
valor_total: _todas.sum { |c| c.tipo == 'desconto' ? -c.valor_aplicado : c.valor_aplicado }.to_f
|
||||
}.to_json) %>
|
||||
</script>
|
||||
|
||||
47
app/views/consolidacao_mailer/pagamento_efetuado.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<%# app/views/consolidacao_mailer/pagamento_efetuado.html.erb %>
|
||||
<div style="font-family: Arial, sans-serif; max-width: 520px; margin: 0 auto; background: #0a0a0a; border-radius: 12px; overflow: hidden;">
|
||||
|
||||
<div style="background: #0a0a0a; padding: 24px; border-left: 6px solid #f97316;">
|
||||
<h1 style="color: #ffffff; margin: 0; font-size: 20px;">REEM TRANSPORTE</h1>
|
||||
<p style="color: #f97316; margin: 4px 0 0; font-size: 13px;">Sistema de Logística</p>
|
||||
</div>
|
||||
|
||||
<div style="background: #ffffff; padding: 28px;">
|
||||
<h2 style="color: #0a0a0a; margin-top: 0;">Olá, <%= @user.nome.split.first %>! ✅</h2>
|
||||
|
||||
<p style="color: #374151; font-size: 15px; line-height: 1.6;">
|
||||
Seu pagamento de entregas foi <strong>efetuado</strong> pela empresa.
|
||||
</p>
|
||||
|
||||
<table style="width: 100%; margin: 20px 0; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Período</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @consolidacao.nome %></td>
|
||||
</tr>
|
||||
<% if @forma.present? %>
|
||||
<tr>
|
||||
<td style="background: #f3f4f6; padding: 10px 14px; font-weight: bold; color: #374151;">Forma</td>
|
||||
<td style="padding: 10px 14px; color: #0a0a0a;"><%= @forma.to_s.humanize %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
|
||||
<div style="background: #22c55e; border-radius: 10px; padding: 20px; text-align: center; margin: 20px 0;">
|
||||
<p style="color: rgba(0,0,0,0.6); margin: 0; font-size: 12px; font-weight: bold;">VALOR PAGO</p>
|
||||
<p style="color: #0a0a0a; margin: 6px 0 0; font-size: 32px; font-weight: 900;">
|
||||
R$ <%= format('%.2f', @valor).gsub('.', ',') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="<%= @link_painel %>"
|
||||
style="background: #0a0a0a; color: #f97316; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-weight: bold; display: inline-block;">
|
||||
Ver meu painel e baixar extrato →
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
|
||||
Entre com seu PIN de 4 números ou escaneie o QR Code do seu extrato.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,12 +30,12 @@
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="<%= @link_painel %>"
|
||||
style="background: #0a0a0a; color: #f97316; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-weight: bold; display: inline-block;">
|
||||
Ver meu painel e baixar holerite →
|
||||
Ver meu painel e baixar extrato →
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
|
||||
Entre com seu PIN de 4 números ou escaneie o QR Code do seu holerite.
|
||||
Entre com seu PIN de 4 números ou escaneie o QR Code do seu extrato.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
68
app/views/consolidacoes/arquivadas.html.erb
Normal file
@@ -0,0 +1,68 @@
|
||||
<%# app/views/consolidacoes/arquivadas.html.erb %>
|
||||
<% content_for :title, 'Consolidações arquivadas' %>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">📁 Consolidações arquivadas</h1>
|
||||
<%= link_to '← Voltar às ativas', consolidacoes_path,
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center' %>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 text-sm mb-6">
|
||||
Histórico de consolidações arquivadas. Você pode <span class="text-orange-400">reativar</span>
|
||||
uma consolidação para voltar a editá-la, ou <span class="text-red-400">excluí-la</span>
|
||||
permanentemente — esta ação não pode ser desfeita.
|
||||
</p>
|
||||
|
||||
<%# ── Lista ────────────────────────────────────────────── %>
|
||||
<% if @consolidacoes.any? %>
|
||||
<div class="space-y-3">
|
||||
<% @consolidacoes.each do |c| %>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||
<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">
|
||||
<%= link_to c.nome, consolidacao_path(c),
|
||||
class: 'text-white font-bold text-lg hover:text-orange-500' %>
|
||||
<%= 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>
|
||||
<p class="text-gray-500 text-xs mt-1">
|
||||
📁 Arquivada
|
||||
<% if c.arquivado_em.present? %>em <%= l c.arquivado_em, format: :short %><% end %>
|
||||
<% if c.arquivador.present? %>por <%= c.arquivador.nome_display %><% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-3">
|
||||
<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>
|
||||
|
||||
<% if policy(c).destroy? %>
|
||||
<div class="flex gap-2">
|
||||
<%= button_to '♻️ Reativar', reativar_consolidacao_path(c), method: :post,
|
||||
form: { data: { turbo_confirm: 'Reativar esta consolidação? Ela voltará para a lista de ativas.' } },
|
||||
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||
<%= button_to '🗑️ Excluir', consolidacao_path(c), method: :delete,
|
||||
form: { data: { turbo_confirm: 'EXCLUIR PERMANENTEMENTE esta consolidação e todas as suas classificações? Esta ação NÃO pode ser desfeita.' } },
|
||||
class: 'text-red-400 hover:text-white hover:bg-red-600 border border-red-900 px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% 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 arquivada.</p>
|
||||
<%= link_to 'Ver consolidações ativas', consolidacoes_path,
|
||||
class: 'inline-block bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg' %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,15 +1,78 @@
|
||||
<%# 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 data-controller="nota-avulsa"
|
||||
data-nota-avulsa-buscar-url-value="<%= buscar_nf_consolidacoes_path %>">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">📦 Consolidações</h1>
|
||||
<div class="flex gap-2">
|
||||
<%= link_to '📁 Arquivadas', arquivadas_consolidacoes_path,
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors' %>
|
||||
<% if policy(Consolidacao).create? %>
|
||||
<button type="button" data-action="nota-avulsa#abrir"
|
||||
class="text-white border border-dashed border-orange-500/60 hover:border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px] flex items-center transition-colors">
|
||||
➕ Nota avulsa
|
||||
</button>
|
||||
<% end %>
|
||||
<%= 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>
|
||||
</div>
|
||||
|
||||
<%# ── Modal "Nota avulsa" — cria a consolidação direto a partir da NF ── %>
|
||||
<% if policy(Consolidacao).create? %>
|
||||
<div data-nota-avulsa-target="modal" data-action="click->nota-avulsa#fundo"
|
||||
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto text-left">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-xl font-bold text-white">➕ Nota avulsa</h2>
|
||||
<button type="button" data-action="nota-avulsa#fechar"
|
||||
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">Para uma nota que chegou por fora: busque a NF e crie uma consolidação já com o apontamento do motorista.</p>
|
||||
|
||||
<p data-nota-avulsa-target="erro" class="hidden bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg px-3 py-2 text-sm mb-3"></p>
|
||||
|
||||
<%# Busca da NF %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input data-nota-avulsa-target="nf" type="text" inputmode="numeric"
|
||||
data-action="keydown.enter->nota-avulsa#buscar"
|
||||
placeholder="Número da nota (NF)"
|
||||
class="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-orange-500 focus:outline-none">
|
||||
<button type="button" data-action="nota-avulsa#buscar"
|
||||
class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 rounded-lg min-h-[48px]">Buscar</button>
|
||||
</div>
|
||||
|
||||
<%# Form de criação — radios (tracking_id) e tipos[] são enviados nativamente %>
|
||||
<%= form_with url: avulsa_consolidacoes_path, method: :post, data: { action: 'submit->nota-avulsa#validar' } do %>
|
||||
<div data-nota-avulsa-target="resultados" class="space-y-2 mb-4"></div>
|
||||
|
||||
<div data-nota-avulsa-target="confirmar" class="hidden border-t border-[#2a2a2a] pt-4">
|
||||
<label class="block text-gray-400 text-sm mb-2">Classificar como <span class="text-gray-600">(pode marcar mais de uma)</span></label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-4">
|
||||
<% [['entrega_normal', '🚚 Entrega Normal', Configuracao.preco_entrega],
|
||||
['retirada', '📦 Retirada', Configuracao.preco_retirada],
|
||||
['bonus', '⭐ Bônus', Configuracao.preco_bonus],
|
||||
['desconto', '⚠️ Desconto', Configuracao.preco_desconto],
|
||||
['extraordinaria', '✨ Entrega Extraordinária', Configuracao.preco_extraordinaria]].each do |tipo, label, preco| %>
|
||||
<label class="flex items-center gap-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-3 py-3 cursor-pointer hover:border-orange-500 text-sm text-white">
|
||||
<%= check_box_tag 'tipos[]', tipo, tipo == 'entrega_normal', id: nil, class: 'accent-orange-500' %>
|
||||
<span><%= label %><br><span class="text-gray-500 text-xs"><%= moeda(preco) %></span></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= submit_tag 'Criar consolidação avulsa →',
|
||||
class: 'w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px] cursor-pointer' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</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| %>
|
||||
class: 'bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4 mb-6 grid grid-cols-1 md:grid-cols-6 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' %>
|
||||
|
||||
@@ -17,6 +80,10 @@
|
||||
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.select :pagamento,
|
||||
options_for_select([['💵 Pagamento (todos)', ''], ['💰 Pagas', 'pago'], ['⏳ Parciais', 'parcial'], ['🕗 Pendentes', 'pendente']], params[:pagamento]),
|
||||
{}, 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' %>
|
||||
|
||||
@@ -40,12 +107,16 @@
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<h3 class="text-white font-bold text-lg"><%= c.nome %></h3>
|
||||
<%= badge_status(c.status) %>
|
||||
<% if c.finalizada? %><%= badge_pagamento(c.status_pagamento) %><% end %>
|
||||
</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>
|
||||
<% if c.operacoes.present? %>
|
||||
<p class="text-gray-500 text-xs mt-1">🗂️ <%= c.operacoes_label.join(' · ') %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-orange-500 font-black text-2xl"><%= moeda(c.valor_total) %></p>
|
||||
|
||||
@@ -17,6 +17,28 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Operação — multi-select opcional (pré-preenche e filtra por NF) %>
|
||||
<div>
|
||||
<label class="block text-white font-semibold mb-2">
|
||||
🗂️ Operação <span class="text-gray-500 font-normal text-sm">(opcional — preenche motoristas, veículos e período, e filtra as entregas por nota fiscal)</span>
|
||||
</label>
|
||||
<div id="operacoes-lista"
|
||||
class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1">
|
||||
<% if @operacoes.any? %>
|
||||
<% @operacoes.each do |op| %>
|
||||
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm min-h-[44px]">
|
||||
<%= check_box_tag 'consolidacao[operacoes][]', op[:tabela], false,
|
||||
class: 'chk-operacao rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %>
|
||||
<%= op[:label] %>
|
||||
</label>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-sm px-2 py-2">Nenhuma tabela de operação encontrada no banco.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p id="operacao-status" class="text-gray-500 text-xs mt-1"></p>
|
||||
</div>
|
||||
|
||||
<%# Nome %>
|
||||
<div>
|
||||
<%= f.label :nome, 'Nome da consolidação', class: 'block text-white font-semibold mb-2' %>
|
||||
@@ -47,12 +69,12 @@
|
||||
<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)"
|
||||
<input type="checkbox" onclick="document.querySelectorAll('.motorista-row').forEach(r => { if (!r.classList.contains('hidden')) r.querySelector('.chk-motorista').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]">
|
||||
<label class="motorista-row 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>
|
||||
@@ -63,21 +85,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Veículos — multi-select opcional %>
|
||||
<%# Veículos — multi-select opcional (filtrado pelo motorista + período) %>
|
||||
<div>
|
||||
<label class="block text-white font-semibold mb-2">
|
||||
Veículos <span class="text-gray-500 font-normal text-sm">(opcional — filtra entregas por veículo)</span>
|
||||
Veículos <span class="text-gray-500 font-normal text-sm">(opcional — só os veículos usados pelo(s) motorista(s) no período)</span>
|
||||
</label>
|
||||
<div class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1">
|
||||
<% if @veiculos.blank? %>
|
||||
<p class="text-gray-500 text-sm px-2 py-2">Nenhum veículo encontrado no período.</p>
|
||||
<% end %>
|
||||
<% @veiculos.each do |v| %>
|
||||
<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm min-h-[44px]">
|
||||
<%= check_box_tag 'consolidacao[vehicle_ids][]', v, false, class: 'rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]', id: nil %>
|
||||
<%= v %>
|
||||
</label>
|
||||
<% end %>
|
||||
<div id="veiculos-lista"
|
||||
data-url="<%= veiculos_disponiveis_consolidacoes_path %>"
|
||||
class="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg p-3 max-h-48 overflow-y-auto space-y-1">
|
||||
<p class="text-gray-500 text-sm px-2 py-2">Selecione um motorista para listar os veículos.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,3 +106,145 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const lista = document.getElementById('veiculos-lista');
|
||||
if (!lista) return;
|
||||
const url = lista.dataset.url;
|
||||
const dataIni = document.getElementById('consolidacao_data_inicio');
|
||||
const dataFim = document.getElementById('consolidacao_data_fim');
|
||||
|
||||
// Mantém os veículos que já estavam marcados ao recarregar a lista.
|
||||
function veiculosMarcados() {
|
||||
return Array.from(lista.querySelectorAll('input[name="consolidacao[vehicle_ids][]"]:checked'))
|
||||
.map(c => c.value);
|
||||
}
|
||||
|
||||
function motoristasSelecionados() {
|
||||
return Array.from(document.querySelectorAll('.chk-motorista:checked')).map(c => c.value);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function renderVeiculos(veiculos, marcados) {
|
||||
if (!veiculos.length) {
|
||||
lista.innerHTML = '<p class="text-gray-500 text-sm px-2 py-2">Nenhum veículo encontrado para este(s) motorista(s) no período.</p>';
|
||||
return;
|
||||
}
|
||||
lista.innerHTML = veiculos.map(function (v) {
|
||||
const checked = marcados.includes(v) ? 'checked' : '';
|
||||
const val = escapeHtml(v);
|
||||
return '<label class="flex items-center gap-2 text-white hover:bg-[#1a1a1a] rounded px-2 py-2 cursor-pointer text-sm min-h-[44px]">' +
|
||||
'<input type="checkbox" name="consolidacao[vehicle_ids][]" value="' + val + '" ' + checked +
|
||||
' class="rounded text-orange-500 bg-[#1a1a1a] border-[#2a2a2a]">' + val + '</label>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
let controller = null;
|
||||
async function atualizarVeiculos() {
|
||||
const motoristas = motoristasSelecionados();
|
||||
const marcados = veiculosMarcados();
|
||||
|
||||
if (!motoristas.length) {
|
||||
lista.innerHTML = '<p class="text-gray-500 text-sm px-2 py-2">Selecione um motorista para listar os veículos.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
motoristas.forEach(m => params.append('motoristas[]', m));
|
||||
if (dataIni && dataIni.value) params.append('inicio', dataIni.value);
|
||||
if (dataFim && dataFim.value) params.append('fim', dataFim.value);
|
||||
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
try {
|
||||
const resp = await fetch(url + '?' + params.toString(), {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal
|
||||
});
|
||||
const data = await resp.json();
|
||||
renderVeiculos(data.veiculos || [], marcados);
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') {
|
||||
lista.innerHTML = '<p class="text-red-400 text-sm px-2 py-2">Erro ao carregar veículos.</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.classList.contains('chk-motorista')) atualizarVeiculos();
|
||||
});
|
||||
if (dataIni) dataIni.addEventListener('change', atualizarVeiculos);
|
||||
if (dataFim) dataFim.addEventListener('change', atualizarVeiculos);
|
||||
|
||||
// ── Operações: ao marcar, pré-preenche período + motoristas + veículos ──
|
||||
const opStatus = document.getElementById('operacao-status');
|
||||
const dadosOpUrl = '<%= dados_da_operacao_consolidacoes_path %>';
|
||||
|
||||
function operacoesSelecionadas() {
|
||||
return Array.from(document.querySelectorAll('.chk-operacao:checked')).map(c => c.value);
|
||||
}
|
||||
|
||||
// Mostra todos os motoristas e desmarca tudo (estado sem operação).
|
||||
function resetarMotoristas() {
|
||||
document.querySelectorAll('.motorista-row').forEach(r => {
|
||||
r.classList.remove('hidden');
|
||||
r.querySelector('.chk-motorista').checked = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function aplicarOperacoes() {
|
||||
const ops = operacoesSelecionadas();
|
||||
|
||||
// Sem operação: volta a mostrar a lista completa de motoristas.
|
||||
if (!ops.length) {
|
||||
resetarMotoristas();
|
||||
if (opStatus) opStatus.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (opStatus) opStatus.textContent = 'Carregando dados da operação…';
|
||||
const params = new URLSearchParams();
|
||||
ops.forEach(o => params.append('operacoes[]', o));
|
||||
|
||||
try {
|
||||
const resp = await fetch(dadosOpUrl + '?' + params.toString(), { headers: { 'Accept': 'application/json' } });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.inicio && dataIni) dataIni.value = data.inicio;
|
||||
if (data.fim && dataFim) dataFim.value = data.fim;
|
||||
|
||||
// Filtra a lista: mostra só os motoristas da operação (já marcados). O
|
||||
// admin pode desmarcar e deixar só um, ou manter todos.
|
||||
const motset = new Set(data.motoristas || []);
|
||||
document.querySelectorAll('.motorista-row').forEach(r => {
|
||||
const chk = r.querySelector('.chk-motorista');
|
||||
const naOperacao = motset.has(chk.value);
|
||||
r.classList.toggle('hidden', !naOperacao);
|
||||
chk.checked = naOperacao;
|
||||
});
|
||||
|
||||
await atualizarVeiculos();
|
||||
const vehset = new Set(data.veiculos || []);
|
||||
lista.querySelectorAll('input[name="consolidacao[vehicle_ids][]"]').forEach(c => { if (vehset.has(c.value)) c.checked = true; });
|
||||
|
||||
if (opStatus) {
|
||||
opStatus.textContent = (data.motoristas || []).length + ' motorista(s) na operação — desmarque para fazer de um só, ou mantenha todos.';
|
||||
}
|
||||
} catch (e) {
|
||||
if (opStatus) opStatus.textContent = 'Erro ao carregar dados da operação.';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
if (e.target.classList.contains('chk-operacao')) aplicarOperacoes();
|
||||
});
|
||||
|
||||
// Estado inicial (caso o form volte com motoristas já marcados após erro de validação)
|
||||
if (motoristasSelecionados().length) atualizarVeiculos();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# app/views/consolidacoes/preview_holerite.html.erb — renderizado sem layout, dentro do modal %>
|
||||
<%# app/views/consolidacoes/preview_extrato.html.erb — renderizado sem layout, dentro do modal %>
|
||||
<div class="bg-white text-black rounded-xl p-6 font-sans">
|
||||
|
||||
<%# Cabeçalho estilo PDF %>
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="w-2 h-12 bg-orange-500 rounded"></div>
|
||||
<div>
|
||||
<p class="text-white font-black text-lg leading-tight">REEM TRANSPORTE</p>
|
||||
<p class="text-orange-500 text-xs">Holerite de Pagamento — Entregas</p>
|
||||
<p class="text-orange-500 text-xs">Extrato de Pagamento — Entregas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<%# app/views/consolidacoes/show.html.erb %>
|
||||
<% content_for :title, @consolidacao.nome %>
|
||||
<% formas_pagamento = [['PIX', 'pix'], ['Transferência', 'transferencia'], ['Dinheiro', 'dinheiro']] %>
|
||||
<% pode_pagar = @consolidacao.finalizada? && policy(@consolidacao).registrar_pagamento? %>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
|
||||
@@ -7,9 +9,10 @@
|
||||
<%= link_to '← Consolidações', consolidacoes_path, class: 'text-gray-500 hover:text-orange-500 text-sm' %>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mt-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-2xl font-bold text-white"><%= @consolidacao.nome %></h1>
|
||||
<%= badge_status(@consolidacao.status) %>
|
||||
<% if @consolidacao.finalizada? %><%= badge_pagamento(@consolidacao.status_pagamento) %><% end %>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
📅 <%= l @consolidacao.data_inicio, format: :short %> → <%= l @consolidacao.data_fim, format: :short %>
|
||||
@@ -18,19 +21,54 @@
|
||||
· Finalizada por <%= @consolidacao.finalizador&.nome_display %> em <%= l @consolidacao.finalizado_em, format: :short %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if @consolidacao.operacoes.present? %>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
🗂️ Operação:
|
||||
<% @consolidacao.operacoes_label.each do |op| %>
|
||||
<span class="inline-block bg-[#2a2a2a] text-gray-200 rounded-full px-2.5 py-0.5 text-xs"><%= op %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<% if @consolidacao.rascunho? %>
|
||||
<%= link_to '✏️ Continuar validação', wizard_consolidacao_path(@consolidacao),
|
||||
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3 rounded-lg min-h-[48px] flex items-center' %>
|
||||
<% end %>
|
||||
<%= link_to '📊 Relatório financeiro', gerar_pdf_financeiro_consolidacao_path(@consolidacao),
|
||||
data: { turbo: false },
|
||||
class: 'bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px] flex items-center' %>
|
||||
<% if policy(@consolidacao).destroy? && !@consolidacao.arquivada? %>
|
||||
<%= button_to '📁 Arquivar', arquivar_consolidacao_path(@consolidacao), method: :post,
|
||||
data: { turbo_confirm: 'Arquivar esta consolidação? Ela não será excluída, apenas movida para o arquivo.' },
|
||||
form: { data: { turbo_confirm: 'Arquivar esta consolidação? Ela não será excluída, apenas movida para o arquivo.' } },
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-3 rounded-lg min-h-[48px]' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── Pagamento da operação inteira ─────────────────────── %>
|
||||
<% if pode_pagar %>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mt-4 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-4">
|
||||
<span class="text-gray-400 text-sm font-medium mr-1">💵 Operação inteira:</span>
|
||||
<% if @consolidacao.status_pagamento != :pago %>
|
||||
<%# Form único (id) — os inputs de NF e os botões "Marcar pago" de cada
|
||||
linha se associam a ele via atributo HTML5 form=, evitando <form> aninhado. %>
|
||||
<%= form_with url: registrar_pagamento_consolidacao_path(@consolidacao), method: :post,
|
||||
id: 'form-pagar-tudo', class: 'flex items-center gap-2' do |f| %>
|
||||
<%= f.select :forma_pagamento, options_for_select(formas_pagamento, 'pix'),
|
||||
{}, class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-2.5 text-sm' %>
|
||||
<%= f.submit '💰 Marcar tudo como pago',
|
||||
data: { turbo_confirm: 'Marcar TODOS os motoristas pendentes desta consolidação como PAGOS? Confirme que a Nota Fiscal de cada um foi preenchida.' },
|
||||
class: 'bg-green-600 hover:bg-green-700 text-white font-bold px-4 py-2.5 rounded-lg text-sm cursor-pointer min-h-[44px]' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @consolidacao.status_pagamento != :pendente %>
|
||||
<%= button_to '↩ Estornar tudo', cancelar_pagamento_consolidacao_path(@consolidacao), method: :post,
|
||||
data: { turbo_confirm: 'Estornar o pagamento de TODOS os motoristas?' },
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-4 py-2.5 rounded-lg text-sm min-h-[44px]' %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Total geral %>
|
||||
@@ -46,40 +84,143 @@
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-white font-bold text-lg"><%= cm.motorista_nome %></p>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="text-white font-bold text-lg"><%= cm.motorista_nome %></p>
|
||||
<% if @consolidacao.finalizada? %>
|
||||
<%= badge_pagamento(cm.pago? ? :pago : :pendente) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-orange-500 font-black text-xl"><%= moeda(cm.valor_total) %></p>
|
||||
<%# Breakdown por veículo (consolidados 🔒 / abertos). Informativo — o
|
||||
pagamento continua sendo do motorista inteiro. %>
|
||||
<% veiculos = @veiculos_por_motorista[cm.motorista_nome] %>
|
||||
<% if veiculos.present? %>
|
||||
<div class="flex flex-wrap gap-1.5 mt-1">
|
||||
<% veiculos.each do |cv| %>
|
||||
<span class="text-xs px-2 py-0.5 rounded border <%= cv.fechado? ? 'border-green-500/50 text-green-400' : 'border-[#2a2a2a] text-gray-400' %>">
|
||||
<%= cv.fechado? ? '🔒' : '○' %> <%= cv.vehicle %> · <%= moeda(cv.valor_total) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if cm.pago? %>
|
||||
<p class="text-green-400 text-xs mt-1">
|
||||
💰 Pago em <%= l cm.pago_em, format: :short %>
|
||||
<% if cm.forma_pagamento.present? %>via <%= cm.forma_pagamento.humanize %><% end %>
|
||||
<% if cm.pagador.present? %>por <%= cm.pagador.nome_display %><% end %>
|
||||
</p>
|
||||
<% if cm.nota_fiscal.present? %>
|
||||
<p class="text-gray-300 text-xs mt-0.5">🧾 NF <%= cm.nota_fiscal %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if pode_pagar %>
|
||||
<div class="mt-2">
|
||||
<% if cm.pago? %>
|
||||
<%= button_to '↩ Estornar pagamento', cancelar_pagamento_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id), method: :post,
|
||||
data: { turbo_confirm: "Estornar o pagamento de #{cm.motorista_nome}?" },
|
||||
class: 'text-gray-400 hover:text-white border border-[#2a2a2a] px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
<% else %>
|
||||
<%# Inputs/botão associados ao form único #form-pagar-tudo via atributo form=. %>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<%= text_field_tag "notas_fiscais[#{cm.id}]", nil, form: 'form-pagar-tudo',
|
||||
placeholder: 'Nº Nota Fiscal',
|
||||
class: 'bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-2 py-2 text-xs w-36' %>
|
||||
<%= button_tag '💰 Marcar pago', type: :submit, form: 'form-pagar-tudo',
|
||||
name: 'consolidacao_motorista_id', value: cm.id,
|
||||
data: { turbo_confirm: "Marcar #{cm.motorista_nome} como pago?" },
|
||||
class: 'bg-green-600 hover:bg-green-700 text-white font-bold px-3 py-2 rounded-lg text-xs cursor-pointer min-h-[40px]' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%# Preview abre modal %>
|
||||
<button onclick="abrirPreview('<%= preview_holerite_consolidacao_path(@consolidacao, motorista: cm.motorista_nome) %>')"
|
||||
<button onclick="abrirPreview('<%= preview_extrato_consolidacao_path(@consolidacao, motorista: cm.motorista_nome) %>')"
|
||||
class="bg-[#0a0a0a] hover:bg-[#2a2a2a] text-white border border-orange-500 font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px]">
|
||||
👁️ Ver antes de gerar
|
||||
</button>
|
||||
<%= link_to '📄 Relatório Individual',
|
||||
gerar_pdf_relatorio_consolidacao_path(@consolidacao, motorista: cm.motorista_nome),
|
||||
data: { turbo: false },
|
||||
class: 'bg-orange-800 hover:bg-orange-700 text-white font-semibold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center' %>
|
||||
<%= link_to '🧾 Gerar Holerite',
|
||||
gerar_pdf_holerite_consolidacao_path(@consolidacao, motorista: cm.motorista_nome),
|
||||
<%= link_to '🧾 Gerar Extrato',
|
||||
gerar_pdf_extrato_consolidacao_path(@consolidacao, motorista: cm.motorista_nome),
|
||||
data: { turbo: false },
|
||||
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-4 py-3 rounded-lg text-sm min-h-[48px] flex items-center' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# ── Apontamentos manuais (notas que chegaram por fora) ─────────────── %>
|
||||
<% if @apontamentos.present? %>
|
||||
<% pode_editar = !(@consolidacao.finalizada? && current_user.operador?) %>
|
||||
<h2 class="text-white font-bold text-lg mt-8 mb-3">➕ Apontamentos manuais</h2>
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-[#0a0a0a] text-gray-400">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3">Nota / Motorista</th>
|
||||
<th class="text-left px-4 py-3">Classificações</th>
|
||||
<th class="text-right px-4 py-3">Valor</th>
|
||||
<% if pode_editar %><th class="px-4 py-3"></th><% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[#2a2a2a]">
|
||||
<% @apontamentos.each do |(motorista, _tracking), pilares| %>
|
||||
<% eh_termo = pilares.first.termo? %>
|
||||
<% entrega = eh_termo ? nil : pilares.first.entrega_original %>
|
||||
<% nf_exibida = entrega&.numero_nf || pilares.first.nf_manual %>
|
||||
<% obs_manual = pilares.first.obs_manual %>
|
||||
<% total = pilares.sum { |p| p.desconto? ? -p.valor_aplicado : p.valor_aplicado } %>
|
||||
<tr>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-white font-bold"><%= eh_termo ? '📄 Entrega de termo' : "NF #{nf_exibida.presence || '—'}" %></p>
|
||||
<p class="text-gray-400 text-xs">
|
||||
<%= motorista %><% if entrega&.vehicle.present? %> · <%= entrega.vehicle %><% end %>
|
||||
<% if entrega.nil? && obs_manual.present? %> · <%= obs_manual %><% end %>
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<% pilares.each do |p| %>
|
||||
<span class="<%= p.tipo_cor[:bg] %> <%= p.tipo_cor[:text] %> px-2 py-1 rounded text-xs font-bold"><%= p.tipo_cor[:label] %><% if p.termo? && p.quantidade > 1 %> ×<%= p.quantidade %><% end %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-bold <%= total.negative? ? 'text-red-400' : 'text-white' %>"><%= moeda(total) %></td>
|
||||
<% if pode_editar %>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<%= button_to '✕ Remover',
|
||||
remover_apontamento_consolidacao_consolidacao_entregas_path(@consolidacao, tracking_id: pilares.first.tracking_id, motorista: motorista),
|
||||
method: :delete,
|
||||
form: { data: { turbo_confirm: eh_termo ? "Remover a entrega de termo de #{motorista}?" : "Remover o apontamento da NF #{nf_exibida.presence || '—'} de #{motorista}?" } },
|
||||
class: 'text-gray-400 hover:text-red-400 border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# ── Modal de Preview ─────────────────────────────────── %>
|
||||
<div id="modal-preview" class="fixed inset-0 bg-black/80 z-50 hidden items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl max-w-2xl w-full max-h-[85vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[#2a2a2a]">
|
||||
<h3 class="text-white font-bold">👁️ Pré-visualização do Holerite</h3>
|
||||
<h3 class="text-white font-bold">👁️ Pré-visualização do Extrato</h3>
|
||||
<button onclick="fecharPreview()" class="text-gray-400 hover:text-white text-2xl min-w-[44px] min-h-[44px]">✕</button>
|
||||
</div>
|
||||
<div id="modal-preview-conteudo" class="overflow-y-auto p-6 flex-1">
|
||||
<div class="flex justify-center py-10"><div class="spinner w-10 h-10"></div></div>
|
||||
</div>
|
||||
<div class="flex gap-3 px-6 py-4 border-t border-[#2a2a2a]">
|
||||
<a id="modal-confirmar" href="#"
|
||||
<a id="modal-confirmar" href="#" data-turbo="false"
|
||||
class="flex-1 bg-orange-500 hover:bg-orange-600 text-black font-bold py-3 rounded-lg text-center min-h-[48px] flex items-center justify-center">
|
||||
✓ Confirmar e baixar PDF
|
||||
</a>
|
||||
@@ -104,7 +245,7 @@
|
||||
|
||||
// O link de confirmar aponta para o PDF do mesmo motorista
|
||||
document.getElementById('modal-confirmar').href =
|
||||
url.replace('preview_holerite', 'gerar_pdf_holerite');
|
||||
url.replace('preview_extrato', 'gerar_pdf_extrato');
|
||||
}
|
||||
function fecharPreview() {
|
||||
const modal = document.getElementById('modal-preview');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<%# 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' %>
|
||||
<%= link_to '← Voltar para o resumo', consolidacao_path(@consolidacao), 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 %>
|
||||
@@ -31,61 +31,266 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Caixa de ferramentas do Passo 1 — concentra "Apontamento manual" (busca pela
|
||||
NF) e "Adicionar motorista" num único botão com menu suspenso. %>
|
||||
<% unless @consolidacao.finalizada? && current_user.operador? %>
|
||||
<div class="relative mb-4"
|
||||
data-controller="ferramentas apontamento adicionar-motorista"
|
||||
data-apontamento-buscar-url-value="<%= buscar_nf_consolidacao_consolidacao_entregas_path(@consolidacao) %>"
|
||||
data-apontamento-apontar-url-value="<%= apontar_consolidacao_consolidacao_entregas_path(@consolidacao) %>">
|
||||
|
||||
<%# Botão único + menu suspenso %>
|
||||
<button type="button" data-action="ferramentas#toggle"
|
||||
class="w-full md:w-auto inline-flex items-center justify-center gap-2 bg-[#1a1a1a] hover:bg-[#2a2a2a] text-white border border-dashed border-[#3a3a3a] hover:border-orange-500 font-semibold px-4 py-3 rounded-lg min-h-[48px]">
|
||||
➕ Adicionar / Ferramentas
|
||||
<span class="text-gray-500">▾</span>
|
||||
</button>
|
||||
|
||||
<div data-ferramentas-target="menu"
|
||||
class="hidden absolute z-40 mt-2 w-72 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-xl overflow-hidden">
|
||||
<button type="button" data-action="apontamento#abrir ferramentas#fechar"
|
||||
class="w-full text-left px-4 py-3 hover:bg-[#2a2a2a] border-l-2 border-transparent hover:border-orange-500">
|
||||
<span class="block text-white font-semibold">📝 Apontamento manual</span>
|
||||
<span class="block text-gray-500 text-xs">nota que chegou por fora (busca pela NF)</span>
|
||||
</button>
|
||||
<button type="button" data-action="adicionar-motorista#abrir ferramentas#fechar"
|
||||
class="w-full text-left px-4 py-3 hover:bg-[#2a2a2a] border-t border-[#2a2a2a] border-l-2 border-l-transparent hover:border-l-green-500">
|
||||
<span class="block text-white font-semibold">🧑✈️ Adicionar motorista</span>
|
||||
<span class="block text-gray-500 text-xs">incluir/restaurar um motorista nesta consolidação</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%# Modal — apontamento manual (busca NF) %>
|
||||
<div data-apontamento-target="modal" data-action="click->apontamento#fundo"
|
||||
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-xl font-bold text-white">Apontamento manual</h2>
|
||||
<button type="button" data-action="apontamento#fechar"
|
||||
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">Busque a nota pela base de rastreio e adicione ao fechamento do motorista correspondente.</p>
|
||||
|
||||
<p data-apontamento-target="erro" class="hidden bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg px-3 py-2 text-sm mb-3"></p>
|
||||
|
||||
<%# Busca da NF %>
|
||||
<form data-action="submit->apontamento#buscar" class="flex gap-2 mb-4">
|
||||
<input data-apontamento-target="nf" type="text" inputmode="numeric"
|
||||
placeholder="Número da nota (NF)"
|
||||
class="flex-1 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-orange-500 focus:outline-none">
|
||||
<button type="submit"
|
||||
class="bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 rounded-lg min-h-[48px]">Buscar</button>
|
||||
</form>
|
||||
|
||||
<%# Resultados (preenchidos via JS) %>
|
||||
<div data-apontamento-target="resultados" class="space-y-2 mb-4"></div>
|
||||
|
||||
<%# Confirmação — pilares (múltiplos) + adicionar (oculto até a busca retornar) %>
|
||||
<div data-apontamento-target="confirmar" class="hidden border-t border-[#2a2a2a] pt-4">
|
||||
<label class="block text-gray-400 text-sm mb-2">Classificar como <span class="text-gray-600">(pode marcar mais de uma)</span></label>
|
||||
<div class="grid grid-cols-2 gap-2 mb-4">
|
||||
<% [['entrega_normal', '🚚 Entrega Normal', Configuracao.preco_entrega],
|
||||
['retirada', '📦 Retirada', Configuracao.preco_retirada],
|
||||
['bonus', '⭐ Bônus', Configuracao.preco_bonus],
|
||||
['desconto', '⚠️ Desconto', Configuracao.preco_desconto],
|
||||
['extraordinaria', '✨ Entrega Extraordinária', Configuracao.preco_extraordinaria]].each do |tipo, label, preco| %>
|
||||
<label class="flex items-center gap-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-3 py-3 cursor-pointer hover:border-orange-500 text-sm text-white">
|
||||
<input type="checkbox" value="<%= tipo %>" data-apontamento-target="tipoCheck"
|
||||
<%= 'checked' if tipo == 'entrega_normal' %>
|
||||
class="accent-orange-500">
|
||||
<span><%= label %><br><span class="text-gray-500 text-xs"><%= moeda(preco) %></span></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<button type="button" data-action="apontamento#apontar"
|
||||
class="w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px]">
|
||||
Adicionar apontamento ✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Modal — adicionar motorista %>
|
||||
<div data-adicionar-motorista-target="modal" data-action="click->adicionar-motorista#fundo"
|
||||
class="hidden fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div class="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-md p-6">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h2 class="text-xl font-bold text-white">🧑✈️ Adicionar motorista</h2>
|
||||
<button type="button" data-action="adicionar-motorista#fechar"
|
||||
class="text-gray-500 hover:text-white text-2xl leading-none">✕</button>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm mb-4">Inclua um motorista nesta consolidação. Se ele estava arquivado, será restaurado.</p>
|
||||
|
||||
<%= form_with url: adicionar_motorista_consolidacao_path(@consolidacao), method: :post do |f| %>
|
||||
<label class="block text-gray-400 text-sm mb-1">Motorista <span class="text-gray-600">(previstos no período)</span></label>
|
||||
<input data-adicionar-motorista-target="input" type="text" name="motorista_nome"
|
||||
list="motoristas-disponiveis" placeholder="Nome do motorista" autocomplete="off"
|
||||
class="w-full bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg px-3 py-3 text-base focus:border-orange-500 focus:outline-none mb-4">
|
||||
<datalist id="motoristas-disponiveis">
|
||||
<% Array(@motoristas_periodo).each do |m| %>
|
||||
<option value="<%= m[:nome] %>" label="<%= '🚚 ' + m[:veiculos].join(', ') if m[:veiculos].any? %>"></option>
|
||||
<% end %>
|
||||
</datalist>
|
||||
|
||||
<%# Lista de previstos no período (nome + veículos usados) — clique para preencher %>
|
||||
<% if Array(@motoristas_periodo).any? %>
|
||||
<p class="text-gray-500 text-xs mb-1">Previstos neste período (clique para escolher):</p>
|
||||
<div class="max-h-48 overflow-y-auto space-y-1 mb-4 pr-1">
|
||||
<% @motoristas_periodo.each do |m| %>
|
||||
<button type="button" data-action="adicionar-motorista#selecionar" data-nome="<%= m[:nome] %>"
|
||||
class="w-full text-left px-3 py-2 bg-[#0a0a0a] border border-[#2a2a2a] hover:border-orange-500 rounded-lg">
|
||||
<span class="block text-white text-sm font-semibold"><%= m[:nome] %></span>
|
||||
<% if m[:veiculos].any? %>
|
||||
<span class="block text-gray-500 text-xs">🚚 <%= m[:veiculos].join(', ') %></span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-xs mb-4">Nenhum motorista previsto no período — você ainda pode digitar um nome manualmente.</p>
|
||||
<% end %>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-green-600 hover:bg-green-500 text-white font-bold py-3.5 rounded-lg min-h-[48px]">
|
||||
Adicionar ✓
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Lista de motoristas %>
|
||||
<% pode_arquivar_motorista = !(@consolidacao.finalizada? && current_user.operador?) %>
|
||||
<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 %>
|
||||
<%# Container relativo: o card é um link; o botão de arquivar fica FORA da
|
||||
âncora (não se pode aninhar <form>/<button_to> dentro de <a>). %>
|
||||
<div class="relative">
|
||||
<%= 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>
|
||||
<% if m[:apontamentos].positive? %>
|
||||
· <span class="text-orange-400">➕ <%= m[:apontamentos] %> apontamento<%= 's' if m[:apontamentos] > 1 %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if m[:veiculos_total] > 0 %>
|
||||
<p class="text-xs mt-0.5">
|
||||
<span class="<%= m[:veiculos_fechados] == m[:veiculos_total] ? 'text-green-400' : 'text-gray-400' %>">
|
||||
🚗 <%= m[:veiculos_fechados] %>/<%= m[:veiculos_total] %> veículo<%= 's' if m[:veiculos_total] > 1 %> consolidado<%= 's' if m[:veiculos_fechados] != 1 %>
|
||||
</span>
|
||||
<% if m[:veiculos_pendentes].any? %>
|
||||
<span class="text-orange-400">· pendente<%= 's' if m[:veiculos_pendentes].size > 1 %>: <%= m[:veiculos_pendentes].join(', ') %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</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 class="text-right <%= 'pr-10' if pode_arquivar_motorista %>">
|
||||
<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="text-right">
|
||||
<p class="text-orange-500 font-bold"><%= moeda(m[:registro].valor_total) %></p>
|
||||
<span class="text-gray-600 text-2xl">→</span>
|
||||
<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>
|
||||
</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 %>
|
||||
|
||||
<% if pode_arquivar_motorista %>
|
||||
<%= button_to '🗄️',
|
||||
arquivar_motorista_consolidacao_path(@consolidacao, consolidacao_motorista_id: m[:registro].id),
|
||||
method: :post,
|
||||
form: { data: { turbo_confirm: "Arquivar #{m[:registro].motorista_nome} desta consolidação? Você poderá restaurar depois." },
|
||||
class: 'absolute top-3 right-3' },
|
||||
class: 'text-gray-500 hover:text-red-400 border border-[#2a2a2a] hover:border-red-500/50 rounded-lg w-9 h-9 flex items-center justify-center',
|
||||
title: "Arquivar #{m[:registro].motorista_nome}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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>
|
||||
<%# Motoristas arquivados — restauráveis %>
|
||||
<% if @arquivados.present? %>
|
||||
<div class="mt-6">
|
||||
<h2 class="text-gray-400 font-semibold text-sm mb-2">🗄️ Motoristas arquivados (<%= @arquivados.size %>)</h2>
|
||||
<div class="space-y-2">
|
||||
<% @arquivados.each do |cm| %>
|
||||
<div class="flex items-center justify-between bg-[#141414] border border-[#2a2a2a] border-dashed rounded-xl px-4 py-3">
|
||||
<div>
|
||||
<p class="text-gray-300 font-semibold"><%= cm.motorista_nome %></p>
|
||||
<p class="text-gray-600 text-xs">Arquivado · não conta no total</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if pode_arquivar_motorista %>
|
||||
<%= button_to '♻️ Restaurar',
|
||||
desarquivar_motorista_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id),
|
||||
method: :post,
|
||||
class: 'text-green-400 hover:text-green-300 border border-[#2a2a2a] hover:border-green-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
<% end %>
|
||||
<% if policy(@consolidacao).destroy? %>
|
||||
<%= button_to '🗑️ Excluir',
|
||||
excluir_motorista_consolidacao_path(@consolidacao, consolidacao_motorista_id: cm.id),
|
||||
method: :delete,
|
||||
form: { data: { turbo_confirm: "Excluir DEFINITIVAMENTE #{cm.motorista_nome} desta consolidação? Os lançamentos dele serão apagados e isso não pode ser desfeito." } },
|
||||
class: 'text-red-400 hover:text-red-300 border border-[#2a2a2a] hover:border-red-500/50 px-3 py-2 rounded-lg text-xs min-h-[40px]' %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Finalizar — habilita quando tudo classificado. Se sobram veículos pendentes
|
||||
mas já há veículos consolidados, oferece "arquivar pendentes e finalizar". %>
|
||||
<% tudo_completo = @motoristas.all? { |m| m[:completo] } && @motoristas.any? %>
|
||||
<% tem_consolidados = @motoristas.any? { |m| m[:veiculos_fechados] > 0 || m[:veiculos_prontos] > 0 } %>
|
||||
<% tem_pendentes = @motoristas.any? { |m| m[:veiculos_pendentes].any? } %>
|
||||
<div class="mt-8 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl p-5">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<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? %>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<% if !tudo_completo && tem_consolidados && tem_pendentes %>
|
||||
<%= button_to '🔒 Arquivar pendentes e finalizar',
|
||||
finalizar_parcial_consolidacao_path(@consolidacao),
|
||||
method: :post,
|
||||
data: { turbo_confirm: 'Arquivar TODOS os veículos ainda não classificados 100% e finalizar? Eles ficam de fora desta consolidação (você resolve depois). Os veículos consolidados são pagos normalmente.' },
|
||||
class: 'bg-orange-500 hover:bg-orange-600 text-black font-bold px-5 py-3.5 rounded-lg min-h-[48px] flex items-center justify-center text-center' %>
|
||||
<% end %>
|
||||
<%= 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'}" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</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>
|
||||
<p class="text-gray-500 text-sm mt-2 text-center">
|
||||
⚠️ Classifique todas as entregas de todos os motoristas para finalizar tudo<% if tem_consolidados && tem_pendentes %> — ou use <strong>“Arquivar pendentes e finalizar”</strong> para fechar só os veículos já consolidados.<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
<%# app/views/dashboard/index.html.erb %>
|
||||
<%# Fase 3: Dashboard com cards, gráfico Chart.js e cálculo ao vivo %>
|
||||
|
||||
<%# Não cachear no Turbo: o snapshot congelava o gráfico/calendário (DOM já
|
||||
<%# alterado pelo JS, sem reexecutar scripts) e só voltava com F5. %>
|
||||
<% content_for :head do %>
|
||||
<meta name="turbo-cache-control" content="no-cache">
|
||||
|
||||
<%# Flatpickr — CSS no <head> (evita flash sem estilo) + tema escuro oficial %>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/themes/dark.css">
|
||||
<style>
|
||||
/* Ajustes do calendário ao tema Reem (preto/laranja) */
|
||||
.flatpickr-calendar { background: #1a1a1a; border: 1px solid rgba(255,255,255,.1); box-shadow: 0 10px 30px rgba(0,0,0,.5); }
|
||||
.flatpickr-months, .flatpickr-weekdays, .flatpickr-weekday { background: #1a1a1a; color: #9ca3af; }
|
||||
.flatpickr-current-month, .flatpickr-monthDropdown-months, .numInput { color: #fff; }
|
||||
.flatpickr-day { color: #e5e7eb; }
|
||||
.flatpickr-day:hover { background: #2a2a2a; border-color: #2a2a2a; }
|
||||
.flatpickr-day.today { border-color: #f97316; }
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange,
|
||||
.flatpickr-day.selected:hover,
|
||||
.flatpickr-day.startRange:hover,
|
||||
.flatpickr-day.endRange:hover {
|
||||
background: #f97316; border-color: #f97316; color: #0a0a0a;
|
||||
}
|
||||
.flatpickr-day.inRange { background: rgba(249,115,22,.18); border-color: rgba(249,115,22,.18); box-shadow: -5px 0 0 rgba(249,115,22,.18), 5px 0 0 rgba(249,115,22,.18); }
|
||||
.flatpickr-day.flatpickr-disabled, .flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay { color: #4b5563; }
|
||||
.flatpickr-months .flatpickr-prev-month svg, .flatpickr-months .flatpickr-next-month svg { fill: #f97316; }
|
||||
</style>
|
||||
<% end %>
|
||||
|
||||
<%# Quick-ranges no formato YYYY-MM-DD para os atalhos do filtro %>
|
||||
<% hoje = Date.current %>
|
||||
<% atalhos = {
|
||||
'Hoje' => [hoje, hoje],
|
||||
'7 dias' => [hoje - 6, hoje],
|
||||
'Este mês' => [hoje.beginning_of_month, hoje],
|
||||
'30 dias' => [hoje - 29, hoje]
|
||||
} %>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
||||
<%# Header + filtro por faixa de datas %>
|
||||
@@ -14,20 +53,72 @@
|
||||
</div>
|
||||
|
||||
<%# Filtro de calendário — seleciona uma faixa de datas %>
|
||||
<form id="dashboard-filtro-form" method="get" action="<%= dashboard_path %>" class="flex items-center gap-2">
|
||||
<form id="dashboard-filtro-form" method="get" action="<%= dashboard_path %>" data-turbo="false" class="flex flex-wrap items-center gap-2">
|
||||
<div class="relative">
|
||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-orange-500">📅</span>
|
||||
<input type="text" id="periodo-range" readonly placeholder="Selecione o período"
|
||||
value="<%= @periodo_inicio.strftime('%d/%m/%Y') %> até <%= @periodo_fim.strftime('%d/%m/%Y') %>"
|
||||
class="cursor-pointer pl-4 pr-10 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl
|
||||
text-white text-sm w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
|
||||
text-white text-sm w-full sm:w-[240px] focus:outline-none focus:border-orange-500 placeholder-gray-500">
|
||||
</div>
|
||||
<input type="hidden" name="inicio" id="inicio-hidden" value="<%= @periodo_inicio.strftime('%Y-%m-%d') %>">
|
||||
<input type="hidden" name="fim" id="fim-hidden" value="<%= @periodo_fim.strftime('%Y-%m-%d') %>">
|
||||
|
||||
<%# Atalhos rápidos de período %>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<% atalhos.each do |label, (ini, fim)| %>
|
||||
<% ativo = @periodo_inicio == ini && @periodo_fim == fim %>
|
||||
<%= link_to label,
|
||||
dashboard_path(inicio: ini.strftime('%Y-%m-%d'), fim: fim.strftime('%Y-%m-%d'), operacoes: @operacao_filtro),
|
||||
data: { turbo: false },
|
||||
class: "px-3 py-2.5 rounded-xl text-sm whitespace-nowrap border #{ativo ? 'bg-orange-500 text-black border-orange-500 font-bold' : 'bg-[#1a1a1a] text-gray-300 border-white/10 hover:border-orange-500'}" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Filtro de operação — multi-seleção; submete o form ao mudar %>
|
||||
<% if @operacoes.any? %>
|
||||
<details class="relative" id="operacao-dropdown">
|
||||
<summary class="cursor-pointer list-none pl-4 pr-4 py-2.5 bg-[#1a1a1a] border border-white/10 rounded-xl text-white text-sm whitespace-nowrap hover:border-orange-500">
|
||||
🗂️ <%= @operacao_filtro.present? ? "#{@operacao_filtro.size} operação(ões)" : 'Todas as operações' %>
|
||||
</summary>
|
||||
<div class="absolute right-0 mt-2 z-30 w-72 max-h-80 overflow-y-auto bg-[#1a1a1a] border border-white/10 rounded-xl p-2 shadow-xl">
|
||||
<%# Limpar seleção — recarrega o dashboard sem filtro de operação %>
|
||||
<div class="flex items-center justify-between px-2 pb-2 mb-1 border-b border-white/10">
|
||||
<span class="text-xs uppercase tracking-wider text-gray-400">Operações</span>
|
||||
<% if @operacao_filtro.present? %>
|
||||
<%= link_to '✕ Limpar', dashboard_path(inicio: @periodo_inicio.strftime('%Y-%m-%d'), fim: @periodo_fim.strftime('%Y-%m-%d')),
|
||||
data: { turbo: false }, class: 'text-xs text-orange-400 hover:text-orange-300 font-semibold' %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<p class="px-2 pt-2 pb-1 text-xs font-bold uppercase tracking-wider text-gray-400"><%= titulo %></p>
|
||||
<% ops.each do |op| %>
|
||||
<label class="flex items-center gap-2 text-white text-sm hover:bg-[#0a0a0a] rounded px-2 py-2 cursor-pointer">
|
||||
<input type="checkbox" name="operacoes[]" value="<%= op[:tabela] %>"
|
||||
<%= 'checked' if @operacao_filtro.include?(op[:tabela]) %>
|
||||
class="op-filtro rounded text-orange-500 bg-[#0a0a0a] border-white/10">
|
||||
<%= op[:label] %>
|
||||
</label>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<%# Imprimir relatório financeiro do período/operação atuais %>
|
||||
<%= link_to '🖨️ Relatório',
|
||||
dashboard_relatorio_financeiro_path(
|
||||
inicio: @periodo_inicio.strftime('%Y-%m-%d'),
|
||||
fim: @periodo_fim.strftime('%Y-%m-%d'),
|
||||
operacoes: @operacao_filtro),
|
||||
data: { turbo: false },
|
||||
class: 'pl-4 pr-4 py-2.5 bg-orange-500 hover:bg-orange-600 text-black font-bold rounded-xl text-sm whitespace-nowrap' %>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<%# CARDS GRANDES — KPIs %>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
|
||||
<%# Card 1: Valor estimado %>
|
||||
<div class="sm:col-span-2 xl:col-span-1 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
|
||||
@@ -38,19 +129,32 @@
|
||||
<%= moeda(@valor_estimado) %>
|
||||
</p>
|
||||
<p class="text-orange-200 text-sm">
|
||||
<%= @entregas_pagas %> entregas pagas
|
||||
<%= @entregas_pagas %> entregas concluídas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Card destacado: total consolidado e pago (consolidações finalizadas do período) %>
|
||||
<div class="sm:col-span-2 xl:col-span-1 bg-[#1a1a1a] rounded-2xl border border-green-500/30 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">💼 Consolidado / Pago</p>
|
||||
<p class="text-3xl font-black text-white mb-1"><%= moeda(@fin_custo_total) %></p>
|
||||
<p class="text-gray-400 text-xs mb-2">consolidado no período</p>
|
||||
<div class="flex items-center gap-2 text-sm border-t border-white/5 pt-2">
|
||||
<span class="text-green-400 font-semibold"><%= moeda(@fin_pago) %></span>
|
||||
<span class="text-gray-400">pago</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Card 2: Total de entregas %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">📦 Total Entregas</p>
|
||||
<p class="text-4xl font-black text-white mb-1"><%= @total_entregas %></p>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-green-400"><%= @entregas_pagas %> pagas</span>
|
||||
<span class="text-gray-600">·</span>
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span class="text-green-400"><%= @entregas_pagas %> entregues</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span class="text-yellow-500"><%= @entregas_pendentes %> pendentes</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span class="text-red-400"><%= @entregas_falhadas %> falhadas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +164,7 @@
|
||||
<p class="text-4xl font-black text-white mb-1"><%= @consolidacoes_mes.count %></p>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-yellow-500"><%= @consolidacoes_abertas %> abertas</span>
|
||||
<span class="text-gray-600">·</span>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span class="text-green-400"><%= @consolidacoes_fechadas %> fechadas</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,44 +173,98 @@
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">🚗 Motoristas</p>
|
||||
<p class="text-4xl font-black text-white mb-1"><%= @motoristas.count %></p>
|
||||
<p class="text-gray-500 text-sm">com entregas no período</p>
|
||||
<p class="text-gray-400 text-sm">com entregas no período</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# KPIs FINANCEIROS (consolidações finalizadas, já com filtro de operação) %>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">💰 Custo total</p>
|
||||
<p class="text-3xl font-black text-white"><%= moeda(@fin_custo_total) %></p>
|
||||
<p class="text-gray-400 text-xs mt-1"><%= @fin_qtd_entregas %> entregas classificadas</p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">🎫 Ticket médio</p>
|
||||
<p class="text-3xl font-black text-white"><%= moeda(@fin_ticket_medio) %></p>
|
||||
<p class="text-gray-400 text-xs mt-1">por entrega</p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">✅ Pago</p>
|
||||
<p class="text-3xl font-black text-green-400"><%= moeda(@fin_pago) %></p>
|
||||
<p class="text-gray-400 text-xs mt-1">já confirmado</p>
|
||||
</div>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<p class="text-gray-400 text-sm font-medium mb-2">🕗 A pagar</p>
|
||||
<p class="text-3xl font-black text-yellow-500"><%= moeda(@fin_pendente) %></p>
|
||||
<p class="text-gray-400 text-xs mt-1">pendente</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
|
||||
<%# Gráfico de linha diário (Chart.js) %>
|
||||
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<%# Gráficos diários (Chart.js) — flex-col p/ preencher a altura do card de motoristas %>
|
||||
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 flex flex-col">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Evolução Diária</h2>
|
||||
<p class="text-gray-400 text-sm">Valor estimado por dia no período</p>
|
||||
<h2 class="text-lg font-semibold text-white">Evolução do custo</h2>
|
||||
<p class="text-gray-400 text-sm">Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-64">
|
||||
<div class="relative flex-1 min-h-56">
|
||||
<canvas id="grafico-diario"></canvas>
|
||||
</div>
|
||||
|
||||
<%# Pagamentos por dia (fluxo de caixa) — quando o dinheiro saiu de fato %>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mt-6 mb-4 pt-6 border-t border-white/5">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Pagamentos por dia</h2>
|
||||
<p class="text-gray-400 text-sm">Valor pago aos motoristas por data de pagamento</p>
|
||||
</div>
|
||||
<% if @pag_pago_qtd.positive? %>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 bg-green-500 rounded-sm"></span> Pago no dia</span>
|
||||
<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-0.5 bg-gray-300 rounded"></span> Acumulado</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @pag_pago_qtd.positive? %>
|
||||
<div class="relative flex-1 min-h-48">
|
||||
<canvas id="grafico-pagamentos-dia"></canvas>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex-1 min-h-48 flex flex-col items-center justify-center text-gray-400">
|
||||
<div class="text-3xl mb-2">💸</div>
|
||||
<p class="text-sm">Nenhum pagamento realizado neste período</p>
|
||||
<% if @pag_pend_valor.positive? %>
|
||||
<p class="text-xs mt-1">
|
||||
<span class="text-yellow-500 font-semibold"><%= moeda(@pag_pend_valor) %></span>
|
||||
aguardando pagamento (<%= @pag_pend_qtd %> motorista(s))
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Top motoristas %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">🏆 Top Motoristas</h2>
|
||||
<h2 class="text-lg font-semibold text-white mb-4">🏆 Motoristas</h2>
|
||||
|
||||
<% if @motoristas.any? %>
|
||||
<div class="space-y-3">
|
||||
<% @motoristas.each_with_index do |m, i| %>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs font-bold w-5 text-center
|
||||
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-600' %>">
|
||||
<%= i == 0 ? 'text-yellow-400' : i == 1 ? 'text-gray-300' : i == 2 ? 'text-orange-600' : 'text-gray-400' %>">
|
||||
<%= i + 1 %>
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-white text-sm font-medium truncate"><%= m[:nome].split.first %></span>
|
||||
<span class="text-white text-sm font-medium truncate"><%= m[:nome] %></span>
|
||||
<span class="text-[#f97316] text-sm font-semibold ml-2 flex-shrink-0">
|
||||
<%= moeda(m[:valor]) %>
|
||||
</span>
|
||||
@@ -117,20 +275,172 @@
|
||||
<div class="h-1.5 rounded-full bg-[#f97316]"
|
||||
style="width: <%= [(m[:entregas] / max * 100).round, 100].min %>%"></div>
|
||||
</div>
|
||||
<span class="text-gray-500 text-xs"><%= m[:entregas] %> entregas</span>
|
||||
<span class="text-gray-400 text-xs"><%= m[:entregas] %> entregas</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<div class="text-3xl mb-2">🚚</div>
|
||||
<p class="text-sm">Nenhuma entrega paga neste período</p>
|
||||
<p class="text-sm">Nenhuma entrega concluída neste período</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── FINANCEIRO POR OPERAÇÃO ───────────────────────────── %>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
|
||||
<%# Custo por operação (barra) %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">🏷️ Custo por operação</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">Valor consolidado no período</p>
|
||||
<% if @fin_por_operacao.any? %>
|
||||
<div class="relative h-64"><canvas id="grafico-custo-operacao"></canvas></div>
|
||||
<% else %>
|
||||
<div class="text-center py-8 text-gray-400"><div class="text-3xl mb-2">🏷️</div><p class="text-sm">Sem dados no período</p></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Composição por tipo (rosca) %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">🧩 Composição do custo</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">Por tipo de lançamento</p>
|
||||
<% if @fin_por_tipo.any? %>
|
||||
<div class="relative h-64"><canvas id="grafico-composicao-tipo"></canvas></div>
|
||||
<% else %>
|
||||
<div class="text-center py-8 text-gray-400"><div class="text-3xl mb-2">🧩</div><p class="text-sm">Sem dados no período</p></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Custo por motorista (barra) %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">👤 Custo por motorista</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">Por motorista no período</p>
|
||||
<% if @fin_por_motorista.any? %>
|
||||
<div class="relative h-64"><canvas id="grafico-custo-motorista"></canvas></div>
|
||||
<% else %>
|
||||
<div class="text-center py-8 text-gray-400"><div class="text-3xl mb-2">👤</div><p class="text-sm">Sem dados no período</p></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# ── PAGAMENTOS (pago x pendente) ──────────────────────── %>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
|
||||
<%# Rosca pago vs a pagar (por valor) %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-1">💵 Pagamentos</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">Pagos por data de pagamento · pendentes no período</p>
|
||||
|
||||
<% if (@pag_pago_qtd + @pag_pend_qtd).zero? %>
|
||||
<div class="text-center py-8 text-gray-400">
|
||||
<div class="text-3xl mb-2">🧾</div>
|
||||
<p class="text-sm">Nenhum pagamento ou pendência no período</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="relative h-44 mb-4">
|
||||
<canvas id="grafico-pagamentos"></canvas>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-green-400">● Pago no período</span>
|
||||
<span class="text-white font-semibold">
|
||||
<%= moeda(@pag_pago_valor) %> · <%= @pag_pago_qtd %> pagamento(s)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-400">● A pagar</span>
|
||||
<span class="text-white font-semibold">
|
||||
<%= moeda(@pag_pend_valor) %> · <%= @pag_pend_qtd %> motorista(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Tabelas: pagamentos realizados (com data) + pendentes %>
|
||||
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 space-y-6">
|
||||
|
||||
<%# Pagamentos realizados no período (filtrados por pago_em) %>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white mb-4">✅ Pagamentos realizados no período</h2>
|
||||
<% if @pagamentos_feitos.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||
<th class="py-2 pr-3 font-medium">Pago em</th>
|
||||
<th class="py-2 px-3 font-medium">Motorista</th>
|
||||
<th class="py-2 px-3 font-medium">Consolidação</th>
|
||||
<th class="py-2 px-3 font-medium">Forma</th>
|
||||
<th class="py-2 pl-3 font-medium text-right">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pagamentos-feitos-tbody">
|
||||
<% @pagamentos_feitos.each do |cm| %>
|
||||
<tr class="border-b border-white/5 pagamento-feito-row">
|
||||
<td class="py-2.5 pr-3 text-gray-300 whitespace-nowrap"><%= l cm.pago_em.to_date, format: :short %></td>
|
||||
<td class="py-2.5 px-3 text-white"><%= cm.motorista_nome %></td>
|
||||
<td class="py-2.5 px-3">
|
||||
<%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao),
|
||||
class: 'text-gray-300 hover:text-orange-500' %>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-gray-400"><%= cm.forma_pagamento&.humanize || '—' %></td>
|
||||
<td class="py-2.5 pl-3 text-right text-green-400 font-semibold"><%= moeda(cm.valor_total) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagamentos-feitos-paginacao" class="flex items-center justify-between mt-3 hidden">
|
||||
<p class="text-gray-400 text-xs" id="pagamentos-feitos-info"></p>
|
||||
<div class="flex gap-1" id="pagamentos-feitos-abas"></div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-400 text-sm py-3">Nenhum pagamento realizado neste período.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Pendentes (consolidações finalizadas do período sem pagamento) %>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white mb-4">🕗 Pagamentos pendentes</h2>
|
||||
<% if @pagamentos_pendentes.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 text-left border-b border-white/5">
|
||||
<th class="py-2 pr-3 font-medium">Motorista</th>
|
||||
<th class="py-2 px-3 font-medium">Consolidação</th>
|
||||
<th class="py-2 pl-3 font-medium text-right">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pagamentos-pendentes-tbody">
|
||||
<% @pagamentos_pendentes.each do |cm| %>
|
||||
<tr class="border-b border-white/5 pagamento-pendente-row">
|
||||
<td class="py-2.5 pr-3 text-white"><%= cm.motorista_nome %></td>
|
||||
<td class="py-2.5 px-3">
|
||||
<%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao),
|
||||
class: 'text-gray-300 hover:text-orange-500' %>
|
||||
</td>
|
||||
<td class="py-2.5 pl-3 text-right text-gray-400 font-semibold"><%= moeda(cm.valor_total) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="pagamentos-pendentes-paginacao" class="flex items-center justify-between mt-3 hidden">
|
||||
<p class="text-gray-400 text-xs" id="pagamentos-pendentes-info"></p>
|
||||
<div class="flex gap-1" id="pagamentos-pendentes-abas"></div>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. 🎉</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Por operação %>
|
||||
<% if @por_operacao.any? %>
|
||||
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
|
||||
@@ -141,7 +451,7 @@
|
||||
<div class="bg-[#0a0a0a] rounded-xl p-4 border border-white/5 text-center">
|
||||
<p class="text-2xl font-bold text-[#f97316]"><%= qtd %></p>
|
||||
<p class="text-gray-400 text-xs mt-1 leading-tight"><%= local.to_s.truncate(20) %></p>
|
||||
<p class="text-gray-600 text-xs mt-1"><%= ((qtd / total_op) * 100).round %>%</p>
|
||||
<p class="text-gray-400 text-xs mt-1"><%= ((qtd / total_op) * 100).round %>%</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -150,37 +460,118 @@
|
||||
|
||||
</div>
|
||||
|
||||
<%# Flatpickr — date range picker (CDN, mesmo padrão do Chart.js) %>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
|
||||
<%# Flatpickr — date range picker (JS via CDN; CSS já carregado no <head>) %>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/pt.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const input = document.getElementById('periodo-range');
|
||||
if (!input || typeof flatpickr === 'undefined') return;
|
||||
function initPeriodo() {
|
||||
const input = document.getElementById('periodo-range');
|
||||
if (!input || typeof flatpickr === 'undefined') return;
|
||||
if (input._flatpickr) return; // já inicializado nesta página
|
||||
|
||||
const inicio = document.getElementById('inicio-hidden').value;
|
||||
const fim = document.getElementById('fim-hidden').value;
|
||||
const inicio = document.getElementById('inicio-hidden').value;
|
||||
const fim = document.getElementById('fim-hidden').value;
|
||||
|
||||
// formata Date local → YYYY-MM-DD (evita deslocamento de fuso do toISOString)
|
||||
const toISO = (d) =>
|
||||
d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0');
|
||||
// formata Date local → YYYY-MM-DD (evita deslocamento de fuso do toISOString)
|
||||
const toISO = (d) =>
|
||||
d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0');
|
||||
|
||||
flatpickr(input, {
|
||||
mode: 'range',
|
||||
locale: 'pt',
|
||||
dateFormat: 'd/m/Y',
|
||||
defaultDate: [inicio, fim],
|
||||
maxDate: 'today',
|
||||
onClose: function (selectedDates) {
|
||||
if (selectedDates.length === 2) {
|
||||
document.getElementById('inicio-hidden').value = toISO(selectedDates[0]);
|
||||
document.getElementById('fim-hidden').value = toISO(selectedDates[1]);
|
||||
// Os valores vêm em ISO (YYYY-MM-DD), mas o dateFormat exibido é d/m/Y.
|
||||
// Convertendo para Date local antes evita o flatpickr interpretar errado
|
||||
// (era a causa da caixa mostrar uma data solta em vez do período).
|
||||
const parseISO = (s) => {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
};
|
||||
|
||||
flatpickr(input, {
|
||||
mode: 'range',
|
||||
locale: Object.assign({}, flatpickr.l10ns.pt, { rangeSeparator: ' até ' }),
|
||||
dateFormat: 'd/m/Y',
|
||||
defaultDate: [parseISO(inicio), parseISO(fim)],
|
||||
maxDate: 'today',
|
||||
onClose: function (selectedDates) {
|
||||
if (selectedDates.length === 0) return;
|
||||
// 1 dia selecionado → início e fim iguais; 2 dias → faixa completa.
|
||||
const ini = selectedDates[0];
|
||||
const f = selectedDates[selectedDates.length - 1];
|
||||
document.getElementById('inicio-hidden').value = toISO(ini);
|
||||
document.getElementById('fim-hidden').value = toISO(f);
|
||||
document.getElementById('dashboard-filtro-form').requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Executa no carregamento inicial e também em navegação Turbo (sem F5).
|
||||
initPeriodo();
|
||||
document.addEventListener('turbo:load', initPeriodo);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%# Paginação client-side das tabelas de pagamentos (10 por página) %>
|
||||
<script>
|
||||
(function () {
|
||||
function paginarTabela(prefixo, classeLinha, rotulo) {
|
||||
const tbody = document.getElementById(prefixo + '-tbody');
|
||||
const nav = document.getElementById(prefixo + '-paginacao');
|
||||
if (!tbody || !nav || nav.dataset.iniciado) return;
|
||||
nav.dataset.iniciado = '1';
|
||||
|
||||
const porPagina = 10;
|
||||
const linhas = Array.from(tbody.querySelectorAll('.' + classeLinha));
|
||||
const paginas = Math.ceil(linhas.length / porPagina);
|
||||
if (paginas <= 1) return;
|
||||
|
||||
const abas = document.getElementById(prefixo + '-abas');
|
||||
const info = document.getElementById(prefixo + '-info');
|
||||
nav.classList.remove('hidden');
|
||||
|
||||
function mostrarPagina(p) {
|
||||
linhas.forEach(function (tr, i) {
|
||||
tr.style.display = (i >= (p - 1) * porPagina && i < p * porPagina) ? '' : 'none';
|
||||
});
|
||||
const de = (p - 1) * porPagina + 1;
|
||||
const ate = Math.min(p * porPagina, linhas.length);
|
||||
info.textContent = de + '–' + ate + ' de ' + linhas.length + ' ' + rotulo;
|
||||
abas.querySelectorAll('button').forEach(function (b) {
|
||||
const ativa = Number(b.dataset.pagina) === p;
|
||||
b.className = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ' +
|
||||
(ativa ? 'bg-[#f97316] text-white'
|
||||
: 'bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white');
|
||||
});
|
||||
}
|
||||
|
||||
for (let p = 1; p <= paginas; p++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = p;
|
||||
btn.dataset.pagina = p;
|
||||
btn.addEventListener('click', function () { mostrarPagina(p); });
|
||||
abas.appendChild(btn);
|
||||
}
|
||||
mostrarPagina(1);
|
||||
}
|
||||
|
||||
function initPaginacaoPagamentos() {
|
||||
paginarTabela('pagamentos-feitos', 'pagamento-feito-row', 'pagamentos');
|
||||
paginarTabela('pagamentos-pendentes', 'pagamento-pendente-row', 'pendências');
|
||||
}
|
||||
|
||||
initPaginacaoPagamentos();
|
||||
document.addEventListener('turbo:load', initPaginacaoPagamentos);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%# Filtro de operação — submete o form ao marcar/desmarcar %>
|
||||
<script>
|
||||
(function () {
|
||||
document.querySelectorAll('.op-filtro').forEach(function (c) {
|
||||
c.addEventListener('change', function () {
|
||||
document.getElementById('dashboard-filtro-form').requestSubmit();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -189,7 +580,15 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script>
|
||||
// IIFE: evita "Identifier already declared" quando o Turbo re-executa o script
|
||||
// ao navegar (o que antes fazia o gráfico não aparecer).
|
||||
(function () {
|
||||
const ctx = document.getElementById('grafico-diario');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
|
||||
// Destrói gráfico anterior preso ao mesmo canvas (evita "Canvas is already
|
||||
// in use" quando o Turbo reexecuta o script ao navegar).
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
|
||||
const labels = <%= raw @grafico_diario[:labels].to_json %>;
|
||||
const valores = <%= raw @grafico_diario[:valores].to_json %>;
|
||||
@@ -231,7 +630,7 @@
|
||||
label: (ctx) => {
|
||||
const idx = ctx.dataIndex;
|
||||
return [
|
||||
` Valor: R$ ${ctx.parsed.y.toFixed(2).replace('.', ',')}`,
|
||||
` Valor: R$ ${ctx.parsed.y.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
|
||||
` Entregas: ${qtds[idx]}`
|
||||
];
|
||||
}
|
||||
@@ -254,4 +653,192 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período) %>
|
||||
<script>
|
||||
(function () {
|
||||
const ctx = document.getElementById('grafico-pagamentos-dia');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
|
||||
const labels = <%= raw @grafico_pagamentos_dia[:labels].to_json %>;
|
||||
const valores = <%= raw @grafico_pagamentos_dia[:valores].to_json %>;
|
||||
const acumulado = <%= raw @grafico_pagamentos_dia[:acumulado].to_json %>;
|
||||
|
||||
const brl = (v) => `R$ ${v.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Pago no dia',
|
||||
data: valores,
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.65)',
|
||||
hoverBackgroundColor: '#22c55e',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
maxBarThickness: 28,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'Acumulado',
|
||||
data: acumulado,
|
||||
type: 'line',
|
||||
borderColor: '#d1d5db',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 4],
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0.3,
|
||||
order: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderColor: '#22c55e',
|
||||
borderWidth: 1,
|
||||
titleColor: '#9ca3af',
|
||||
bodyColor: '#ffffff',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (c) => ` ${c.dataset.label}: ${brl(c.parsed.y)}`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false, drawBorder: false },
|
||||
ticks: { color: '#6b7280', font: { size: 11 }, maxTicksLimit: 10 }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
|
||||
ticks: { color: '#6b7280', font: { size: 11 }, callback: (v) => `R$ ${v.toFixed(0)}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%# Rosca de pagamentos (pago x a pagar, por valor) %>
|
||||
<script>
|
||||
(function () {
|
||||
const ctx = document.getElementById('grafico-pagamentos');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
|
||||
const pago = <%= @pag_pago_valor.to_f %>;
|
||||
const aPagar = <%= @pag_pend_valor.to_f %>;
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Pago', 'A pagar'],
|
||||
datasets: [{
|
||||
data: [pago, aPagar],
|
||||
backgroundColor: ['#22c55e', '#374151'],
|
||||
borderColor: '#1a1a1a',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '62%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderColor: '#22c55e',
|
||||
borderWidth: 1,
|
||||
titleColor: '#9ca3af',
|
||||
bodyColor: '#ffffff',
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (c) => ` ${c.label}: R$ ${c.parsed.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%# ── Gráficos financeiros por operação ─────────────────────── %>
|
||||
<script>
|
||||
(function () {
|
||||
const moedaTick = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0 });
|
||||
const moedaFull = (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const tooltipBase = {
|
||||
backgroundColor: '#0a0a0a', borderColor: '#f97316', borderWidth: 1,
|
||||
titleColor: '#9ca3af', bodyColor: '#ffffff', padding: 12
|
||||
};
|
||||
const eixoCinza = { grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false }, ticks: { color: '#6b7280', font: { size: 11 } } };
|
||||
|
||||
function barraHorizontal(id, labels, valores, cor) {
|
||||
const ctx = document.getElementById(id);
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: { labels: labels, datasets: [{ data: valores, backgroundColor: cor, borderRadius: 4 }] },
|
||||
options: {
|
||||
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: Object.assign({}, tooltipBase, { callbacks: { label: (c) => ' ' + moedaFull(c.parsed.x) } })
|
||||
},
|
||||
scales: {
|
||||
x: Object.assign({}, eixoCinza, { ticks: Object.assign({}, eixoCinza.ticks, { callback: moedaTick }) }),
|
||||
y: eixoCinza
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 1) Custo por operação
|
||||
barraHorizontal('grafico-custo-operacao',
|
||||
<%= raw @fin_por_operacao.map { |l, _| l }.to_json %>,
|
||||
<%= raw @fin_por_operacao.map { |_, v| v.to_f }.to_json %>,
|
||||
'#f97316');
|
||||
|
||||
// 2) Custo por motorista
|
||||
barraHorizontal('grafico-custo-motorista',
|
||||
<%= raw @fin_por_motorista.map { |n, _| n }.to_json %>,
|
||||
<%= raw @fin_por_motorista.map { |_, v| v.to_f }.to_json %>,
|
||||
'#9a3412');
|
||||
|
||||
// 3) Composição por tipo (rosca)
|
||||
(function () {
|
||||
const ctx = document.getElementById('grafico-composicao-tipo');
|
||||
if (!ctx || typeof Chart === 'undefined') return;
|
||||
Chart.getChart(ctx)?.destroy();
|
||||
const labels = <%= raw @fin_por_tipo.map { |h| h[:label] }.to_json %>;
|
||||
const valores = <%= raw @fin_por_tipo.map { |h| h[:valor] }.to_json %>;
|
||||
const cores = ['#f97316', '#9a3412', '#fed7aa', '#374151', '#9333ea'];
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: { labels: labels, datasets: [{ data: valores, backgroundColor: cores, borderColor: '#1a1a1a', borderWidth: 3 }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: '#9ca3af', font: { size: 11 }, padding: 12 } },
|
||||
tooltip: Object.assign({}, tooltipBase, { callbacks: { label: (c) => ' ' + c.label + ': ' + moedaFull(c.parsed) } })
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -4,12 +4,8 @@
|
||||
|
||||
<%# Logo / Header %>
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-[#f97316] mb-4">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<%= image_tag '/logo-reem.png', alt: 'Reem Transporte',
|
||||
class: 'h-20 w-auto mx-auto mb-4 object-contain' %>
|
||||
<h1 class="text-3xl font-bold text-white">Reem Logística</h1>
|
||||
<p class="text-gray-400 mt-1 text-sm">Sistema de Controle de Custos</p>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,9 @@
|
||||
transform -translate-x-full md:translate-x-0 transition-transform duration-300">
|
||||
|
||||
<%# Logo / Marca %>
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-[#2a2a2a]">
|
||||
<div class="w-9 h-9 bg-orange-500 rounded-lg flex items-center justify-center font-black text-black text-lg">G</div>
|
||||
<div>
|
||||
<p class="font-bold text-white text-sm leading-tight">Reem Transporte</p>
|
||||
<p class="text-orange-500 text-xs">Logística</p>
|
||||
</div>
|
||||
<div class="px-4 py-5 border-b border-[#2a2a2a]">
|
||||
<%= image_tag '/logo-reem.png', alt: 'Reem Transporte',
|
||||
class: 'h-14 w-auto mx-auto object-contain' %>
|
||||
</div>
|
||||
|
||||
<%# Usuário logado %>
|
||||
@@ -28,6 +25,7 @@
|
||||
<%= current_user.admin? ? 'bg-orange-500 text-black' :
|
||||
current_user.gerente? ? 'bg-orange-800 text-white' :
|
||||
current_user.operador? ? 'bg-gray-700 text-white' :
|
||||
current_user.externo? ? 'bg-blue-900 text-blue-200 border border-blue-500' :
|
||||
'bg-gray-900 text-orange-400 border border-orange-500' %>">
|
||||
<%= current_user.role_label %>
|
||||
</span>
|
||||
@@ -36,10 +34,16 @@
|
||||
<%# Navegação %>
|
||||
<nav class="px-3 py-4 space-y-1 flex-1 overflow-y-auto">
|
||||
|
||||
<%# Dashboard — admin/gerente/operador %>
|
||||
<%# Dashboard — admin/gerente/operador/externo (todos menos motorista) %>
|
||||
<% unless current_user.motorista? %>
|
||||
<%= nav_link_to '📊 Dashboard', dashboard_path %>
|
||||
<%= nav_link_to '📈 Operações', operacoes_dashboard_path %>
|
||||
<% end %>
|
||||
|
||||
<%# Consolidações — só quem pode consolidar (exclui o externo) %>
|
||||
<% if current_user.pode_consolidar? %>
|
||||
<%= nav_link_to '📦 Consolidações', consolidacoes_path %>
|
||||
<%= nav_link_to '📁 Arquivadas', arquivadas_consolidacoes_path %>
|
||||
<% end %>
|
||||
|
||||
<%# Motorista %>
|
||||
@@ -54,6 +58,10 @@
|
||||
<%= nav_link_to '👥 Usuários', admin_usuarios_path %>
|
||||
<%= nav_link_to '⚙️ Configurações', admin_configuracoes_path %>
|
||||
<%= nav_link_to '🔍 Auditoria', admin_auditoria_logs_path %>
|
||||
<%= nav_link_to '📥 Planilha SimpliRoute', admin_planilha_simpli_route_path %>
|
||||
<% if current_user.admin? %>
|
||||
<%= nav_link_to '✏️ Editar Lançamento', admin_edicao_lancamento_path %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
@@ -67,6 +75,21 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<%# Botão flutuante (desktop) — esconde/mostra a sidebar do site inteiro.
|
||||
Em repouso mostra uma ALÇA fina laranja (indicador de que dá p/ recolher);
|
||||
ao chegar perto com o mouse, ela expande no botão com a seta. %>
|
||||
<div id="sidebar-edge" class="hidden md:flex group fixed top-1/2 -translate-y-1/2 z-40 h-32 w-12 items-center">
|
||||
<button id="sidebar-toggle-btn" type="button" onclick="toggleSidebarCollapse()"
|
||||
class="flex items-center justify-center rounded-r-lg shadow-lg transition-all duration-200
|
||||
w-1.5 h-14 bg-orange-500/60 text-transparent
|
||||
group-hover:w-7 group-hover:h-16 group-hover:bg-[#1a1a1a] group-hover:text-orange-500
|
||||
group-hover:border group-hover:border-orange-500/70
|
||||
focus:w-7 focus:h-16 focus:bg-[#1a1a1a] focus:text-orange-500"
|
||||
title="Esconder/mostrar menu" aria-label="Esconder ou mostrar o menu">
|
||||
<span id="sidebar-toggle-icon" class="text-lg leading-none">◀</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%# Topbar mobile %>
|
||||
<header class="md:hidden fixed top-0 left-0 right-0 z-30 bg-[#111111] border-b border-[#2a2a2a] px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleSidebar()"
|
||||
@@ -74,8 +97,7 @@
|
||||
☰
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center font-black text-black text-sm">G</div>
|
||||
<span class="font-bold text-white text-sm">Reem Logística</span>
|
||||
<%= image_tag '/logo-reem.png', alt: 'Reem Transporte', class: 'h-8 w-auto object-contain' %>
|
||||
</div>
|
||||
<div class="w-12"></div>
|
||||
</header>
|
||||
@@ -83,10 +105,24 @@
|
||||
<div class="h-14 md:hidden"></div>
|
||||
|
||||
<script>
|
||||
// Mobile: abre/fecha a sidebar por cima do conteúdo (hamburguer).
|
||||
function toggleSidebar() {
|
||||
const s = document.getElementById('sidebar');
|
||||
const o = document.getElementById('sidebar-overlay');
|
||||
s.classList.toggle('-translate-x-full');
|
||||
o.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Desktop: recolhe/expande a sidebar (estado salvo p/ o site inteiro).
|
||||
function toggleSidebarCollapse() {
|
||||
const collapsed = document.documentElement.classList.toggle('sidebar-collapsed');
|
||||
try { localStorage.setItem('sidebarCollapsed', collapsed ? '1' : '0'); } catch (e) {}
|
||||
syncSidebarToggleIcon();
|
||||
}
|
||||
function syncSidebarToggleIcon() {
|
||||
const icon = document.getElementById('sidebar-toggle-icon');
|
||||
if (icon) icon.textContent =
|
||||
document.documentElement.classList.contains('sidebar-collapsed') ? '▶' : '◀';
|
||||
}
|
||||
syncSidebarToggleIcon();
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<meta name="csrf-token" content="<%= form_authenticity_token %>">
|
||||
<title><%= content_for?(:title) ? "#{yield(:title)} | Reem Logística" : "Reem Logística" %></title>
|
||||
|
||||
<%# Favicon / ícone do app (marca Reem — van laranja) %>
|
||||
<link rel="icon" href="/favicon.png" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/favicon.png">
|
||||
|
||||
<%# Tailwind via CDN em desenvolvimento — compilar em produção %>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
@@ -33,7 +37,12 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
/* Fonte base maior para conforto de leitura (Tailwind usa rem → escala tudo).
|
||||
16px no desktop e 18px no celular, conforme o padrão de design do projeto. */
|
||||
html { font-size: 17px; }
|
||||
@media (max-width: 767px) { html { font-size: 18px; } }
|
||||
|
||||
body { font-family: 'Inter', sans-serif; -webkit-text-size-adjust: 100%; }
|
||||
/* Scrollbar tema escuro */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0a0a0a; }
|
||||
@@ -46,13 +55,70 @@
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
/* Sidebar recolhível no desktop (botão flutuante esconde/mostra o menu).
|
||||
O estado fica na classe .sidebar-collapsed do <html>. */
|
||||
@media (min-width: 768px) {
|
||||
main, footer { transition: margin-left .3s ease; }
|
||||
#sidebar-edge { left: 16rem; transition: left .3s ease; }
|
||||
.sidebar-collapsed #sidebar { transform: translateX(-100%); }
|
||||
.sidebar-collapsed main, .sidebar-collapsed footer { margin-left: 0; }
|
||||
.sidebar-collapsed #sidebar-edge { left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
<%= yield :head %>
|
||||
|
||||
<%# Aplica o estado recolhido da sidebar antes de pintar (evita "piscar") %>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
if (localStorage.getItem('sidebarCollapsed') === '1')
|
||||
document.documentElement.classList.add('sidebar-collapsed');
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen">
|
||||
<%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na
|
||||
página (aparecia como uma barra laranja "quebrando" o rodapé no celular);
|
||||
tabelas largas já rolam nos próprios contêineres overflow-x-auto. %>
|
||||
<body class="bg-[#0a0a0a] text-white min-h-screen overflow-x-hidden">
|
||||
|
||||
<%# ── Animação de abertura (uma vez, logo após o login) ── %>
|
||||
<% if @mostrar_splash %>
|
||||
<div id="splash-login"
|
||||
class="fixed inset-0 z-[100] bg-[#0a0a0a] flex items-center justify-center"
|
||||
onclick="encerrarSplash()">
|
||||
<%# mix-blend-screen "derruba" o fundo preto do vídeo: o preto vira a cor do
|
||||
overlay e só o logo (pixels claros) aparece, sem a caixa retangular. %>
|
||||
<video id="splash-video" autoplay muted playsinline
|
||||
style="mix-blend-mode: screen;"
|
||||
class="max-w-[90vw] max-h-[90vh] w-auto h-auto object-contain">
|
||||
<source src="/login-animacao.webm" type="video/webm">
|
||||
<source src="/login-animacao.mp4" type="video/mp4">
|
||||
</video>
|
||||
<button type="button" onclick="event.stopPropagation(); encerrarSplash()"
|
||||
class="absolute bottom-6 right-6 text-gray-400 hover:text-white text-sm border border-white/20 rounded-lg px-4 py-2">
|
||||
Pular ✕
|
||||
</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var splash = document.getElementById('splash-login');
|
||||
var video = document.getElementById('splash-video');
|
||||
if (!splash) return;
|
||||
window.encerrarSplash = function () {
|
||||
splash.style.transition = 'opacity .4s';
|
||||
splash.style.opacity = '0';
|
||||
setTimeout(function () { splash.remove(); }, 400);
|
||||
};
|
||||
// Some quando o vídeo termina; fallback de segurança de 8s.
|
||||
if (video) video.addEventListener('ended', window.encerrarSplash);
|
||||
setTimeout(window.encerrarSplash, 8000);
|
||||
})();
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<%# ── Navbar ─────────────────────────────────────────── %>
|
||||
<% if user_signed_in? %>
|
||||
@@ -81,6 +147,22 @@
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
<%# ── Rodapé ───────────────────────────────────────────── %>
|
||||
<footer class="<%= 'md:ml-64' if user_signed_in? %> border-t border-[#2a2a2a] py-5 px-4 md:px-8 text-center">
|
||||
<p class="text-gray-500 text-xs">
|
||||
© <%= Date.current.year %>
|
||||
<a href="https://victor.paravatti.com.br/" target="_blank" rel="noopener noreferrer"
|
||||
class="text-orange-500 font-semibold tracking-wide hover:text-orange-400 transition-colors">VK TECNOLOGIA</a>
|
||||
— Todos os direitos reservados.
|
||||
</p>
|
||||
<p class="text-gray-600 text-xs mt-1">
|
||||
Desenvolvido por
|
||||
<a href="https://victor.paravatti.com.br/" target="_blank" rel="noopener noreferrer"
|
||||
class="text-orange-500 hover:text-orange-400 transition-colors">VK Tecnologia</a>
|
||||
· Reem Logística
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<%# Auto-hide flash após 4s (JS simples) %>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
|
||||