Compare commits

..

8 Commits

Author SHA256 Message Date
1dd2610c1f Refatoraão tela de Edição de apontamento 2026-07-08 15:30:53 -03:00
96b85adbe9 Estilização da edição de apontamento 2026-07-08 15:14:36 -03:00
bb9d55675a Implantação do recurso de Gerar planilha de carga simplerout e Correção das imagens do mapa de aconpanhamento 2026-07-08 12:26:23 -03:00
3f56e87a33 fix: lat/long com ponto decimal (aceita fonte com vírgula)
O rastreio pode trazer a coordenada como texto com vírgula (-23,62); troca
por ponto antes de converter, garantindo a saída numérica com ponto no xlsx
e evitando perder a geo de recorrentes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:15:50 -03:00
44ba0a93c6 Implantação do recurso de Gerar planilha de carga simplerout 2026-07-08 12:12:50 -03:00
07fda079aa fix(docker): instala gems faltantes no boot (bundle check || bundle install)
O bundle fica no volume nomeado bundle_cache, que sombreia os gems da
imagem — adicionar uma gem (ex.: caxlsx) causava Bundler::GemNotFound ao
subir, mesmo com --build, pois o volume persistente não recebe os gems
novos. Rodar bundle install no boot resolve de forma idempotente.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:25:54 -03:00
53341ac22e Implantação do recurso de Gerar planilha de carga simplerout 2026-07-08 11:20:59 -03:00
b2695cd920 feat: gerar planilha de carga do SimpliRoute (download .xlsx)
Nova tela ADM/gerente (Administração > Planilha SimpliRoute) que gera a
planilha de importação (.xlsx) de uma operação vigente, no formato do
pt_br_example.xlsx. Preenche Latitude/Longitude reaproveitando a
geocodificação do mês anterior da mesma operação (casa nome + endereço +
supervisão; geo vem do rastreio db_reem_simplerout_2026). Pacientes novos
saem sem lat/long.

- SimpliRoute::PlanilhaCarga: monta linhas + resolve match do mês anterior
- SimpliRoute::PlanilhaCargaXlsx: builder caxlsx com cabeçalho verbatim
- Admin::PlanilhasSimpliRouteController (show/baixar) + policy admin/gerente
- Gem caxlsx (rebuild do container atualiza o Gemfile.lock)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:05:24 -03:00
15 changed files with 7295 additions and 148 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

View File

@@ -48,7 +48,17 @@ class Admin::EdicaoLancamentosController < ApplicationController
notes: visita['notes'],
checkout_time: visita['checkout_time'],
checkout_latitude: visita['checkout_latitude'],
checkout_longitude: visita['checkout_longitude']
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
}
@@ -59,20 +69,26 @@ class Admin::EdicaoLancamentosController < ApplicationController
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 redirect_to(admin_edicao_lancamento_path, alert: 'Visita não identificada.') if id.blank?
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 redirect_to(admin_edicao_lancamento_path, alert: 'Nenhuma alteração informada.')
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 redirect_to(admin_edicao_lancamento_path, alert: 'Status inválido.')
return responder_erro('Status inválido.')
end
atualizada = client.atualizar_visita(id, attrs)
@@ -87,17 +103,41 @@ class Admin::EdicaoLancamentosController < ApplicationController
request: request
)
redirect_to admin_edicao_lancamento_path,
notice: "Lançamento da NF #{atualizada['reference']} atualizado no SimpliRoute. " \
'O painel refletirá na próxima sincronização.'
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
redirect_to admin_edicao_lancamento_path, alert: "Não foi possível atualizar: #{e.message}"
responder_erro("Não foi possível atualizar: #{e.message}", status: :bad_gateway)
end
# GET /admin/edicao_lancamento/historico?visit_id=123 → JSON (best-effort).
# 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
render json: { ok: true, historico: client.historico(params[:visit_id]) }
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
@@ -163,6 +203,21 @@ class Admin::EdicaoLancamentosController < ApplicationController
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?

View 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

View 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

View 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

View 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

View File

