Compare commits

2 Commits
teste ... main

Author SHA256 Message Date
a98e7868d2 Merge pull request 'teste' (#6) from teste into main
Reviewed-on: #6
2026-07-13 12:57:26 -03:00
cb47d1c294 Merge pull request 'teste' (#5) from teste into main
Reviewed-on: #5
2026-07-03 10:52:51 -03:00
7 changed files with 9 additions and 253 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1366,51 +1366,3 @@ docker compose up -d --build
</details> </details>
---
<details>
<summary><strong>🔭 Roadmap — Integrações futuras com a API SimpliRoute (08/07/2026)</strong></summary>
Mapeamento completo da API do SimpliRoute (documentation.simpliroute.com) cruzado com o fluxo
do sistema. **Nada aqui está implementado** — são os próximos passos priorizados. A base
técnica já existe (`SimpliRoute::Client`, `SimpliRoute::PlanilhaCarga`, token via ENV):
cada item vira uma fase própria quando for priorizado.
### 1. 🚀 Carga direta no SimpliRoute (substitui o baixar/importar planilha)
`POST /v1/routes/visits/` aceita criação **em lote**: um botão "Subir para o SimpliRoute"
criaria as visitas da operação vigente direto pela API, com as mesmas regras da planilha
(título `NF {nf} - {nome}`, janelas 08:0018:00, lat/long do mês anterior, etc.).
> ⚠️ **Pré-requisito que segura esta fase: consolidar os ENDEREÇOS antes da subida.**
- Anti-duplicação: conferir `reference` + `planned_date` antes de criar (reexecutar não duplica).
- Desfazer: `POST /v1/bulk/delete/visits/` permite implementar um "remover carga".
### 2. 🧾 Relatório de comprovantes de entrega (POD)
`GET /v1/plans/visits/{visit_id}/detail/` traz **foto, assinatura, hora e GPS** de cada
entrega → gerar PDF por operação/mês (reuso do padrão Prawn em `app/services/pdf/`).
Valor: faturamento com STS/prefeitura e defesa em disputas ("não recebi").
### 3. ⚡ Webhooks — painel em tempo real
`POST /v1/addons/webhooks/` com eventos `visit_checkout`, `route_started/finished`,
`on_its_way` → endpoint público autenticado no app grava o evento e o painel atualiza
**na hora**, sem esperar a sync de 30 min. Exige atenção à segurança (assinatura,
idempotência) e URL pública estável.
### 4. 📱 Aviso ao paciente via WhatsApp
Evento `on_its_way` + ETA da API + Twilio já existente (`NotificacaoService`):
"seu medicamento saiu para entrega". Reduz insucesso por **RESPONSÁVEL AUSENTE**
(motivo real da lista de observations).
### 5. 📺 Monitor de rotas ao vivo
`GET /v1/plans/{date}/vehicles/` + visitas por rota → tela "Operação de hoje" com cada
veículo, % concluído e atrasos (complementa o dashboard, que olha o passado).
### 6. 👷 Sincronização de motoristas/veículos
`GET /v1/accounts/drivers/` e `GET /v1/routes/vehicles/` ↔ usuários motoristas do sistema
(hoje o vínculo é o nome digitado — sujeito a divergência).
### 7. 📊 Datamart e 🏷️ tags/skills
Export paginado de analytics para enriquecer dashboards; tags/skills para classificar
visitas por tipo de material.
</details>

View File

@@ -165,9 +165,6 @@ class DashboardController < ApplicationController
@pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc) @pagamentos_feitos = pagos.includes(:consolidacao, :pagador).order(pago_em: :desc)
@pagamentos_pendentes = pendentes.includes(:consolidacao).order(consolidacao_id: :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 end
# Visão financeira (custo) das consolidações finalizadas do período, já com o # Visão financeira (custo) das consolidações finalizadas do período, já com o
@@ -244,28 +241,6 @@ class DashboardController < ApplicationController
{ labels: labels, valores: valores, qtds: qtds } { labels: labels, valores: valores, qtds: qtds }
end 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. # Meses ("YYYY-MM") do início ao fim do período selecionado, inclusive.
def meses_no_periodo def meses_no_periodo
meses = [] meses = []

View File

@@ -204,9 +204,9 @@
<%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %> <%# GRÁFICO + MOTORISTAS (lado a lado no desktop) %>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4"> <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 %> <%# Gráfico de linha diário (Chart.js) %>
<div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6 flex flex-col"> <div class="xl:col-span-2 bg-[#1a1a1a] rounded-2xl border border-white/5 p-6">
<div class="flex flex-wrap items-center justify-between gap-2 mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h2 class="text-lg font-semibold text-white">Evolução do custo</h2> <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> <p class="text-gray-400 text-sm">Valor estimado por dia no período<%= ' · operação filtrada' if @operacao_filtro.present? %></p>
@@ -215,39 +215,9 @@
<span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$) <span class="inline-block w-3 h-0.5 bg-[#f97316] rounded"></span> Valor (R$)
</div> </div>
</div> </div>
<div class="relative flex-1 min-h-56"> <div class="relative h-64">
<canvas id="grafico-diario"></canvas> <canvas id="grafico-diario"></canvas>
</div> </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> </div>
<%# Top motoristas %> <%# Top motoristas %>
@@ -378,9 +348,9 @@
<th class="py-2 pl-3 font-medium text-right">Valor</th> <th class="py-2 pl-3 font-medium text-right">Valor</th>
</tr> </tr>
</thead> </thead>
<tbody id="pagamentos-feitos-tbody"> <tbody>
<% @pagamentos_feitos.each do |cm| %> <% @pagamentos_feitos.each do |cm| %>
<tr class="border-b border-white/5 pagamento-feito-row"> <tr class="border-b border-white/5">
<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 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 text-white"><%= cm.motorista_nome %></td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
@@ -394,10 +364,6 @@
</tbody> </tbody>
</table> </table>
</div> </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 %> <% else %>
<p class="text-gray-400 text-sm py-3">Nenhum pagamento realizado neste período.</p> <p class="text-gray-400 text-sm py-3">Nenhum pagamento realizado neste período.</p>
<% end %> <% end %>
@@ -416,9 +382,9 @@
<th class="py-2 pl-3 font-medium text-right">Valor</th> <th class="py-2 pl-3 font-medium text-right">Valor</th>
</tr> </tr>
</thead> </thead>
<tbody id="pagamentos-pendentes-tbody"> <tbody>
<% @pagamentos_pendentes.each do |cm| %> <% @pagamentos_pendentes.each do |cm| %>
<tr class="border-b border-white/5 pagamento-pendente-row"> <tr class="border-b border-white/5">
<td class="py-2.5 pr-3 text-white"><%= cm.motorista_nome %></td> <td class="py-2.5 pr-3 text-white"><%= cm.motorista_nome %></td>
<td class="py-2.5 px-3"> <td class="py-2.5 px-3">
<%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao), <%= link_to cm.consolidacao.nome, consolidacao_path(cm.consolidacao),
@@ -430,10 +396,6 @@
</tbody> </tbody>
</table> </table>
</div> </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 %> <% else %>
<p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. 🎉</p> <p class="text-gray-400 text-sm py-3">Nenhuma pendência no período. 🎉</p>
<% end %> <% end %>
@@ -511,60 +473,6 @@
})(); })();
</script> </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 %> <%# Filtro de operação — submete o form ao marcar/desmarcar %>
<script> <script>
(function () { (function () {
@@ -656,82 +564,6 @@
})(); })();
</script> </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) %> <%# Rosca de pagamentos (pago x a pagar, por valor) %>
<script> <script>
(function () { (function () {

View File

@@ -80,10 +80,7 @@
</script> </script>
</head> </head>
<%# overflow-x-hidden: conteúdo largo demais não pode criar scroll horizontal na <body class="bg-[#0a0a0a] text-white min-h-screen">
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) ── %> <%# ── Animação de abertura (uma vez, logo após o login) ── %>
<% if @mostrar_splash %> <% if @mostrar_splash %>