Correção de horario e implantação da geração de Romaneio

This commit is contained in:
2026-08-27 16:21:56 -03:00
parent a0aafe62fc
commit ad1a071e4b
51 changed files with 3450 additions and 46 deletions

View File

@@ -0,0 +1,48 @@
<%# Divergências explicadas ONDE elas aparecem — não em documentação. %>
<% multifolha = @romaneio.veiculos_multifolha %>
<% if @colunas_ausentes.include?('status') && @romaneio.operacao_tabela.present? %>
<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>
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
<strong>STATUS</strong>, então não dá para saber quem é paciente novo — a coluna
<strong>APARELHO</strong> saiu vazia para todos e precisa ser preenchida à mão.
Um romaneio em que ninguém recebe aparelho, entregue calado, vira aparelho não entregue.
</p>
</div>
<% end %>
<% if @colunas_ausentes.include?('telefones') && @romaneio.operacao_tabela.present? %>
<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>
A operação <strong><%= @romaneio.operacao_label %></strong> não tem a coluna
<strong>TELEFONES</strong>. O que aparece na tabela veio do “TEL:” das anotações do
plano, que nem sempre traz todos os números.
</p>
</div>
<% end %>
<% if @romaneio.operacao_tabela.blank? %>
<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>
Este romaneio foi importado <strong>sem operação vinculada</strong>: a coluna APARELHO
não é preenchida sozinha e o telefone vem só das anotações do plano.
</p>
</div>
<% end %>
<% if multifolha.any? %>
<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>
<% multifolha.each do |veiculo, total| %>
<strong><%= veiculo %></strong> tem <%= total %> paradas →
sai em <%= @romaneio.folhas_de(veiculo) %> folhas<%= ',' unless veiculo == multifolha.keys.last %>
<% end %>
— cada folha vai com o próprio bloco de assinatura, e a numeração continua entre elas.
</p>
</div>
<% end %>

View File

