# app/helpers/application_helper.rb module ApplicationHelper include Pagy::Frontend # --------------------------------------------------------------------------- # Ícones # # Substituem os emojis antigos (que davam aparência "genérica de IA" e variavam # de forma/cor conforme o sistema operacional). Agora são SVG chapados vindos do # sprite em public/icons.svg (subset do Bootstrap Icons, licença MIT). # # O SVG usa `currentColor`, então a cor vem da classe CSS: # - padrão → laranja da marca (#f97316) # - ícones de status → passe `cor:` para preservar a leitura semântica # (ex.: icone(:sucesso, cor: "text-green-400")) # --------------------------------------------------------------------------- # Nome semântico (pt-BR) → id do símbolo no sprite. ICONES = { # Navegação dashboard: "bar-chart-fill", operacoes: "graph-up-arrow", consolidacoes: "box-seam-fill", arquivadas: "folder-fill", painel: "house-fill", usuarios: "people-fill", configuracoes: "gear-fill", auditoria: "search", planilha: "box-arrow-in-down", editar_lancamento: "pencil-fill", sair: "box-arrow-right", menu: "list", # Domínio caminhao: "truck", veiculo: "car-front-fill", motorista: "person-badge-fill", dinheiro: "cash-coin", pagamento: "cash-stack", custo: "cash", consolidado_pago: "briefcase-fill", calendario: "calendar-event", nota_fiscal: "receipt", documento: "file-earmark-text-fill", termo_especial: "clipboard-data-fill", apontamento: "pencil-square", local: "geo-alt-fill", operacao: "hospital-fill", retirada: "box-seam-fill", desconto: "exclamation-triangle-fill", global: "globe2", comparar: "arrow-left-right", pessoa: "person-fill", ticket: "ticket-perforated-fill", tag: "tag-fill", composicao: "puzzle-fill", ranking: "trophy-fill", historico: "clock-history", relogio: "clock-fill", parcial: "hourglass-split", diario: "journal-text", vazio: "inbox", dica: "lightbulb-fill", bonus: "star-fill", extraordinaria: "stars", calor: "fire", foto: "camera-fill", mapa: "map-fill", satelite: "broadcast-pin", escuro: "moon-fill", tela_cheia: "arrows-fullscreen", email: "envelope-fill", telefone: "telephone-fill", chave: "key-fill", # Ações adicionar: "plus-lg", fechar: "x-lg", confirmar: "check-lg", buscar: "search", editar: "pencil-fill", excluir: "trash-fill", arquivar: "archive-fill", restaurar: "arrow-clockwise", salvar: "floppy-fill", imprimir: "printer-fill", visualizar: "eye-fill", baixar: "download", pasta: "folder2-open", travado: "lock-fill", destravado: "unlock-fill", expandir: "chevron-down", recolher: "chevron-left", voltar: "arrow-left", avancar: "arrow-right", estornar: "arrow-counterclockwise", # Status (use sempre com `cor:` semântica) sucesso: "check-circle-fill", alerta: "exclamation-triangle-fill", erro: "x-circle-fill", desconhecido: "question-circle", cancelado: "slash-circle", ponto: "circle-fill", ponto_vazio: "circle" }.freeze # Renderiza um ícone do sprite. # # icone(:caminhao) # laranja da marca # icone(:sucesso, cor: "text-green-400") # status verde # icone(:fechar, cor: nil) # herda a cor do elemento pai # icone(:dashboard, titulo: "Painel") # acessível (vira role="img") def icone(nome, cor: "text-brand-laranja", tamanho: "w-[1.05em] h-[1.05em]", classe: nil, titulo: nil) simbolo = ICONES.fetch(nome.to_sym) { nome.to_s } css = ["inline-block align-[-0.125em] shrink-0", tamanho, cor, classe].compact.join(" ") tag.svg(class: css, fill: "currentColor", role: (titulo ? "img" : nil), aria: { hidden: (titulo ? nil : true), label: titulo }) do tag.use(href: "/icons.svg##{simbolo}") end end # Rótulo "ícone + texto" para link_to / button_to (que recebem o label como # argumento, onde não dá para escrever ERB). # # link_to rotulo(:salvar, 'Salvar rascunho'), caminho, class: '...' # # Atenção: NÃO serve para `f.submit` nem `button_to` sem bloco — ambos geram # , que não aceita HTML dentro. Nesses casos use # `button_tag`/`button_to ... do`. def rotulo(nome, texto, cor: "text-brand-laranja", tamanho: "w-4 h-4") safe_join([icone(nome, cor: cor, tamanho: tamanho), texto], " ") end # Opções [label, valor] para o select de perfil de usuário (form de usuário) def role_options_for_select User::ROLES_LABEL.map { |value, label| [label, value] } end # Link de navegação com estado ativo. # `icon:` aceita um nome semântico de ICONES; no estado ativo o ícone herda o # preto do fundo laranja, fora dele fica laranja da marca. def nav_link_to(label, path, icon: nil, **opts) active = current_page?(path) css = [ "flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all min-h-[44px]", active ? "bg-orange-500 text-black" : "text-gray-400 hover:bg-[#1a1a1a] hover:text-white" ].join(" ") conteudo = if icon safe_join([icone(icon, cor: (active ? "text-black" : "text-brand-laranja"), tamanho: "w-4 h-4"), label]) else label end link_to conteudo, path, class: css end # Formata moeda BR (milhar com ".", decimais com ","): R$ 1.234,56 def moeda(valor) number_to_currency(valor.to_f, unit: 'R$ ', separator: ',', delimiter: '.', precision: 2) end # Badge de status de consolidação. # O ícone herda a cor do texto do badge (cor: nil) para não brigar com o fundo. def badge_status(status) cfg = case status.to_s when 'rascunho' then { cor: 'bg-yellow-500 text-black', icone: :apontamento, label: 'Rascunho' } when 'finalizada' then { cor: 'bg-green-600 text-white', icone: :sucesso, label: 'Finalizada' } when 'arquivada' then { cor: 'bg-gray-700 text-gray-300', icone: :arquivar, label: 'Arquivada' } else { cor: 'bg-gray-800 text-gray-400', icone: :desconhecido, label: status.humanize } end badge_com_icone(cfg) end # Badge de status de pagamento de uma consolidação (derivado dos motoristas) def badge_pagamento(status) cfg = case status.to_s when 'pago' then { cor: 'bg-green-600 text-white', icone: :dinheiro, label: 'Pago' } when 'parcial' then { cor: 'bg-yellow-500 text-black', icone: :parcial, label: 'Parcial' } when 'pendente' then { cor: 'bg-gray-700 text-gray-300', icone: :relogio, label: 'Pendente' } else { cor: 'bg-gray-800 text-gray-400', icone: :desconhecido, label: status.to_s.humanize } end badge_com_icone(cfg) end def badge_com_icone(cfg) tag.span class: "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" do safe_join([icone(cfg[:icone], cor: nil, tamanho: "w-3.5 h-3.5"), cfg[:label]]) end end # Badge de papel do usuário (admin/gerente/operador/motorista) def badge_role(role) cfg = case role.to_s when 'admin' then { cor: 'bg-orange-500 text-black' } when 'gerente' then { cor: 'bg-orange-800 text-white' } when 'operador' then { cor: 'bg-gray-700 text-white' } when 'motorista' then { cor: 'bg-gray-900 text-orange-400 border border-orange-500' } when 'externo' then { cor: 'bg-blue-900 text-blue-200 border border-blue-500' } else { cor: 'bg-gray-800 text-gray-400' } end label = User::ROLES_LABEL[role.to_s] || role.to_s.humanize tag.span label, class: "inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold #{cfg[:cor]}" end # Badge de status ativo/inativo do usuário def badge_status_usuario(ativo) cor = ativo ? 'bg-green-600 text-white' : 'bg-red-600 text-white' label = ativo ? 'Ativo' : 'Inativo' tag.span label, class: "inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold #{cor}" end # Horário da última sincronização dos dados de operação. # # O backend atualiza os dados a cada 30 min, das 08h às 18h (horário de # Brasília). Como as tabelas externas de rastreio não têm um carimbo de # sincronização confiável, derivamos o último "slot" concluído a partir dessa # grade fixa. Usa Time.current (fuso do app), NUNCA Time.now — que pega o fuso # do container (UTC) e por isso mostrava o horário adiantado/errado. DADOS_SYNC_INICIO = 8 # abre às 08h DADOS_SYNC_FIM = 18 # fecha às 18h DADOS_SYNC_PASSO = 30 # minutos entre atualizações def ultima_atualizacao_dados(agora = Time.current) agora = agora.in_time_zone abre = agora.change(hour: DADOS_SYNC_INICIO, min: 0) fecha = agora.change(hour: DADOS_SYNC_FIM, min: 0) if agora < abre # Antes de abrir: a última atualização foi o fechamento do dia anterior. (abre - 1.day).change(hour: DADOS_SYNC_FIM, min: 0) elsif agora >= fecha fecha else # Arredonda para baixo ao múltiplo de 30 min dentro da janela. minuto = (agora.min / DADOS_SYNC_PASSO) * DADOS_SYNC_PASSO agora.change(min: minuto, sec: 0) end end # Rótulo amigável: "às 14:30" (hoje) ou "em 05/07 às 18:00" (outro dia). def ultima_atualizacao_label(agora = Time.current) t = ultima_atualizacao_dados(agora) if t.to_date == agora.in_time_zone.to_date "às #{t.strftime('%H:%M')}" else "em #{t.strftime('%d/%m')} às #{t.strftime('%H:%M')}" end end # Barra de progresso laranja def progress_bar(percentual, label: nil) pct = percentual.to_i.clamp(0, 100) tag.div class: "w-full" do concat tag.div(class: "flex justify-between text-xs text-gray-400 mb-1") { concat tag.span(label || "#{pct}% classificado") concat tag.span("#{pct}%") } concat tag.div(class: "w-full bg-[#2a2a2a] rounded-full h-2") { tag.div style: "width: #{pct}%", class: "h-2 rounded-full bg-orange-500 transition-all duration-500" } end end end