@@ -1,10 +1,18 @@
<%# app/views/admin/edicao_lancamentos/show.html.erb %>
<div class="space-y-6" data-buscar-url="<%= buscar_admin_edicao_lancamento_path %>">
<%# 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 comentários de uma entrega direto no SimpliRoute</p>
<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 %>
@@ -13,9 +21,9 @@
<%# 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>, 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>
<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 %>
@@ -35,156 +43,380 @@
<p id="busca-loading" class="hidden mt-2 text-sm text-gray-400">Consultando SimpliRoute…</p>
</div>
<%# Formulário de edição (oculto até achar a visita) %>
<%= form_with url: atualizar_admin_edicao_lancamento_path, method: :patch,
html: { id: 'form-edicao', class: 'hidden bg-[#111] border border-white/5 rounded-2xl p-5 space-y-5' } do %>
<input type="hidden" name="visit_id" id="f-visit-id">
<%# 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">
<%# Cabeçalho da visita (somente leitura) %>
<div class="border-b border-white/5 pb-4">
<p class="text-white font-semibold" id="f-titulo">—</p>
<p class="text-gray-500 text-sm" id="f-endereco">—</p>
<p class="text-gray-600 text-xs mt-1">NF <span id="f-nf">—</span> · visita <span id="f-id">—</span></p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<%# Status %>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Status</label>
<select name="status" id="f-status"
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]">
<% { 'completed' => 'Completa', 'failed' => 'Falha', 'pending' => 'Pendente',
'partial' => 'Parcial', 'canceled' => 'Cancelada' }.each do |v, label| %>
<option value="<%= v %>"><%= label %></option>
<% end %>
</select>
</div>
<%# Motivo (só faz sentido em falha) %>
<div id="wrap-motivo">
<label class="block text-sm font-medium text-gray-300 mb-1.5">Motivo (insucesso)</label>
<select name="checkout_observation" id="f-motivo"
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 motivo —</option>
<% @motivos.each do |m| %>
<option value="<%= m['id'] %>"><%= m['label'] %></option>
<% end %>
</select>
</div>
</div>
<%# Comentário livre %>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Comentário do checkout</label>
<textarea name="checkout_comment" id="f-comment" rows="2"
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"></textarea>
</div>
<%# Observações internas %>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">Observações (notes)</label>
<textarea name="notes" id="f-notes" rows="2"
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"></textarea>
</div>
<%# Data/geo do checkout (avançado) %>
<details class="rounded-xl border border-white/5 bg-[#0d0d0d] p-4">
<summary class="cursor-pointer text-sm text-gray-400">Data e localização do checkout (avançado)</summary>
<div class="grid sm:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-xs text-gray-400 mb-1.5">Data/hora</label>
<input type="datetime-local" name="checkout_time" id="f-checkout-time"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
<%# 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>
<div>
<label class="block text-xs text-gray-400 mb-1.5">Latitude</label>
<input type="text" inputmode="decimal" name="checkout_latitude" id="f-lat"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
</div>
<div>
<label class="block text-xs text-gray-400 mb-1.5">Longitude</label>
<input type="text" inputmode="decimal" name="checkout_longitude" id="f-lng"
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-2.5 text-white
focus:outline-none focus:border-orange-500">
<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>
</details>
<div class="flex justify-end pt-2">
<button type="submit"
class="px-6 py-3 bg-green-600 hover:bg-green-500 text-white font-semibold rounded-xl
transition-colors min-h-[48px]">
Salvar no SimpliRoute
<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>
<% end %>
<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 wrap = document.querySelector('[data-buscar-url]');
const buscarU = wrap.dataset.buscarUrl;
const campo = document.getElementById('campo-nf');
const btn = document.getElementById('btn-buscar');
const erro = document.getElementById('busca-erro');
const loading = document.getElementById('busca-loading');
const form = document.getElementById('form-edicao');
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 || '[]');
const set = (id, v) => { document.getElementById(id).value = (v ?? ''); };
const txt = (id, v) => { document.getElementById(id).textContent = (v ?? '—'); };
let visita = null; // estado atual do lançamento carregado
function toggleMotivo() {
const falha = document.getElementById('f-status').value === 'failed';
document.getElementById('wrap-motivo').style.opacity = falha ? '1' : '0.45';
}
document.getElementById('f-status').addEventListener('change', toggleMotivo);
// ── 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = campo.value.trim();
erro.classList.add('hidden');
if (!nf) { erro.textContent = 'Informe o número da NF.'; erro.classList.remove('hidden'); return; }
const nf = campoNf.value.trim();
erroEl.classList.add('hidden');
if (!nf) { mostrarErroBusca('Informe o número da NF.'); return; }
loading.classList.remove('hidden');
form.classList.add('hidden');
loadEl.classList.remove('hidden');
cardEl.classList.add('hidden');
try {
const resp = await fetch(buscarU + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
const resp = await fetch(URLS.buscar + '?nf=' + encodeURIComponent(nf), { headers: { 'Accept': 'application/json' } });
const data = await resp.json();
if (!data.ok) {
erro.textContent = data.erro || 'Não foi possível localizar a entrega.';
erro.classList.remove('hidden');
return;
}
const v = data.visita;
set('f-visit-id', v.id);
txt('f-titulo', v.titulo);
txt('f-endereco', v.endereco);
txt('f-nf', v.nf);
txt('f-id', v.id);
set('f-status', v.status || 'completed');
set('f-motivo', v.checkout_observation || '');
set('f-comment', v.checkout_comment || '');
set('f-notes', v.notes || '');
set('f-lat', v.checkout_latitude ?? '');
set('f-lng', v.checkout_longitude ?? '');
// ISO -> valor de datetime-local (YYYY-MM-DDTHH:MM)
set('f-checkout-time', v.checkout_time ? String(v.checkout_time).slice(0, 16) : '');
toggleMotivo();
form.classList.remove('hidden');
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) {
erro.textContent = 'Erro de conexão ao buscar a NF.';
erro.classList.remove('hidden');
mostrarErroBusca('Erro de conexão ao buscar a NF.');
} finally {
loading.classList.add('hidden');
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 = '';
}
}
btn.addEventListener('click', buscar);
campo.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
// ── 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>