@@ -0,0 +1,13 @@
<%# Uma célula editável + o marcador de "editado". %>
<div class="flex items-center gap-1">
<input type="text"
value="<%= linha.public_send(campo) %>"
data-romaneio-target="celula"
data-action="change->romaneio#salvarCelula"
data-linha-id="<%= linha.id %>"
data-campo="<%= campo %>"
data-original="<%= linha.public_send(campo) %>"
class="w-full bg-transparent border border-transparent hover:border-white/10 focus:border-orange-500
focus:bg-[#1a1a1a] rounded-lg px-2 py-2 text-gray-100 focus:outline-none min-h-[40px]">
<%= render 'marcador', linha: linha, campo: campo %>
</div>

View File

@@ -0,0 +1,55 @@
<%# Seletor de veículo + os dois campos de cabeçalho da folha. %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Veículo</label>
<div class="flex gap-2">
<%# ao lado do select: com 72 veículos, uma lista lateral vira armadilha
de scroll, mas percorrer o plano carro a carro é o que o operador faz. %>
<button type="button" data-action="romaneio#anterior" aria-label="Veículo anterior"
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
<select data-romaneio-target="veiculo" data-action="romaneio#trocarVeiculo"
class="flex-1 bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
focus:outline-none focus:border-orange-500 min-h-[48px]">
<% @veiculos.each do |v| %>
<option value="<%= v.veiculo %>" <%= 'selected' if v.veiculo == @veiculo %>>
<%= v.veiculo %> (<%= @romaneio.contagem_por_veiculo[v.veiculo].to_i %>)
</option>
<% end %>
</select>
<button type="button" data-action="romaneio#proximo" aria-label="Próximo veículo"
class="px-3 min-w-[48px] min-h-[48px] bg-[#1a1a1a] border border-white/10 rounded-xl
text-white hover:bg-[#242424]"></button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
PLANO <span class="text-gray-500 font-normal">— texto do canto direito do PDF</span>
</label>
<input type="text" value="<%= @romaneio.rotulo_plano %>"
data-romaneio-target="rotulo" data-action="change->romaneio#salvarRotulo"
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]">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Motorista <span class="text-gray-500 font-normal">— só na tela, não sai impresso</span>
</label>
<% atual = @veiculos.find { |v| v.veiculo == @veiculo } %>
<input type="text" value="<%= atual&.motorista %>" placeholder="Quem leva este carro"
data-romaneio-target="motorista" data-action="change->romaneio#salvarMotorista"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Buscar nesta folha</label>
<input type="search" placeholder="NF, nome ou endereço…"
data-action="input->romaneio#filtrar"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>

View File

@@ -0,0 +1,12 @@
<%# Ponto laranja = campo tocado por humano, e por isso protegido na reimportação.
O botão ↺ devolve o valor do plano (e destrava o campo de novo) — sem ele, uma
tecla acidental seria permanente. %>
<span class="shrink-0 w-4 flex items-center justify-center"
data-romaneio-marcador="<%= linha.id %>-<%= campo %>">
<% if linha.editado?(campo) %>
<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano."
data-action="romaneio#restaurar"
data-linha-id="<%= linha.id %>" data-campo="<%= campo %>"
class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>
<% end %>
</span>

View File

@@ -0,0 +1,21 @@
<%# Paradas que saíram do plano mas tinham edição humana. Não são impressas, e não
são apagadas: quem decide o que fazer com elas é o operador. %>
<% if linhas.any? %>
<div class="bg-[#111] border border-amber-500/30 rounded-2xl p-4">
<h2 class="text-amber-200 font-semibold text-sm mb-1">
<%= linhas.size %> parada(s) saíram do plano
</h2>
<p class="text-gray-400 text-xs mb-3">
Elas tinham edição sua, então foram guardadas em vez de apagadas — e não entram no PDF.
Se voltarem ao plano numa próxima importação, voltam sozinhas para a folha.
</p>
<ul class="space-y-1 text-sm">
<% linhas.each do |linha| %>
<li class="text-gray-500 line-through">
<span class="text-gray-400"><%= linha.veiculo %></span> ·
NF <%= linha.nota_fiscal.presence || '—' %> · <%= linha.nome %>
</li>
<% end %>
</ul>
</div>
<% end %>

View File

@@ -0,0 +1,59 @@
<%# Tabela editável do veículo selecionado.
SEM paginação de propósito: acima de 1366px a lista inteira é o certo — esconder
item em monitor grande atrapalha até o Ctrl+F do navegador, e conferir romaneio é
exatamente procurar uma NF na lista. A busca acima filtra sem esconder nada.
<input> por célula, e não contenteditable: colar do Excel num contenteditable
traz HTML junto. %>
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between">
<h2 class="text-white font-semibold text-sm"><%= linhas.size %> paradas</h2>
<span class="text-xs text-gray-500" data-romaneio-target="status">Alterações salvam sozinhas</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-2 py-2 w-10">#</th>
<th class="text-left px-2 py-2 w-28">Nota fiscal</th>
<th class="text-left px-2 py-2 w-56">Nome</th>
<th class="text-left px-2 py-2">Endereço</th>
<th class="text-center px-2 py-2 w-24">Aparelho</th>
<th class="text-left px-2 py-2 w-44">Telefones</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% linhas.each_with_index do |linha, i| %>
<tr data-romaneio-target="linha"
data-busca="<%= [linha.nota_fiscal, linha.nome, linha.endereco].join(' ').downcase %>">
<td class="px-2 py-1 text-gray-500 text-xs"><%= i + 1 %></td>
<% %w[nota_fiscal nome endereco].each do |campo| %>
<td class="px-1 py-1">
<%= render 'celula', linha: linha, campo: campo %>
</td>
<% end %>
<%# APARELHO só assume 'SIM' ou vazio: um botão de dois estados é alvo de
toque de verdade e não deixa digitar um terceiro valor. %>
<td class="px-1 py-1 text-center">
<button type="button"
data-action="romaneio#alternarAparelho"
data-linha-id="<%= linha.id %>"
data-valor="<%= linha.aparelho %>"
class="<%= linha.aparelho == 'SIM' ? 'bg-green-600/20 text-green-300 border-green-600/40' : 'bg-[#1a1a1a] text-gray-500 border-white/10' %>
w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors">
<%= linha.aparelho.presence || '—' %>
</button>
<%= render 'marcador', linha: linha, campo: 'aparelho' %>
</td>
<td class="px-1 py-1">
<%= render 'celula', linha: linha, campo: 'telefones' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,144 @@
<%# app/views/admin/romaneios/index.html.erb %>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-white">Romaneio de entrega</h1>
<p class="text-gray-400 text-sm mt-0.5">
Monta o "CONTROLE DE ENTREGA" — uma folha por veículo, assinada pelo motorista na retirada
</p>
</div>
<%= render 'shared/flash' %>
<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 coluna <strong>APARELHO</strong> é marcada sozinha para quem está como
<strong>NOVO</strong> na operação do mês, e o <strong>telefone</strong> vem da própria
operação — não é preciso subir uma segunda planilha. Tudo continua editável na tela
antes de imprimir.
</p>
</div>
<%# Importação %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-5 space-y-4">
<h2 class="text-white font-semibold">Importar plano</h2>
<%= form_with url: admin_romaneios_path, method: :post, multipart: true,
data: { turbo: false }, class: 'space-y-4' do %>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Data do plano</label>
<input type="date" name="planned_date" required value="<%= @data_padrao %>"
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]">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Operação do mês
<span class="text-gray-500 font-normal">(APARELHO e telefone)</span>
</label>
<select name="operacao_tabela"
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]">
<option value="">Sem operação — só o que o plano trouxer</option>
<% @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>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
PLANO <span class="text-gray-500 font-normal">(canto direito do PDF)</span>
</label>
<input type="text" name="rotulo_plano" placeholder="Ex.: EMAD 09.2026"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
placeholder-gray-600 focus:outline-none focus:border-orange-500 min-h-[48px]">
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 sm:items-center">
<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">
<%= icone :buscar, cor: nil %> Buscar no SimpliRoute
</button>
<%# Reserva: o plano do dia pode ainda não estar publicado. %>
<details class="flex-1">
<summary class="cursor-pointer text-sm text-gray-400 hover:text-white select-none py-3">
Ou enviar a planilha do plano (.xlsx)
</summary>
<div class="mt-2 flex flex-col sm:flex-row gap-3">
<input type="file" name="arquivo" accept=".xlsx"
class="flex-1 text-sm text-gray-300 file:mr-3 file:py-2.5 file:px-4 file:rounded-lg
file:border-0 file:bg-[#1a1a1a] file:text-gray-200 file:cursor-pointer">
<button type="submit" formaction="<%= importar_planilha_admin_romaneios_path %>"
class="px-5 py-3 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10
rounded-xl min-h-[48px] whitespace-nowrap">
<%= icone :baixar, cor: nil %> Enviar planilha
</button>
</div>
<p class="text-xs text-gray-500 mt-2">
Use quando o plano do dia ainda não estiver publicado no SimpliRoute.
O arquivo é lido na hora e não fica guardado no sistema.
</p>
</details>
</div>
<% end %>
</div>
<%# Romaneios já importados %>
<div class="bg-[#111] border border-white/5 rounded-2xl overflow-hidden">
<% if @romaneios.empty? %>
<div class="p-8 text-center">
<p class="text-gray-400">Nenhum romaneio ainda.</p>
<p class="text-gray-500 text-sm mt-1">Escolha a data do plano acima e clique em “Buscar no SimpliRoute”.</p>
</div>
<% else %>
<table class="w-full text-sm">
<thead class="bg-[#1a1a1a] text-gray-400 text-xs uppercase tracking-wider">
<tr>
<th class="text-left px-4 py-3">Data</th>
<th class="text-left px-4 py-3">Plano</th>
<th class="text-left px-4 py-3">Operação</th>
<th class="text-left px-4 py-3">Veículos</th>
<th class="text-left px-4 py-3">Origem</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
<% @romaneios.each do |r| %>
<tr class="hover:bg-white/5">
<td class="px-4 py-3 text-white"><%= l r.planned_date, format: '%d/%m/%Y' %></td>
<td class="px-4 py-3 text-gray-200"><%= r.rotulo_plano.presence || '—' %></td>
<td class="px-4 py-3 text-gray-400"><%= r.operacao_label || '—' %></td>
<td class="px-4 py-3 text-gray-400"><%= r.romaneio_veiculos.size %></td>
<td class="px-4 py-3">
<span class="px-2 py-0.5 rounded-md text-xs bg-white/5 text-gray-300"><%= r.origem %></span>
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<%= link_to rotulo(:editar, 'Abrir'), admin_romaneio_path(r),
class: 'text-orange-400 hover:text-orange-300 font-medium' %>
<%# button_to em BLOCO: sem bloco ele vira <input type="submit">, que
não aceita o SVG do ícone dentro (ver ApplicationHelper#rotulo). %>
<%= button_to admin_romaneio_path(r), method: :delete,
form: { data: { turbo_confirm: 'Excluir este romaneio e todas as edições feitas nele?' },
class: 'inline-block ml-3' },
class: 'text-gray-500 hover:text-red-400' do %>
<%= icone :excluir, cor: nil %> Excluir
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<%# app/views/admin/romaneios/show.html.erb
Editor do romaneio: tabela editável à esquerda, o PDF REAL à direita.
A prévia é um <iframe> de mesma origem, e isso não é preferência: o CSP deste
app está em modo enforcing com `object_src :none` (config/initializers/
content_security_policy.rb), o que bloqueia <embed> e <object>; não há
`frame_src`, então ele cai em `default_src :self` e o iframe passa. Uma data:
URL seria barrada pelo mesmo fallback. %>
<div class="space-y-5"
data-controller="romaneio"
data-romaneio-url-value="<%= admin_romaneio_path(@romaneio) %>"
data-romaneio-pdf-url-value="<%= pdf_admin_romaneio_path(@romaneio) %>"
data-romaneio-veiculo-value="<%= @veiculo %>">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-white">
Romaneio de <%= l @romaneio.planned_date, format: '%d/%m/%Y' %>
</h1>
<p class="text-gray-400 text-sm mt-0.5">
<%= @romaneio.operacao_label || 'Sem operação vinculada' %> ·
<%= @romaneio.contagem_por_veiculo.values.sum %> paradas em
<%= @veiculos.size %> veículo(s) ·
importado de <%= @romaneio.origem %>
<%= "em #{l @romaneio.importado_em, format: '%d/%m %H:%M'}" if @romaneio.importado_em %>
</p>
</div>
<div class="flex flex-wrap gap-2">
<%= link_to rotulo(:baixar, 'Baixar todos'),
pdf_admin_romaneio_path(@romaneio),
class: 'px-4 py-2.5 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl min-h-[44px] inline-flex items-center' %>
<%# Em BLOCO: button_to sem bloco gera <input type="submit">, que não aceita
o SVG do ícone (ver ApplicationHelper#rotulo). %>
<%= button_to reimportar_admin_romaneio_path(@romaneio, veiculo: @veiculo), method: :post,
form: { data: { turbo_confirm: 'Rebuscar o plano? Os campos que você editou são preservados.' } },
class: 'px-4 py-2.5 bg-[#1a1a1a] hover:bg-[#242424] text-white border border-white/10 rounded-xl min-h-[44px]' do %>
<%= icone :restaurar, cor: nil %> Reimportar plano
<% end %>
<%= link_to 'Voltar', admin_romaneios_path,
class: 'px-4 py-2.5 text-gray-400 hover:text-white min-h-[44px] inline-flex items-center' %>
</div>
</div>
<%= render 'shared/flash' %>
<% if @veiculos.empty? %>
<div class="bg-[#111] border border-white/5 rounded-2xl p-8 text-center">
<p class="text-gray-400">Este romaneio está sem paradas.</p>
<p class="text-gray-500 text-sm mt-1">Clique em “Reimportar plano” ou envie a planilha do plano na tela anterior.</p>
</div>
<% else %>
<%= render 'avisos' %>
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,560px)] gap-5 items-start">
<div class="space-y-4">
<%= render 'controles' %>
<%= render 'tabela', linhas: @linhas %>
<%= render 'removidas', linhas: @removidas %>
</div>
<%# Prévia — no celular vira um bloco recolhido: 6 colunas em tela pequena
exigiriam zoom, e o que importa ali é editar, não conferir o layout. %>
<div class="lg:sticky lg:top-4">
<details class="lg:hidden bg-[#111] border border-white/5 rounded-2xl p-4 mb-4">
<summary class="cursor-pointer text-white font-medium select-none min-h-[44px] flex items-center">
<%= icone :visualizar %> Ver prévia do PDF
</summary>
<%= link_to 'Abrir o PDF deste veículo',
pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo),
class: 'block mt-3 text-orange-400 hover:text-orange-300', target: '_blank', rel: 'noopener' %>
</details>
<div class="hidden lg:block bg-[#111] border border-white/5 rounded-2xl p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-white font-semibold text-sm">Prévia — <%= @veiculo %></h2>
<button type="button" data-action="romaneio#atualizarPreview"
class="text-xs text-gray-400 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5">
<%= icone :restaurar, tamanho: 'w-4 h-4' %> Atualizar prévia
</button>
</div>
<iframe data-romaneio-target="preview"
src="<%= pdf_admin_romaneio_path(@romaneio, veiculo: @veiculo) %>"
class="w-full h-[78vh] rounded-xl border border-white/10 bg-white"
title="Pré-visualização do romaneio do veículo <%= @veiculo %>"></iframe>
</div>
</div>
</div>
<% end %>
</div>