Compare commits
8 Commits
fd14c057b6
...
teste
| Author | SHA256 | Date | |
|---|---|---|---|
| 1dd2610c1f | |||
| 96b85adbe9 | |||
| bb9d55675a | |||
| 3f56e87a33 | |||
| 44ba0a93c6 | |||
| 07fda079aa | |||
| 53341ac22e | |||
| b2695cd920 |
3
Gemfile
3
Gemfile
@@ -26,6 +26,9 @@ gem "dotenv-rails"
|
||||
# PDF
|
||||
gem "prawn"
|
||||
gem "prawn-table"
|
||||
|
||||
# Planilhas (XLSX) — geração da carga de importação do SimpliRoute
|
||||
gem "caxlsx"
|
||||
gem "rqrcode" # QR code no holerite (login rápido motorista)
|
||||
gem "chunky_png" # renderiza QR como PNG para embed no Prawn
|
||||
|
||||
|
||||
6472
Imagens para correção /reem-notas-teste-app-1.html
Normal file
6472
Imagens para correção /reem-notas-teste-app-1.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
Imagens para implantação /Imagem colada.png
Normal file
BIN
Imagens para implantação /Imagem colada.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 599 KiB |
BIN
Imagens para implantação /pt_br_example.xlsx
Normal file
BIN
Imagens para implantação /pt_br_example.xlsx
Normal file
Binary file not shown.
@@ -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
|
||||
)
|
||||
|
||||
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?
|
||||
|
||||
43
app/controllers/admin/planilhas_simpli_route_controller.rb
Normal file
43
app/controllers/admin/planilhas_simpli_route_controller.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# app/controllers/admin/planilhas_simpliroute_controller.rb
|
||||
#
|
||||
# Gera a planilha de carga do SimpliRoute (.xlsx) a partir de UMA operação
|
||||
# vigente. Lat/long são preenchidas com o histórico do mês anterior
|
||||
# (SimpliRoute::PlanilhaCarga). Somente leitura das bases.
|
||||
class Admin::PlanilhasSimpliRouteController < ApplicationController
|
||||
XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'.freeze
|
||||
|
||||
def show
|
||||
authorize :planilha_simpli_route
|
||||
@operacoes_agrupadas = Operacao.agrupadas_por_mes
|
||||
end
|
||||
|
||||
# GET /admin/planilha_simpli_route/baixar?tabela=gade_entregas_...
|
||||
def baixar
|
||||
authorize :planilha_simpli_route
|
||||
|
||||
tabela = params[:tabela].to_s
|
||||
unless Operacao.valida?(tabela)
|
||||
return redirect_to(admin_planilha_simpli_route_path, alert: 'Selecione uma operação válida.')
|
||||
end
|
||||
|
||||
carga = SimpliRoute::PlanilhaCarga.new(tabela)
|
||||
linhas = carga.linhas
|
||||
|
||||
if linhas.empty?
|
||||
return redirect_to(admin_planilha_simpli_route_path,
|
||||
alert: "A operação #{carga.label} não tem registros.")
|
||||
end
|
||||
|
||||
binario = SimpliRoute::PlanilhaCargaXlsx.new(linhas).gerar
|
||||
send_data binario,
|
||||
filename: "carga_simpliroute_#{nome_arquivo(carga.label)}.xlsx",
|
||||
type: XLSX_MIME,
|
||||
disposition: 'attachment'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def nome_arquivo(label)
|
||||
label.to_s.parameterize(separator: '_').presence || 'operacao'
|
||||
end
|
||||
end
|
||||
9
app/policies/planilha_simpli_route_policy.rb
Normal file
9
app/policies/planilha_simpli_route_policy.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# app/policies/planilha_simpliroute_policy.rb
|
||||
#
|
||||
# Geração da planilha de carga do SimpliRoute — tarefa de preparação de operação.
|
||||
# Liberada para admin e gerente (quem prepara as cargas). Policy "headless"
|
||||
# (autorizada com o símbolo :planilha_simpliroute).
|
||||
class PlanilhaSimpliRoutePolicy < ApplicationPolicy
|
||||
def show? = admin_ou_gerente?
|
||||
def baixar? = admin_ou_gerente?
|
||||
end
|
||||
173
app/services/simpli_route/planilha_carga.rb
Normal file
173
app/services/simpli_route/planilha_carga.rb
Normal file
@@ -0,0 +1,173 @@
|
||||
# app/services/simpli_route/planilha_carga.rb
|
||||
#
|
||||
# Monta as linhas da planilha de carga do SimpliRoute a partir de UMA operação
|
||||
# vigente (tabela gade_entregas_*). Preenche Latitude/Longitude reaproveitando a
|
||||
# geocodificação do MÊS ANTERIOR da mesma operação: casa nome + endereço +
|
||||
# supervisão e puxa a lat/long do rastreio (db_reem_simplerout_2026) pelo NF antigo.
|
||||
#
|
||||
# Tabelas de operação e rastreio são SOMENTE LEITURA (apenas SELECT).
|
||||
module SimpliRoute
|
||||
class PlanilhaCarga
|
||||
# Colunas da tabela de operação usadas (nem toda operação tem todas — as que
|
||||
# faltam viram NULL, no mesmo padrão do OperacaoMetricas#selects_gade).
|
||||
COLUNAS_OP = %w[
|
||||
nota_fiscal nome_completo endereco_sem_complemento complemento supervisao telefones
|
||||
].freeze
|
||||
|
||||
# Colunas que formam a chave de match com o histórico.
|
||||
COLUNAS_CHAVE = %w[nome_completo endereco_sem_complemento supervisao].freeze
|
||||
|
||||
# Mês (número) -> grafias aceitas nos nomes de tabela (abreviação e extenso).
|
||||
MESES_GRAFIAS = {
|
||||
1 => %w[jan janeiro], 2 => %w[fev fevereiro], 3 => %w[mar marco março],
|
||||
4 => %w[abr abril], 5 => %w[mai maio], 6 => %w[jun junho], 7 => %w[jul julho],
|
||||
8 => %w[ago agosto], 9 => %w[set setembro], 10 => %w[out outubro],
|
||||
11 => %w[nov novembro], 12 => %w[dez dezembro]
|
||||
}.freeze
|
||||
GRAFIA_PARA_NUM = MESES_GRAFIAS.each_with_object({}) do |(num, grafias), h|
|
||||
grafias.each { |g| h[g] = num }
|
||||
end.freeze
|
||||
|
||||
attr_reader :tabela
|
||||
|
||||
def initialize(tabela)
|
||||
@tabela = tabela.to_s
|
||||
raise ArgumentError, "Operação inválida: #{@tabela}" unless Operacao.valida?(@tabela)
|
||||
end
|
||||
|
||||
# Nome amigável da operação (para o nome do arquivo).
|
||||
def label
|
||||
Operacao.label(@tabela)
|
||||
end
|
||||
|
||||
# A tabela do mês anterior foi encontrada? (para avisar quando não houver histórico)
|
||||
def mes_anterior_encontrado?
|
||||
tabela_anterior.present?
|
||||
end
|
||||
|
||||
# Array de hashes no formato das colunas da planilha (A..N).
|
||||
def linhas
|
||||
geo = lookup_geo
|
||||
registros_operacao.map do |r|
|
||||
lat, lng = geo[chave(r)] || [nil, nil]
|
||||
{
|
||||
titulo: "NF #{r['nota_fiscal']} - #{r['nome_completo']}".strip,
|
||||
endereco: r['endereco_sem_complemento'],
|
||||
carga: 1,
|
||||
janela_ini: '08:00',
|
||||
janela_fim: '18:00',
|
||||
tempo: 10,
|
||||
anotacoes: r['complemento'],
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
referencia: r['nota_fiscal'],
|
||||
contato: r['supervisao'],
|
||||
telefone: r['telefones']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conn
|
||||
ActiveRecord::Base.connection
|
||||
end
|
||||
|
||||
# Linhas cruas da operação vigente.
|
||||
def registros_operacao
|
||||
sql = "SELECT #{selects(@tabela, COLUNAS_OP)} " \
|
||||
"FROM #{conn.quote_table_name(@tabela)} g " \
|
||||
"WHERE g.nota_fiscal IS NOT NULL"
|
||||
conn.select_all(sql).to_a
|
||||
end
|
||||
|
||||
# SELECT com as colunas existentes; as ausentes viram NULL com o mesmo alias.
|
||||
# As chaves de COLUNAS_* são uma whitelist fixa (sem entrada do usuário no SQL).
|
||||
def selects(tabela, colunas)
|
||||
existentes = conn.columns(tabela).map(&:name)
|
||||
colunas.map do |c|
|
||||
existentes.include?(c) ? "g.#{c} AS #{c}" : "CAST(NULL AS text) AS #{c}"
|
||||
end.join(', ')
|
||||
end
|
||||
|
||||
# Hash { chave_normalizada => [lat, lng] } vindo do mês anterior.
|
||||
def lookup_geo
|
||||
prev = tabela_anterior
|
||||
return {} if prev.nil?
|
||||
|
||||
# Sem as colunas-chave não dá pra casar com segurança.
|
||||
cols_prev = conn.columns(prev).map(&:name)
|
||||
return {} unless COLUNAS_CHAVE.all? { |c| cols_prev.include?(c) }
|
||||
|
||||
rastreio = conn.quote_table_name(Entrega.table_name)
|
||||
sql = <<~SQL
|
||||
WITH ultimo AS (
|
||||
SELECT reference_id, latitude, longitude,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference_id ORDER BY checkout DESC NULLS LAST) AS rn
|
||||
FROM #{rastreio}
|
||||
WHERE reference_id IS NOT NULL
|
||||
)
|
||||
SELECT #{selects(prev, COLUNAS_CHAVE)}, r.latitude AS lat, r.longitude AS lng
|
||||
FROM #{conn.quote_table_name(prev)} g
|
||||
INNER JOIN ultimo r ON r.reference_id::text = g.nota_fiscal AND r.rn = 1
|
||||
WHERE r.latitude IS NOT NULL AND r.longitude IS NOT NULL
|
||||
SQL
|
||||
|
||||
conn.select_all(sql).to_a.each_with_object({}) do |r, memo|
|
||||
lat = to_coord(r['lat'])
|
||||
lng = to_coord(r['lng'])
|
||||
next if lat.nil? || lng.nil? || (lat.zero? && lng.zero?)
|
||||
|
||||
memo[chave(r)] = [lat, lng] # o INNER JOIN já ordena pelo checkout mais recente
|
||||
end
|
||||
end
|
||||
|
||||
# Chave de match: nome | endereço | supervisão, normalizados.
|
||||
def chave(row)
|
||||
COLUNAS_CHAVE.map { |c| normaliza(row[c]) }.join('|')
|
||||
end
|
||||
|
||||
def normaliza(valor)
|
||||
valor.to_s.strip.gsub(/\s+/, ' ').upcase
|
||||
end
|
||||
|
||||
# Coordenada como Float (com PONTO). O rastreio pode trazer texto com vírgula
|
||||
# decimal ("-23,62"); coordenadas não usam separador de milhar, então trocar
|
||||
# a vírgula por ponto é seguro e garante a saída numérica (ponto) no .xlsx.
|
||||
def to_coord(valor)
|
||||
Float(valor.to_s.strip.tr(',', '.'))
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
# Nome da tabela do mês imediatamente anterior, da MESMA operação, se existir.
|
||||
def tabela_anterior
|
||||
return @tabela_anterior if defined?(@tabela_anterior)
|
||||
|
||||
@tabela_anterior = calcular_tabela_anterior
|
||||
end
|
||||
|
||||
def calcular_tabela_anterior
|
||||
tokens = @tabela.delete_prefix(Operacao::PREFIXO).split('_')
|
||||
idx = tokens.index { |t| GRAFIA_PARA_NUM.key?(t.downcase) }
|
||||
return nil if idx.nil?
|
||||
|
||||
num = GRAFIA_PARA_NUM[tokens[idx].downcase]
|
||||
tem_ano = tokens[idx + 1].to_s.match?(/\A\d{4}\z/)
|
||||
ano = tem_ano ? tokens[idx + 1].to_i : nil
|
||||
|
||||
prev_num = num == 1 ? 12 : num - 1
|
||||
prev_ano = ano && num == 1 ? ano - 1 : ano
|
||||
|
||||
validas = Operacao.nomes_validos
|
||||
MESES_GRAFIAS[prev_num].each do |grafia|
|
||||
novo = tokens.dup
|
||||
novo[idx] = grafia
|
||||
novo[idx + 1] = prev_ano.to_s if tem_ano
|
||||
candidato = Operacao::PREFIXO + novo.join('_')
|
||||
return candidato if validas.include?(candidato)
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
93
app/services/simpli_route/planilha_carga_xlsx.rb
Normal file
93
app/services/simpli_route/planilha_carga_xlsx.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
# app/services/simpli_route/planilha_carga_xlsx.rb
|
||||
#
|
||||
# Gera o binário .xlsx da carga do SimpliRoute a partir das linhas montadas pelo
|
||||
# PlanilhaCarga, replicando EXATAMENTE o cabeçalho do arquivo de referência
|
||||
# (Imagens para implantação/pt_br_example.xlsx) — 26 colunas A..Z.
|
||||
require 'caxlsx'
|
||||
|
||||
module SimpliRoute
|
||||
class PlanilhaCargaXlsx
|
||||
# Cabeçalho verbatim do exemplo (inclui os espaços duplos em A e B).
|
||||
CABECALHO = [
|
||||
"Titulo* Solicitado",
|
||||
"endereço completo* Solicitado",
|
||||
"Carga",
|
||||
"Janela de horário inicial",
|
||||
"Janela de horário final",
|
||||
"Tempo de serviço",
|
||||
"Anotações",
|
||||
"Latitude",
|
||||
"Longitude",
|
||||
"Identificação de referência",
|
||||
"Habilidade necessária",
|
||||
"Habilidade opcional",
|
||||
"Pessoa de contato",
|
||||
"Telefone de contato",
|
||||
"Janela de horário inicial 2",
|
||||
"Janela de horário final 2",
|
||||
"Capacidade 2",
|
||||
"Capacidade 3",
|
||||
"Prioridade",
|
||||
"SMS",
|
||||
"Correio eletrônico de contato",
|
||||
"Carga pick",
|
||||
"Carga pick 2",
|
||||
"Carga pick 3",
|
||||
"Data agendada",
|
||||
"Tipo de visita"
|
||||
].freeze
|
||||
|
||||
# Colunas O..Z (14ª em diante) ficam vazias — 12 células.
|
||||
VAZIAS_FINAIS = Array.new(CABECALHO.size - 14, '').freeze
|
||||
|
||||
def initialize(linhas)
|
||||
@linhas = linhas
|
||||
end
|
||||
|
||||
# String binária do .xlsx.
|
||||
def gerar
|
||||
pkg = Axlsx::Package.new
|
||||
pkg.workbook.add_worksheet(name: 'Carga') do |sheet|
|
||||
sheet.add_row(CABECALHO)
|
||||
@linhas.each do |l|
|
||||
valores, tipos = linha(l)
|
||||
sheet.add_row(valores, types: tipos)
|
||||
end
|
||||
end
|
||||
pkg.to_stream.read
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Monta [valores, tipos] de uma linha (A..Z). Lat/long numéricos quando há
|
||||
# match; D/E como texto ("08:00"/"18:00"); NF/ref como texto (igual ao exemplo).
|
||||
def linha(l)
|
||||
lat = l[:latitude]
|
||||
lng = l[:longitude]
|
||||
valores = [
|
||||
l[:titulo].to_s, # A
|
||||
l[:endereco].to_s, # B
|
||||
l[:carga], # C
|
||||
l[:janela_ini], # D
|
||||
l[:janela_fim], # E
|
||||
l[:tempo], # F
|
||||
l[:anotacoes].to_s, # G
|
||||
(lat.nil? ? '' : lat), # H
|
||||
(lng.nil? ? '' : lng), # I
|
||||
l[:referencia].to_s, # J
|
||||
'', '', # K, L
|
||||
l[:contato].to_s, # M
|
||||
l[:telefone].to_s, # N
|
||||
*VAZIAS_FINAIS # O..Z
|
||||
]
|
||||
tipos = [
|
||||
:string, :string, :integer, :string, :string, :integer, :string,
|
||||
(lat.nil? ? :string : :float), # H
|
||||
(lng.nil? ? :string : :float), # I
|
||||
:string, :string, :string, :string, :string,
|
||||
*Array.new(VAZIAS_FINAIS.size, :string)
|
||||
]
|
||||
[valores, tipos]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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>
|
||||
<%# 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 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>
|
||||
<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>
|
||||
|
||||
<%# 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">
|
||||
</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">
|
||||
</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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) { mostrarErroBusca(data.erro || 'Não foi possível localizar a entrega.'); return; }
|
||||
visita = data.visita;
|
||||
renderCard();
|
||||
cardEl.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
mostrarErroBusca('Erro de conexão ao buscar a NF.');
|
||||
} finally {
|
||||
loadEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
function mostrarErroBusca(msg) { erroEl.textContent = msg; erroEl.classList.remove('hidden'); }
|
||||
|
||||
document.getElementById('btn-buscar').addEventListener('click', buscar);
|
||||
campoNf.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
||||
|
||||
// ── Card ────────────────────────────────────────────────────
|
||||
function renderCard() {
|
||||
const v = visita;
|
||||
document.getElementById('c-titulo').textContent = v.titulo || '—';
|
||||
document.getElementById('c-endereco').textContent = v.endereco || '—';
|
||||
document.getElementById('c-nf').textContent = v.nf ?? '—';
|
||||
document.getElementById('c-id').textContent = v.id ?? '—';
|
||||
document.getElementById('c-motorista').textContent = v.motorista || '—';
|
||||
document.getElementById('c-veiculo').textContent = v.veiculo || '—';
|
||||
document.getElementById('c-telefone').textContent = v.telefone || '—';
|
||||
renderBadge();
|
||||
renderFotos();
|
||||
renderCampos();
|
||||
}
|
||||
|
||||
function renderBadge() {
|
||||
const cfg = STATUS[visita.status] || { label: visita.status || '—', cls: 'bg-gray-800 text-gray-400', icone: '❓' };
|
||||
const b = document.getElementById('c-badge');
|
||||
b.className = 'inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold ' + cfg.cls;
|
||||
b.textContent = cfg.icone + ' ' + cfg.label;
|
||||
}
|
||||
|
||||
function renderFotos() {
|
||||
const wrap = document.getElementById('c-foto-wrap');
|
||||
if (visita.foto_fachada) {
|
||||
wrap.innerHTML =
|
||||
'<a href="' + esc(visita.foto_fachada) + '" target="_blank" rel="noopener" title="Abrir em tamanho real">' +
|
||||
'<img src="' + esc(visita.foto_fachada) + '" alt="Fachada" loading="lazy" ' +
|
||||
'class="w-full h-52 object-cover rounded-xl border border-white/10 hover:opacity-90 transition-opacity">' +
|
||||
'</a>';
|
||||
} else {
|
||||
wrap.innerHTML =
|
||||
'<div class="w-full h-52 rounded-xl border border-dashed border-white/10 flex items-center justify-center ' +
|
||||
'text-gray-600 text-sm">📷 Sem foto da fachada para esta entrega</div>';
|
||||
}
|
||||
|
||||
const extras = [...(visita.pictures || [])];
|
||||
if (visita.signature) extras.push(visita.signature);
|
||||
const extrasWrap = document.getElementById('c-extras-wrap');
|
||||
const grid = document.getElementById('c-extras');
|
||||
if (extras.length) {
|
||||
grid.innerHTML = extras.map(u =>
|
||||
'<a href="' + esc(u) + '" target="_blank" rel="noopener">' +
|
||||
'<img src="' + esc(u) + '" loading="lazy" class="w-full h-16 object-cover rounded-lg border border-white/10 hover:opacity-90">' +
|
||||
'</a>').join('');
|
||||
extrasWrap.classList.remove('hidden');
|
||||
} else {
|
||||
extrasWrap.classList.add('hidden');
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Campos click-to-edit ────────────────────────────────────
|
||||
function valorExibicao(def) {
|
||||
const v = visita[def.campo];
|
||||
if (def.tipo === 'status') return (STATUS[v] || {}).label || v || '—';
|
||||
if (def.tipo === 'motivo') return v ? (motivoLabel(v) || v) : '— sem motivo —';
|
||||
if (def.tipo === 'datetime') {
|
||||
if (!v) return '—';
|
||||
const s = String(v);
|
||||
return s.slice(8, 10) + '/' + s.slice(5, 7) + '/' + s.slice(0, 4) + ' ' + s.slice(11, 16);
|
||||
}
|
||||
return (v === null || v === undefined || v === '') ? '—' : String(v);
|
||||
}
|
||||
|
||||
function renderCampos() {
|
||||
const grid = document.getElementById('c-campos');
|
||||
grid.innerHTML = '';
|
||||
CAMPOS.forEach(def => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'campo group bg-[#1a1a1a] border border-white/5 rounded-xl px-4 py-3 cursor-pointer ' +
|
||||
'hover:border-orange-500/50 transition-colors' + (def.col2 ? ' sm:col-span-2' : '');
|
||||
div.dataset.campo = def.campo;
|
||||
pintarExibicao(div, def);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function pintarExibicao(div, def, flash) {
|
||||
div.innerHTML =
|
||||
'<p class="text-xs text-gray-500 mb-0.5">' + esc(def.label) + '</p>' +
|
||||
'<div class="flex items-start justify-between gap-2">' +
|
||||
'<p class="texto-valor text-sm text-white break-words min-h-[20px]">' + esc(valorExibicao(def)) + '</p>' +
|
||||
'<span class="text-gray-600 group-hover:text-orange-400 text-sm" title="Editar">✏️</span>' +
|
||||
'</div>';
|
||||
div.onclick = () => abrirEdicao(div, def);
|
||||
if (flash) {
|
||||
div.classList.add('ring-2', 'ring-green-500/70');
|
||||
setTimeout(() => div.classList.remove('ring-2', 'ring-green-500/70'), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function abrirEdicao(div, def) {
|
||||
div.onclick = null;
|
||||
div.classList.remove('cursor-pointer');
|
||||
const atual = visita[def.campo];
|
||||
|
||||
let editorHtml;
|
||||
const base = 'w-full bg-[#111] border border-orange-500/60 rounded-lg px-2.5 py-2 text-sm text-white focus:outline-none';
|
||||
if (def.tipo === 'status') {
|
||||
editorHtml = '<select class="ed ' + base + '">' +
|
||||
Object.entries(STATUS).map(([v, c]) =>
|
||||
'<option value="' + v + '"' + (v === atual ? ' selected' : '') + '>' + c.label + '</option>').join('') +
|
||||
'</select>';
|
||||
} else if (def.tipo === 'motivo') {
|
||||
editorHtml = '<select class="ed ' + base + '">' +
|
||||
'<option value="">— sem motivo —</option>' +
|
||||
MOTIVOS.map(m =>
|
||||
'<option value="' + esc(m.id) + '"' + (m.id === atual ? ' selected' : '') + '>' + esc(m.label) + '</option>').join('') +
|
||||
'</select>';
|
||||
} else if (def.tipo === 'textarea') {
|
||||
editorHtml = '<textarea rows="2" class="ed ' + base + '">' + esc(atual ?? '') + '</textarea>';
|
||||
} else if (def.tipo === 'datetime') {
|
||||
const valor = atual ? String(atual).slice(0, 16) : '';
|
||||
editorHtml = '<input type="datetime-local" value="' + esc(valor) + '" class="ed ' + base + '">';
|
||||
} else {
|
||||
editorHtml = '<input type="text" value="' + esc(atual ?? '') + '" class="ed ' + base + '">';
|
||||
}
|
||||
|
||||
div.innerHTML =
|
||||
'<p class="text-xs text-orange-400 mb-1">' + esc(def.label) + '</p>' +
|
||||
editorHtml +
|
||||
'<div class="flex items-center gap-2 mt-2">' +
|
||||
'<button type="button" class="btn-ok px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg">✓ Salvar</button>' +
|
||||
'<button type="button" class="btn-cancelar px-3 py-1.5 bg-white/5 hover:bg-white/10 text-gray-300 text-xs rounded-lg">✕ Cancelar</button>' +
|
||||
'<span class="msg-salvando hidden text-xs text-gray-400">Salvando…</span>' +
|
||||
'</div>' +
|
||||
'<p class="msg-erro hidden mt-1.5 text-xs text-red-400"></p>';
|
||||
|
||||
const ed = div.querySelector('.ed');
|
||||
ed.focus();
|
||||
|
||||
const cancelar = () => pintarExibicao(div, def) || div.classList.add('cursor-pointer');
|
||||
div.querySelector('.btn-cancelar').onclick = e => { e.stopPropagation(); cancelar(); };
|
||||
div.querySelector('.btn-ok').onclick = e => { e.stopPropagation(); salvar(div, def, ed.value); };
|
||||
ed.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') cancelar();
|
||||
if (e.key === 'Enter' && def.tipo !== 'textarea') { e.preventDefault(); salvar(div, def, ed.value); }
|
||||
});
|
||||
}
|
||||
|
||||
async function salvar(div, def, valor) {
|
||||
const msgErro = div.querySelector('.msg-erro');
|
||||
const msgSalvando = div.querySelector('.msg-salvando');
|
||||
msgErro.classList.add('hidden');
|
||||
msgSalvando.classList.remove('hidden');
|
||||
try {
|
||||
const body = { visit_id: visita.id };
|
||||
body[def.campo] = valor;
|
||||
const resp = await fetch(URLS.atualizar, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': CSRF
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!data.ok) {
|
||||
erro.textContent = data.erro || 'Não foi possível localizar a entrega.';
|
||||
erro.classList.remove('hidden');
|
||||
msgErro.textContent = data.erro || 'Erro ao salvar.';
|
||||
msgErro.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');
|
||||
// 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) {
|
||||
erro.textContent = 'Erro de conexão ao buscar a NF.';
|
||||
erro.classList.remove('hidden');
|
||||
msgErro.textContent = 'Erro de conexão ao salvar.';
|
||||
msgErro.classList.remove('hidden');
|
||||
} finally {
|
||||
loading.classList.add('hidden');
|
||||
msgSalvando.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
btn.addEventListener('click', buscar);
|
||||
campo.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); buscar(); } });
|
||||
// ── Histórico (modal) ───────────────────────────────────────
|
||||
const modal = document.getElementById('modal-hist');
|
||||
const corpo = document.getElementById('modal-hist-corpo');
|
||||
|
||||
function fecharModal() { modal.classList.add('hidden'); }
|
||||
document.getElementById('modal-hist-fechar').onclick = fecharModal;
|
||||
document.getElementById('modal-hist-overlay').onclick = fecharModal;
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') fecharModal(); });
|
||||
|
||||
document.getElementById('btn-historico').addEventListener('click', async () => {
|
||||
if (!visita) return;
|
||||
modal.classList.remove('hidden');
|
||||
corpo.innerHTML = '<p class="text-gray-400">Carregando…</p>';
|
||||
try {
|
||||
const resp = await fetch(URLS.historico + '?visit_id=' + encodeURIComponent(visita.id),
|
||||
{ headers: { 'Accept': 'application/json' } });
|
||||
const data = await resp.json();
|
||||
corpo.innerHTML = htmlHistorico(data);
|
||||
} catch (e) {
|
||||
corpo.innerHTML = '<p class="text-red-400">Erro ao carregar o histórico.</p>';
|
||||
}
|
||||
});
|
||||
|
||||
function nomeCampo(campo) {
|
||||
const def = CAMPOS.find(c => c.campo === campo);
|
||||
return def ? def.label : campo;
|
||||
}
|
||||
function fmtValor(campo, valor) {
|
||||
if (valor === null || valor === undefined || valor === '') return '—';
|
||||
if (campo === 'checkout_observation') return motivoLabel(valor) || valor;
|
||||
if (campo === 'status') return (STATUS[valor] || {}).label || valor;
|
||||
return String(valor);
|
||||
}
|
||||
|
||||
function htmlHistorico(data) {
|
||||
let html = '<div>' +
|
||||
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">📒 Nosso sistema (auditoria)</p>';
|
||||
const interno = (data.interno || []);
|
||||
if (!interno.length) {
|
||||
html += '<p class="text-gray-500">Nenhuma alteração registrada pelo sistema.</p>';
|
||||
} else {
|
||||
html += interno.map(l => {
|
||||
const mudancas = Object.keys(l.para || {}).map(campo =>
|
||||
'<li class="text-gray-300"><span class="text-gray-500">' + esc(nomeCampo(campo)) + ':</span> ' +
|
||||
'<span class="text-red-300/80 line-through">' + esc(fmtValor(campo, (l.de || {})[campo])) + '</span>' +
|
||||
' → <span class="text-green-300">' + esc(fmtValor(campo, l.para[campo])) + '</span></li>').join('');
|
||||
return '<div class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 mb-2">' +
|
||||
'<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">' +
|
||||
'<span>👤 ' + esc(l.usuario) + '</span><span>' + esc(l.quando) + '</span>' +
|
||||
'</div>' +
|
||||
'<ul class="space-y-0.5 text-xs">' + (mudancas || '<li class="text-gray-500">—</li>') + '</ul>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
html += '</div><div>' +
|
||||
'<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">🌐 SimpliRoute (API)</p>';
|
||||
const api = (data.api || []);
|
||||
if (!api.length) {
|
||||
html += '<p class="text-gray-500">Sem registros de histórico na API do SimpliRoute.</p>';
|
||||
} else {
|
||||
html += '<pre class="bg-[#1a1a1a] border border-white/5 rounded-xl p-3 text-xs text-gray-300 overflow-x-auto">' +
|
||||
esc(JSON.stringify(api, null, 2)) + '</pre>';
|
||||
}
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
51
app/views/admin/planilhas_simpli_route/show.html.erb
Normal file
51
app/views/admin/planilhas_simpli_route/show.html.erb
Normal file
@@ -0,0 +1,51 @@
|
||||
<%# app/views/admin/planilhas_simpliroute/show.html.erb %>
|
||||
<div class="space-y-6">
|
||||
|
||||
<%# Header %>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Planilha SimpliRoute</h1>
|
||||
<p class="text-gray-400 text-sm mt-0.5">Gera a planilha de carga (.xlsx) de uma operação vigente, já com lat/long do mês anterior</p>
|
||||
</div>
|
||||
|
||||
<%# Flash %>
|
||||
<%= render 'shared/flash' %>
|
||||
|
||||
<%# Explicação %>
|
||||
<div class="p-4 bg-blue-900/20 border border-blue-500/40 rounded-xl text-blue-200/90 text-sm flex items-start gap-2">
|
||||
<span class="text-base leading-none">ℹ️</span>
|
||||
<p>A <strong>Latitude/Longitude</strong> é reaproveitada do <strong>mês anterior</strong> da mesma
|
||||
operação (casando nome + endereço + supervisão). Pacientes <strong>novos</strong> ou sem
|
||||
correspondência saem <strong>sem</strong> lat/long — o SimpliRoute geocodifica na importação.
|
||||
Se a operação não tiver mês anterior, a planilha sai inteira sem lat/long.</p>
|
||||
</div>
|
||||
|
||||
<%# Seleção + download %>
|
||||
<div class="bg-[#111] border border-white/5 rounded-2xl p-5">
|
||||
<% if @operacoes_agrupadas.blank? %>
|
||||
<p class="text-gray-400 text-sm">Nenhuma operação encontrada.</p>
|
||||
<% else %>
|
||||
<%= form_with url: baixar_admin_planilha_simpli_route_path, method: :get, data: { turbo: false },
|
||||
class: 'flex flex-col sm:flex-row sm:items-end gap-3' do %>
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-1.5">Operação vigente</label>
|
||||
<select name="tabela" required
|
||||
class="w-full bg-[#1a1a1a] border border-white/10 rounded-xl px-3 py-3 text-white
|
||||
focus:outline-none focus:border-orange-500 min-h-[48px]">
|
||||
<% @operacoes_agrupadas.each do |titulo, ops| %>
|
||||
<optgroup label="<%= titulo %>">
|
||||
<% ops.each do |op| %>
|
||||
<option value="<%= op[:tabela] %>"><%= op[:label] %></option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-[#f97316] hover:bg-orange-500 text-white font-semibold rounded-xl
|
||||
transition-colors min-h-[48px] whitespace-nowrap">
|
||||
📥 Baixar planilha
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user