View 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>

View File

@@ -58,6 +58,7 @@
<%= 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 %>

View File

@@ -40,13 +40,17 @@ Rails.application.configure do
p.font_src :self, :data, "https://fonts.gstatic.com"
# Imagens: data/blob (QR base64, canvas dos gráficos), ícones do Leaflet e
# tiles do mapa (ArcGIS World Imagery + OpenStreetMap).
# Imagens: data/blob (QR base64, canvas dos gráficos), ícones do Leaflet,
# tiles do mapa (ArcGIS World Imagery + OpenStreetMap) e as FOTOS DA FACHADA
# do popup do mapa de operações — hospedadas no S3 do SimpliRoute
# (ex.: simpli-visit-images.s3.amazonaws.com). Sem o host da Amazon o CSP
# enforcing bloqueava a foto ("Refused to load the image ... img-src").
p.img_src :self, :data, :blob,
"https://unpkg.com",
"https://cdn.jsdelivr.net",
"https://server.arcgisonline.com",
"https://*.tile.openstreetmap.org"
"https://*.tile.openstreetmap.org",
"https://*.amazonaws.com"
p.connect_src :self
end

View File

@@ -92,6 +92,12 @@ Rails.application.routes.draw do
patch :atualizar # aplica a alteração na API
get :historico # histórico da visita na API (JSON, best-effort)
end
# Geração da planilha de carga do SimpliRoute (download .xlsx a partir de uma
# operação vigente + lat/long do mês anterior).
resource :planilha_simpli_route, only: [:show], controller: 'planilhas_simpli_route' do
get :baixar
end
end
# API interna — Dashboard métricas

View File

@@ -32,7 +32,12 @@ services:
# O passo do cron é "best-effort": se o crontab/cron não estiver disponível
# (ex.: imagem antiga sem o pacote `cron`), apenas avisa no log e SEGUE —
# nunca impede o Rails de subir. (Requer rebuild: docker compose up -d --build)
command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0"
# `bundle check || bundle install`: os gems ficam num VOLUME nomeado
# (bundle_cache) que persiste e SOMBREIA os gems da imagem — então adicionar
# uma gem nova ao Gemfile não chega no container só com `up`/`--build`. Este
# passo instala o que faltar no volume no boot (idempotente e rápido quando já
# está tudo instalado), evitando o Bundler::GemNotFound ao subir.
command: bash -c "rm -f tmp/pids/server.pid && (bundle check || bundle install) && bundle exec rails db:prepare && { bundle exec whenever --update-crontab && cron || echo '[boot] AVISO cron/crontab indisponivel (rode com --build), seguindo sem agendamento'; } && bundle exec rails s -b 0.0.0.0"
# O banco PostgreSQL já existe externamente.
# Configure DB_HOST no .env com o IP/hostname do seu servidor PostgreSQL.