Files
Reem-Notas/app/javascript/controllers/romaneio_controller.js

241 lines
9.8 KiB
JavaScript

import { Controller } from "@hotwired/stimulus"
// Editor do romaneio: salva cada campo assim que ele muda e reatualiza a prévia
// (que é o PDF de verdade, num iframe).
//
// Por que salvar POR CAMPO e não com um submit de formulário: `campos_editados`
// precisa registrar exatamente o campo que um humano tocou. Um submit em bloco não
// distingue "redigitei o mesmo valor" de "nunca olhei" — marcaria os 5 campos das 29
// linhas como editados, e a reimportação seguinte preservaria endereços velhos no
// plano inteiro. A regra de reconciliação (Romaneios::Importador) só funciona com
// escrita por campo.
export default class extends Controller {
static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca",
"contador", "overlay", "caixaPreview", "tituloPreview"]
static values = { url: String, pdfUrl: String, veiculo: String }
// Recarregar o PDF a cada tecla travaria a tela; 600ms depois do último save é o
// suficiente para parecer imediato sem regerar o documento à toa.
static ATRASO_PREVIEW = 600
connect() {
this.timerPreview = null
// A prévia nasce fechada e DESATUALIZADA: o PDF só é gerado na primeira vez
// que alguém pede para ver. Antes o iframe era montado a cada carregamento da
// tela e gerava um PDF que muitas vezes ninguém olhava.
this.previewDesatualizado = true
// Com 72 carros a lista rola; sem isto, ao trocar de veículo o selecionado
// pode nascer fora da área visível e parece que a seleção não pegou.
const atual = this.itemVeiculoTargets.find((a) => a.dataset.atual === "1")
if (atual) atual.scrollIntoView({ block: "nearest", inline: "nearest" })
}
disconnect() {
if (this.timerPreview) clearTimeout(this.timerPreview)
}
// ── Edição ──────────────────────────────────────────────────────────────
salvarCelula(event) {
const input = event.currentTarget
// Sair do campo sem mudar nada não é edição — marcar como editado aqui
// congelaria o valor contra a próxima reimportação sem o operador querer.
if (input.value === input.dataset.original) return
this.gravar(input.dataset.linhaId, input.dataset.campo, input.value, input)
}
restaurar(event) {
const botao = event.currentTarget
const { linhaId, campo } = botao.dataset
const input = this.celulaTargets.find(
(c) => c.dataset.linhaId === linhaId && c.dataset.campo === campo
)
// valor null no corpo = "devolve o que o plano trouxe e destrava o campo"
this.gravar(linhaId, campo, null, input, botao)
}
alternarAparelho(event) {
const botao = event.currentTarget
const novo = botao.dataset.valor === "SIM" ? "" : "SIM"
this.gravar(botao.dataset.linhaId, "aparelho", novo, null, null, (dados) => {
botao.dataset.valor = dados.valor
botao.textContent = dados.valor || "—"
const estilo = dados.valor === "SIM"
? "bg-green-600/20 text-green-300 border-green-600/40"
: "bg-[#1a1a1a] text-gray-500 border-white/10"
botao.className = `${estilo} w-full min-h-[40px] rounded-lg border font-semibold text-xs hover:border-orange-500 transition-colors`
this.marcar(botao.dataset.linhaId, "aparelho", dados.editado)
})
}
salvarRotulo() {
this.enviar(`${this.urlValue}/atualizar_rotulo`, { rotulo_plano: this.rotuloTarget.value })
.then(() => this.agendarPreview())
}
salvarMotorista() {
// Não mexe na prévia: o nome do motorista não sai impresso — a linha do
// rodapé é preenchida à mão na retirada.
this.enviar(`${this.urlValue}/atualizar_veiculo`, {
veiculo: this.veiculoValue,
motorista: this.motoristaTarget.value
})
}
// ── Navegação e filtro ──────────────────────────────────────────────────
// A lista de veículos são LINKS de verdade (o veículo vive na query string), então
// clicar já navega sem JS. As setas só precisam achar o vizinho na mesma lista —
// é o que o operador faz o dia inteiro: percorrer carro a carro.
anterior() { this.mover(-1) }
proximo() { this.mover(1) }
mover(passo) {
const itens = this.itemVeiculoTargets
if (itens.length === 0) return
const atual = itens.findIndex((a) => a.dataset.atual === "1")
const destino = (atual === -1 ? 0 : atual) + passo
// Sem dar a volta de propósito: chegar na ponta e voltar ao primeiro carro faz
// o operador reimprimir folha que já separou, sem perceber.
if (destino < 0 || destino >= itens.length) return
window.location.href = itens[destino].href
}
filtrar() {
const termo = this.hasBuscaTarget ? this.buscaTarget.value.trim().toLowerCase() : ""
let visiveis = 0
this.linhaTargets.forEach((linha) => {
const casa = termo === "" || linha.dataset.busca.includes(termo)
linha.hidden = !casa
if (casa) visiveis += 1
})
// Sem o contador, uma busca que não casa com nada deixa a tabela vazia e
// parece plano vazio — o operador acha que perdeu o romaneio.
if (!this.hasContadorTarget) return
const total = this.linhaTargets.length
const plural = visiveis === 1 ? "parada" : "paradas"
this.contadorTarget.textContent = termo === ""
? `${total} ${total === 1 ? "parada" : "paradas"} nesta folha`
: `${visiveis} de ${total} ${plural} — filtrando por "${termo}"`
}
limparBusca() {
if (!this.hasBuscaTarget) return
this.buscaTarget.value = ""
this.filtrar()
this.buscaTarget.focus()
}
// ── Prévia (sobreposição) ───────────────────────────────────────────────
abrirPreview() {
if (!this.hasOverlayTarget) return
// Classe + atributo: só o atributo `hidden` não basta, porque a utility
// `.flex` do Tailwind vence o `[hidden] { display:none }` do preflight —
// foi assim que o modal ficou preso na tela do /admin/romaneios/13.
this.overlayTarget.hidden = false
this.overlayTarget.classList.remove("hidden")
this.overlayTarget.classList.add("flex")
// Trava o scroll do fundo: rolar a lista atrás de um modal aberto faz o
// operador perder o lugar quando fecha.
document.body.style.overflow = "hidden"
if (this.previewDesatualizado) this.carregarPreview()
}
fecharPreview() {
if (!this.hasOverlayTarget || this.overlayTarget.hidden) return
this.overlayTarget.hidden = true
this.overlayTarget.classList.remove("flex")
this.overlayTarget.classList.add("hidden")
document.body.style.overflow = ""
}
// Clique no fundo escuro fecha; clique DENTRO da caixa não — senão mexer no
// PDF fecharia a prévia sem querer.
fecharPorFora(event) {
if (this.hasCaixaPreviewTarget && this.caixaPreviewTarget.contains(event.target)) return
this.fecharPreview()
}
carregarPreview() {
if (!this.hasPreviewTarget) return
// Cache-bust em vez de reload(): reload reinicializa o visualizador de PDF do
// navegador e a tela pisca a cada salvamento.
this.previewTarget.src =
`${this.pdfUrlValue}?veiculo=${encodeURIComponent(this.veiculoValue)}&t=${Date.now()}`
this.previewDesatualizado = false
}
// Chamada depois de cada gravação. Com a prévia FECHADA não gera PDF nenhum —
// só marca que o próximo "Ver prévia" precisa buscar de novo.
atualizarPreview() {
if (!this.hasOverlayTarget || this.overlayTarget.hidden) {
this.previewDesatualizado = true
return
}
this.carregarPreview()
}
// ── Infra ───────────────────────────────────────────────────────────────
gravar(linhaId, campo, valor, input, botao, aoConcluir) {
this.status("Salvando…")
this.enviar(`${this.urlValue}/atualizar_linha`, { linha_id: linhaId, campo, valor })
.then((dados) => {
if (!dados || !dados.ok) return
if (input) {
// Escrevemos de volta o valor QUE O SERVIDOR GRAVOU: a escrita é
// last-write-wins, e assim uma sobreposição de duas abas fica visível em
// vez de silenciosa.
input.value = dados.valor
input.dataset.original = dados.valor
}
this.marcar(linhaId, campo, dados.editado)
if (aoConcluir) aoConcluir(dados)
this.status("Salvo")
this.agendarPreview()
})
.catch(() => this.status("Não deu para salvar — tente de novo"))
}
marcar(linhaId, campo, editado) {
const alvo = this.element.querySelector(`[data-romaneio-marcador="${linhaId}-${campo}"]`)
if (!alvo) return
if (!editado) {
alvo.innerHTML = ""
return
}
alvo.innerHTML =
`<button type="button" title="Editado — não será sobrescrito na reimportação. Clique para restaurar o valor do plano." ` +
`data-action="romaneio#restaurar" data-linha-id="${linhaId}" data-campo="${campo}" ` +
`class="text-orange-400 hover:text-orange-200 text-xs leading-none">●</button>`
}
agendarPreview() {
if (this.timerPreview) clearTimeout(this.timerPreview)
this.timerPreview = setTimeout(() => this.atualizarPreview(), this.constructor.ATRASO_PREVIEW)
}
enviar(url, corpo) {
return fetch(url, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
},
body: JSON.stringify(corpo)
}).then((r) => (r.ok ? r.json() : Promise.reject(r)))
}
status(texto) {
if (this.hasStatusTarget) this.statusTarget.textContent = texto
}
}