// app/javascript/controllers/validacao_controller.js // Stimulus controller — Wizard Passo 2 (validação de entregas) // Atualização em tempo real: toggles, ações em massa, barra de progresso, resumo lateral. import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "linha", "checkbox", "selecionarTodos", "busca", "buscaVazia", "resumo", "listaCol", "btnResumoLabel", "toggles", "barra", "barraManuais", "percentual", "chipVeiculo", "consolidarBtn", "consolidarHint", "contadorClassificadas", "contadorTotal", "contadorManuais", "manuaisInfo", "qtdNormal", "qtdRetirada", "qtdBonus", "qtdDesconto", "qtdExtraordinaria", "qtdTermo", "qtdManuais", "valorTotal" ] static values = { url: String, // POST classificar massaUrl: String, // POST classificar_em_massa motorista: String, total: Number, veiculos: Array, // veículo(s) selecionado(s) — filtro multi-seleção precoExtraordinaria: Number // preço padrão sugerido no prompt } connect() { // Carrega estado inicial renderizado pelo servidor const el = document.getElementById("validacao-estado-inicial") if (el) { const estado = JSON.parse(el.textContent) this.atualizarResumo(estado) } } // ── Toggle individual ────────────────────────────────────── async classificar(event) { const btn = event.currentTarget const tracking = btn.dataset.tracking const tipo = btn.dataset.tipo // Pilar extraordinária: ao ATIVAR (botão ainda sem ring-2), pergunta o valor. const ativando = !btn.classList.contains("ring-2") let valor = null if (tipo === "extraordinaria" && ativando) { valor = this.pedirValor() if (valor === null) return // admin cancelou } btn.disabled = true try { const resp = await this.post(this.urlValue, { tracking_id: tracking, tipo: tipo, motorista: this.motoristaValue, vehicle: this.veiculosValue, valor: valor }) // resp.ativo indica se o pilar ficou ligado ou desligado (toggle) this.setToggle(tracking, tipo, resp.ativo) this.atualizarResumo(resp) } catch (e) { alert("Erro ao classificar entrega. Tente novamente.") } finally { btn.disabled = false } } // ── Oculta/mostra o resumo lateral (lista ocupa a largura toda) ── toggleResumo() { const escondido = this.resumoTarget.classList.toggle("hidden") if (this.hasListaColTarget) { this.listaColTarget.classList.toggle("lg:col-span-4", escondido) this.listaColTarget.classList.toggle("lg:col-span-3", !escondido) } if (this.hasBtnResumoLabelTarget) this.btnResumoLabelTarget.textContent = escondido ? "Mostrar resumo" : "Ocultar resumo" } // ── Busca na listagem de notas (filtra os cards por NF/local/endereço/veículo) ── filtrarBusca(event) { const q = (event.currentTarget.value || "").trim().toLowerCase() let visiveis = 0 this.linhaTargets.forEach(l => { const ok = q === "" || (l.dataset.busca || "").includes(q) l.classList.toggle("hidden", !ok) if (ok) visiveis++ }) if (this.hasBuscaVaziaTarget) this.buscaVaziaTarget.classList.toggle("hidden", q === "" || visiveis > 0) } // ── Selecionar / desmarcar todos os checkboxes (só os visíveis na busca) ── selecionarTodos(event) { const marcar = event.currentTarget.checked this.checkboxTargets.forEach(c => { const linha = c.closest("[data-busca]") if (linha && linha.classList.contains("hidden")) return c.checked = marcar }) } // O pilar está ativo (ring-2) na entrega indicada? pilarAtivo(tracking, tipo) { const grupo = this.togglesTargets.find(t => t.dataset.tracking === tracking) if (!grupo) return false const btn = grupo.querySelector(`.toggle-btn[data-tipo="${tipo}"]`) return !!btn && btn.classList.contains("ring-2") } // ── Aplicar/remover o pilar nos selecionados (toggle em massa) ── async marcarSelecionados(event) { const tipo = event.currentTarget.dataset.tipo const ids = this.checkboxTargets.filter(c => c.checked).map(c => c.value) if (ids.length === 0) { alert("Selecione ao menos uma entrega (checkbox à esquerda).") return } // Toggle em massa: se TODAS as selecionadas já têm o pilar, remove de todas; // caso contrário, aplica em todas. const remover = ids.every(id => this.pilarAtivo(id, tipo)) let valor = null if (tipo === "extraordinaria" && !remover) { valor = this.pedirValor() if (valor === null) return } const resp = await this.post(this.massaUrlValue, { tipo: tipo, motorista: this.motoristaValue, vehicle: this.veiculosValue, tracking_ids: ids, acao: remover ? "remover" : "adicionar", valor: valor }) ids.forEach(id => this.setToggle(id, tipo, !remover)) this.checkboxTargets.forEach(c => c.checked = false) if (this.hasSelecionarTodosTarget) this.selecionarTodosTarget.checked = false this.atualizarResumo(resp) } // ── Helpers ──────────────────────────────────────────────── async post(url, body) { const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content }, body: JSON.stringify(body) }) if (!resp.ok) throw new Error(`HTTP ${resp.status}`) return resp.json() } // Liga/desliga UM pilar (os demais pilares da entrega ficam como estão) setToggle(tracking, tipo, ativo) { const grupo = this.togglesTargets.find(t => t.dataset.tracking === tracking) if (!grupo) return const btn = grupo.querySelector(`.toggle-btn[data-tipo="${tipo}"]`) if (btn) { btn.classList.toggle("ring-2", ativo) btn.classList.toggle("ring-green-400", ativo) btn.classList.toggle("scale-105", ativo) btn.classList.toggle("opacity-50", !ativo) } // a linha está "classificada" se tiver qualquer pilar ligado const algumAtivo = grupo.querySelectorAll(".toggle-btn.ring-2").length > 0 const linha = this.linhaTargets.find(l => l.dataset.tracking === tracking) if (linha) linha.dataset.classificada = algumAtivo ? "1" : "0" } atualizarResumo(dados) { const porTipo = dados.por_tipo || {} this.qtdNormalTarget.textContent = porTipo["entrega_normal"] || 0 this.qtdRetiradaTarget.textContent = porTipo["retirada"] || 0 this.qtdBonusTarget.textContent = porTipo["bonus"] || 0 this.qtdDescontoTarget.textContent = porTipo["desconto"] || 0 if (this.hasQtdExtraordinariaTarget) this.qtdExtraordinariaTarget.textContent = porTipo["extraordinaria"] || 0 if (this.hasQtdTermoTarget) this.qtdTermoTarget.textContent = porTipo["termo"] || 0 const valor = dados.valor_total || 0 this.valorTotalTarget.textContent = "R$ " + valor.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) // Lançamentos manuais do motorista (apontamentos + termo): feedback visual. // Ficam fora do denominador de elegíveis (o gate de finalização não muda). const manuais = dados.manuais || 0 if (this.hasQtdManuaisTarget) this.qtdManuaisTarget.textContent = manuais if (this.hasContadorManuaisTarget) this.contadorManuaisTarget.textContent = manuais if (this.hasManuaisInfoTarget) this.manuaisInfoTarget.classList.toggle("hidden", manuais <= 0) // Barra/contador refletem o ESCOPO atual (veículo selecionado, se houver). const classificadas = (dados.classificadas_escopo ?? dados.classificadas) || 0 this.contadorClassificadasTarget.textContent = classificadas const total = this.totalValue || parseInt(this.contadorTotalTarget.textContent) const pct = total > 0 ? Math.round((classificadas / total) * 100) : 0 this.percentualTarget.textContent = `${Math.min(pct, 100)}%` // Barra com 2 segmentos, proporcionais ao total geral (elegíveis + manuais): // laranja/verde = elegíveis classificadas · azul = lançamentos manuais. const denom = total + manuais this.barraTarget.style.width = denom > 0 ? `${(classificadas / denom) * 100}%` : "0%" if (this.hasBarraManuaisTarget) this.barraManuaisTarget.style.width = denom > 0 ? `${(manuais / denom) * 100}%` : "0%" // Cor do segmento elegível: verde quando todas as elegíveis estão classificadas. this.barraTarget.classList.toggle("bg-green-500", pct >= 100) this.barraTarget.classList.toggle("bg-orange-500", pct < 100) // Botão "Consolidar este veículo" (só existe quando UM veículo está // selecionado e ainda não fechado): habilita quando o escopo chega a 100%. if (this.hasConsolidarBtnTarget) { const pronto = pct >= 100 this.consolidarBtnTarget.disabled = !pronto this.consolidarBtnTarget.classList.toggle("bg-green-600", pronto) this.consolidarBtnTarget.classList.toggle("hover:bg-green-500", pronto) this.consolidarBtnTarget.classList.toggle("text-white", pronto) this.consolidarBtnTarget.classList.toggle("cursor-pointer", pronto) this.consolidarBtnTarget.classList.toggle("bg-[#2a2a2a]", !pronto) this.consolidarBtnTarget.classList.toggle("text-gray-600", !pronto) this.consolidarBtnTarget.classList.toggle("cursor-not-allowed", !pronto) if (this.hasConsolidarHintTarget) this.consolidarHintTarget.textContent = pronto ? "Pronto para consolidar." : "Classifique 100% deste veículo para consolidar." } if (dados.veiculos_status) this.atualizarChips(dados.veiculos_status) } // Atualiza os chips "fechar por veículo" (contador + estado fechado) a partir // do status devolvido pelo servidor. atualizarChips(status) { if (!this.hasChipVeiculoTarget) return const porVeiculo = {} status.forEach(v => { porVeiculo[v.vehicle] = v }) const LARANJA = ["border-orange-500", "text-orange-400", "bg-orange-500/10"] const VERDE_FECHADO = ["border-green-500", "text-green-400", "bg-green-500/10"] const VERDE_PRONTO = ["border-green-600/50", "text-green-400"] const ARQUIVADO = ["border-dashed", "border-[#3a3a3a]", "text-gray-500"] const NEUTRO = ["border-[#2a2a2a]", "text-gray-300"] const TODAS = [...LARANJA, ...VERDE_FECHADO, ...VERDE_PRONTO, ...ARQUIVADO, ...NEUTRO] this.chipVeiculoTargets.forEach(chip => { const dados = porVeiculo[chip.dataset.vehicle] if (!dados) return const feito = chip.querySelector("[data-chip-feito]") const total = chip.querySelector("[data-chip-total]") const check = chip.querySelector("[data-chip-check]") const lock = chip.querySelector("[data-chip-lock]") if (feito) feito.textContent = dados.classificadas if (total) total.textContent = dados.eligible if (check) check.textContent = dados.completo ? "✓" : "" if (lock) lock.classList.toggle("hidden", !dados.fechado) // Cor determinística: selecionado(laranja) > consolidado(verde+🔒) > // pronto(verde) > arquivado(cinza) > neutro. chip.classList.remove(...TODAS) if (this.veiculosValue.includes(chip.dataset.vehicle)) { chip.classList.add(...LARANJA) } else if (dados.fechado) { chip.classList.add(...VERDE_FECHADO) } else if (dados.completo) { chip.classList.add(...VERDE_PRONTO) } else if (dados.arquivado) { chip.classList.add(...ARQUIVADO) } else { chip.classList.add(...NEUTRO) } }) } labelTipo(tipo) { return { entrega_normal: "Normal", retirada: "Retirada", bonus: "Bônus", desconto: "Desconto", extraordinaria: "Extraordinária" }[tipo] || tipo } // Pergunta o valor da entrega extraordinária (pré-preenchido com o padrão). // Retorna o número (>= 0) ou null se o admin cancelar. pedirValor() { const padrao = (this.precoExtraordinariaValue || 0).toFixed(2).replace(".", ",") const entrada = prompt("Valor desta entrega extraordinária (R$):", padrao) if (entrada === null) return null const num = parseFloat(entrada.replace(",", ".")) if (isNaN(num) || num < 0) { alert("Valor inválido. Informe um número maior ou igual a zero.") return null } return num } }