Files
Reem-Notas/app/views/dashboard/index.html.erb

949 lines
44 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<%# 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 %>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-white">Dashboard</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @periodo_inicio.strftime('%d/%m/%Y') %> <%= @periodo_fim.strftime('%d/%m/%Y') %>
· Atualizado às <%= Time.now.strftime('%H:%M') %>
</p>
</div>
<%# Filtro de calendário — seleciona uma faixa de datas %>
<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"><%= icone :calendario, espaco: false %></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-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">
<%= icone :pasta %> <%= @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 rotulo(:fechar, 'Limpar', cor: nil, tamanho: 'w-3.5 h-3.5'), 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 — só para quem
pode ver custo (permissão `dashboard.financeiro`). %>
<% if @ver_financeiro %>
<%= link_to rotulo(:imprimir, 'Relatório', cor: nil),
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' %>
<% end %>
</form>
</div>
<%# CARDS GRANDES — KPIs
auto-fit + minmax em vez de `xl:grid-cols-5`: o breakpoint xl olha a JANELA
(1280px), mas com a sidebar aberta sobram ~960px de conteúdo — 5 colunas de
~176px, e "R$ 82.692,00" em text-4xl era cortado pelo overflow-hidden do
card. Com auto-fit o número de colunas cai sozinho quando o menu está
aberto e sobe quando ele é recolhido, sem depender de breakpoint. %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] gap-4">
<%# Card 1: Valor estimado — sempre 2 faixas: é o maior número da tela %>
<div class="sm:col-span-2 bg-[#f97316] rounded-2xl p-6 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-transparent"></div>
<div class="relative">
<p class="text-orange-100 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :dinheiro, cor: nil, espaco: false %> Valor Estimado</p>
<%# break-words: se ainda faltar espaço, quebra a linha em vez de cortar %>
<p class="text-3xl lg:text-4xl font-black text-white mb-1 break-words">
<%= moeda(@valor_estimado) %>
</p>
<%# "Atendidas" = concluídas + falhadas (motorista foi ao local e fechou
a visita). É a mesma base que a consolidação paga, por isso o rótulo
NÃO pode voltar a dizer "concluídas": o valor acima inclui as falhas. %>
<p class="text-orange-200 text-sm">
<%= @entregas_atendidas %> entregas atendidas
</p>
<p class="text-orange-200/80 text-xs">
<%= @entregas_pagas %> concluídas · <%= @entregas_falhadas %> falhadas
</p>
</div>
</div>
<%# Card destacado: total consolidado e pago (consolidações finalizadas do período) %>
<% if @ver_financeiro %>
<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 flex items-center gap-2"><%= icone :consolidado_pago, espaco: false %> 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>
<% end %>
<%# 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 flex items-center gap-2"><%= icone :consolidacoes, espaco: false %> Total Entregas</p>
<p class="text-4xl font-black text-white mb-1"><%= @total_entregas %></p>
<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>
<%# Clicável: leva à lista das entregas em aberto, no MESMO recorte do card
(período + filtro de operação). O número existia sem nenhuma tela por trás. %>
<%= link_to "#{@entregas_pendentes} pendentes",
dashboard_pendentes_path(inicio: @periodo_inicio.strftime('%Y-%m-%d'),
fim: @periodo_fim.strftime('%Y-%m-%d'),
operacoes: @operacao_filtro.presence),
data: { turbo: false },
title: 'Ver quais são as entregas em aberto',
class: 'text-yellow-500 underline decoration-dotted underline-offset-4 hover:text-yellow-300' %>
<span class="text-gray-400">·</span>
<span class="text-red-400"><%= @entregas_falhadas %> falhadas</span>
</div>
<%# Conta VISITAS (é por ida ao local que o motorista recebe). O dashboard
de Operações conta NOTAS — a diferença são retentativas da mesma NF. %>
<p class="text-xs text-gray-500 mt-2">
visitas atendidas · <span class="text-gray-400"><%= @notas_atendidas %> notas fiscais</span>
</p>
</div>
<%# Card 3: Consolidações %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :planilha, espaco: false %> Consolidações</p>
<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-400">·</span>
<span class="text-green-400"><%= @consolidacoes_fechadas %> fechadas</span>
</div>
</div>
<%# Card 4: Motoristas ativos %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<p class="text-gray-400 text-sm font-medium mb-2 flex items-center gap-2"><%= icone :veiculo, espaco: false %> Motoristas</p>
<p class="text-4xl font-black text-white mb-1"><%= @motoristas.count %></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) %>
<% if @ver_financeiro %>
<div class="grid grid-cols-[repeat(auto-fit,minmax(13rem,1fr))] 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 flex items-center gap-2"><%= icone :dinheiro, espaco: false %> 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 flex items-center gap-2"><%= icone :ticket, espaco: false %> 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 flex items-center gap-2"><%= icone :sucesso, cor: 'text-green-500', espaco: false %> 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 flex items-center gap-2"><%= icone :relogio, cor: 'text-amber-400', espaco: false %> 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>
<% end %>
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
<%# 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 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-400">
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
</div>
</div>
<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.
É valor pago: fica atrás da mesma permissão dos demais blocos de custo. %>
<% if @ver_financeiro %>
<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="mb-2"><%= icone :custo, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></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 %>
<% end %>
</div>
<%# Top motoristas — DUAS ABAS sobre o mesmo ranking:
• Estimado = entregas atendidas × preço da entrega (mesma base do card
laranja do topo).
• Consolidado = o que foi REALMENTE fechado nas consolidações do período,
já com bônus/desconto/retirada aplicados, e a quantidade
exata de entregas que entrou no fechamento.
Os dois números divergem DE PROPÓSITO: o estimado cobre tudo que foi
atendido no período; o consolidado, só o que já entrou em consolidação
finalizada. Por isso cada aba diz de onde vem o seu número. %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex flex-wrap items-center justify-between gap-2 mb-4">
<h2 class="text-lg font-semibold text-white flex items-center gap-2"><%= icone :ranking, espaco: false %> Motoristas</h2>
<%# Mesmas pílulas da paginação de pagamentos (as classes estão duplicadas
no JS que troca a aba — mexeu aqui, mexa lá). %>
<div id="ranking-abas" class="flex gap-1.5">
<button type="button" data-aba="estimado"
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#f97316] text-white">
Estimado
</button>
<button type="button" data-aba="consolidado"
class="px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white">
Consolidado
</button>
</div>
</div>
<div id="ranking-painel-estimado">
<p class="text-gray-400 text-xs mb-3">Entregas atendidas × preço da entrega</p>
<%= render 'ranking_motoristas',
itens: @motoristas,
vazio: 'Nenhuma entrega atendida neste período' %>
</div>
<%# Sem JS a aba fica escondida — aceitável: o mesmo dado consolidado
também aparece no gráfico "Custo por motorista" logo abaixo. %>
<div id="ranking-painel-consolidado" class="hidden">
<p class="text-gray-400 text-xs mb-3">Valor fechado nas consolidações do período</p>
<%= render 'ranking_motoristas',
itens: @ranking_consolidado,
vazio: 'Nenhuma consolidação finalizada neste período' %>
</div>
</div>
</div>
<% if @ver_financeiro %>
<%# ── 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 flex items-center gap-2"><%= icone :tag, espaco: false %> 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="mb-2"><%= icone :tag, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></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 flex items-center gap-2"><%= icone :composicao, espaco: false %> Composição do custo</h2>
<p class="text-gray-400 text-sm mb-4">Por tipo de lançamento, antes dos descontos</p>
<% if @fin_por_tipo.any? %>
<div class="relative h-64"><canvas id="grafico-composicao-tipo"></canvas></div>
<%# A rosca soma os pilares que ACRESCENTAM valor; o desconto subtrai e
não cabe como fatia (ver DashboardController#carregar_dados_financeiro).
Quando existe desconto, a diferença entre a soma das fatias e o
"Custo total" do topo é exatamente esta linha — dita aqui, no lugar
onde ela aparece, e não em documentação (diretriz 1). %>
<% if @fin_descontos.to_f.positive? %>
<p class="text-gray-400 text-xs mt-4 pt-3 border-t border-white/5">
<span class="text-gray-300 font-semibold"> <%= moeda(@fin_descontos) %></span>
em descontos, já abatidos no Custo total.
</p>
<% end %>
<% else %>
<div class="text-center py-8 text-gray-400"><div class="mb-2"><%= icone :composicao, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></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 flex items-center gap-2"><%= icone :pessoa, espaco: false %> 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="mb-2"><%= icone :pessoa, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></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 flex items-center gap-2"><%= icone :pagamento, espaco: false %> 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="mb-2"><%= icone :nota_fiscal, tamanho: 'w-8 h-8', cor: 'text-gray-600', espaco: false %></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 flex items-center gap-2"><%= icone :sucesso, cor: 'text-green-500', espaco: false %> 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 flex-wrap items-center justify-between gap-2 mt-3 hidden">
<p class="text-gray-400 text-xs" id="pagamentos-feitos-info"></p>
<%# flex-wrap: com muitas páginas a fila de abas passava da largura da tela
e o dashboard inteiro ganhava scroll horizontal (1338px em 1280px). %>
<div class="flex flex-wrap justify-end 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 flex items-center gap-2"><%= icone :relogio, cor: 'text-amber-400', espaco: false %> 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 flex-wrap items-center justify-between gap-2 mt-3 hidden">
<p class="text-gray-400 text-xs" id="pagamentos-pendentes-info"></p>
<div class="flex flex-wrap justify-end gap-1" id="pagamentos-pendentes-abas"></div>
</div>
<% else %>
<p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. <%= icone :sucesso, cor: 'text-green-500', espaco: false %></p>
<% end %>
</div>
</div>
</div>
<% end %>
<%# Por operação (contagem de entregas — não é custo, fica para todos) %>
<% if @por_operacao.any? %>
<div class="bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<h2 class="text-lg font-semibold text-white mb-4 flex items-center gap-2"><%= icone :operacao, espaco: false %> Entregas por Local</h2>
<div class="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
<% total_op = @por_operacao.values.sum.to_f %>
<% @por_operacao.each do |local, qtd| %>
<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-400 text-xs mt-1"><%= ((qtd / total_op) * 100).round %>%</p>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<%# 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 () {
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;
// 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');
// 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;
// min-w/min-h 44px: são os alvos de toque mais apertados do sistema
// (medidos 63 abaixo de 44px no celular em 28/08/2026) e ficam lado a
// lado, então errar o número da página é fácil com o dedo em movimento.
b.className = 'min-w-[44px] min-h-[44px] px-3 py-1.5 rounded-lg text-xs font-semibold ' +
'inline-flex items-center justify-center 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>
<%# Abas do ranking de motoristas (Estimado × Consolidado) %>
<script>
(function () {
// As classes repetem as do ERB porque a troca de aba reescreve o className
// inteiro — mesmo padrão da paginação de pagamentos acima.
const ATIVA = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#f97316] text-white';
const INATIVA = 'px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors bg-[#0a0a0a] text-gray-400 border border-white/5 hover:text-white';
function initAbasRanking() {
const abas = document.getElementById('ranking-abas');
if (!abas || abas.dataset.iniciado) return;
abas.dataset.iniciado = '1';
const botoes = Array.from(abas.querySelectorAll('button'));
botoes.forEach(function (btn) {
btn.addEventListener('click', function () {
botoes.forEach(function (b) {
const ativa = (b === btn);
const painel = document.getElementById('ranking-painel-' + b.dataset.aba);
b.className = ativa ? ATIVA : INATIVA;
if (painel) painel.classList.toggle('hidden', !ativa);
});
});
});
}
initAbasRanking();
document.addEventListener('turbo:load', initAbasRanking);
})();
</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>
<%# Chart.js via CDN %>
<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 %>;
const qtds = <%= raw @grafico_diario[:qtds].to_json %>;
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Valor (R$)',
data: valores,
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.08)',
borderWidth: 2.5,
pointBackgroundColor: '#f97316',
pointBorderColor: '#1a1a1a',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0a0a0a',
borderColor: '#f97316',
borderWidth: 1,
titleColor: '#9ca3af',
bodyColor: '#ffffff',
padding: 12,
callbacks: {
label: (ctx) => {
const idx = ctx.dataIndex;
return [
` Valor: R$ ${ctx.parsed.y.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
` Entregas: ${qtds[idx]}`
];
}
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
ticks: { color: '#6b7280', font: { size: 11 }, maxTicksLimit: 10 }
},
y: {
grid: { color: 'rgba(255,255,255,0.04)', drawBorder: false },
ticks: {
color: '#6b7280',
font: { size: 11 },
callback: (v) => `R$ ${v.toFixed(0)}`
}
}
}
}
});
})();
</script>
<%# Fluxo de pagamentos: barras (pago no dia) + linha (acumulado no período).
Sem permissão de financeiro os dados não são carregados — o script sai fora. %>
<% if @ver_financeiro %>
<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) — mesmo bloco de permissão. %>
<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>
<% end %>
<%# ── Gráficos financeiros por operação ───────────────────────
Sem a permissão de financeiro os dados nem chegam à página: o controller não
carrega, e este script inteiro fica de fora do HTML. %>
<% if @ver_financeiro %>
<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 %>;
// A cor vem junto do label (DashboardController::CORES_TIPO). Era uma lista
// fixa de 5 cores aqui para 7 tipos possíveis: o Chart.js reciclava e
// "Entrega de Termo" saía com a mesma laranja de "Entrega Normal".
const cores = <%= raw @fin_por_tipo.map { |h| h[:cor] }.to_json %>;
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>
<% end %>