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 { // `overlay` é o
da prévia (nome mantido para não renomear em vão); // `caixaPreview` saiu junto com o modal. static targets = ["celula", "preview", "itemVeiculo", "motorista", "rotulo", "status", "linha", "busca", "contador", "overlay", "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 (pop-up por :target, CSS puro) ─────────────────────────────── // Abrir, fechar e trocar de folha são do NAVEGADOR: link para `#previa`, // link para `#sem-previa` e links `?veiculo=X#previa`. Nada disso depende // deste controller — é o que mantém a prévia viva quando o JS não carrega. // O que sobra aqui é recarregar a folha depois de uma edição. previewAberta() { return this.hasOverlayTarget && this.overlayTarget.matches(":target") } 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 o pop-up FECHADO não gera PDF nenhum — // o `loading="lazy"` do iframe já garante isso, e recarregar o src de um // iframe escondido forçaria o servidor a montar um PDF que ninguém vai ver. atualizarPreview() { if (!this.previewAberta()) { this.previewDesatualizado = true return } this.carregarPreview() } // O operador pode abrir a prévia DEPOIS de editar: aí o iframe ainda mostra a // folha antiga. Como quem abre é o `:target`, o gancho é o hashchange. aoTrocarHash() { if (this.previewAberta() && this.previewDesatualizado) 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 = `` } 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 } }