Atualização floresta

This commit is contained in:
Luiz Silva 2026-05-31 11:27:51 -03:00
parent 0886fd5c3e
commit 2d4d8f8929
103 changed files with 19266 additions and 14160 deletions

View file

@ -23,7 +23,7 @@
</template>
<script lang="ts">
import { dayjsbr } from "p-comuns"
import dayjs from "dayjs"
import { computed, defineComponent, type PropType } from "vue"
import type { CampoDensidade, CampoVariante } from "../../tipos"
import type { PadroesEntradas } from "./tiposEntradas"
@ -122,13 +122,9 @@ export default defineComponent({
}
})
const modoEfetivo = computed<"data" | "dataHora">(
() => opcoesEfetivas.value.modo ?? "dataHora",
)
const modoEfetivo = computed<"data" | "dataHora">(() => opcoesEfetivas.value.modo ?? "dataHora")
const desabilitadoEfetivo = computed<boolean>(() =>
Boolean(props.desabilitado),
)
const desabilitadoEfetivo = computed<boolean>(() => Boolean(props.desabilitado))
const tipoInput = computed<"date" | "datetime-local">(() =>
modoEfetivo.value === "data" ? "date" : "datetime-local",
@ -136,23 +132,21 @@ export default defineComponent({
function isoParaInputDatetime(valorIso: string): string {
if (modoEfetivo.value === "data") {
return dayjsbr(valorIso).format("YYYY-MM-DD")
return dayjs(valorIso).format("YYYY-MM-DD")
}
return dayjsbr(valorIso).format("YYYY-MM-DDTHH:mm")
return dayjs(valorIso).format("YYYY-MM-DDTHH:mm")
}
function inputDatetimeParaIsoLocal(valorInput: string): string {
if (modoEfetivo.value === "data") {
return dayjsbr(`${valorInput}T00:00`).format()
return dayjs(`${valorInput}T00:00`).format()
}
return dayjsbr(valorInput).format()
return dayjs(valorInput).format()
}
const effectiveModelValue = computed<string | null>(() => {
// Prioridade: value (novo) se vier definido; senão usa modelValue (antigo)
return props.value !== undefined
? (props.value ?? null)
: props.modelValue
return props.value !== undefined ? (props.value ?? null) : props.modelValue
})
const valor = computed<string>({

View file

@ -0,0 +1,14 @@
.eli-entrada__prefixo,
.eli-entrada__sufixo {
opacity: 0.75;
font-size: 0.9em;
white-space: nowrap;
}
.eli-entrada__prefixo {
margin-right: 6px;
}
.eli-entrada__sufixo {
margin-left: 6px;
}

View file

@ -0,0 +1,112 @@
/**
* Helpers de formatação e parsing numérico usados pelo EliEntradaNumero.
* Extraídos do componente Vue para manter o arquivo dentro de 250 linhas.
*/
export function casasDecimaisFromPrecisao(precisao: number): number {
if (!Number.isFinite(precisao) || precisao <= 0) return 0
if (precisao >= 1) return 0
const texto = precisao.toString()
if (texto.includes("e-")) {
const [, exp] = texto.split("e-")
const n = Number(exp)
return Number.isFinite(n) ? n : 0
}
const idx = texto.indexOf(".")
if (idx === -1) return 0
const dec = texto.slice(idx + 1).replace(/0+$/, "")
return dec.length
}
/**
* Analisa a entrada de texto e converte em um valor numérico válido (float).
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
*
* @param texto - O texto bruto digitado ou vindo do estado
* @returns O número processado ou `null` caso inválido ou vazio
*/
export function parseNumero(texto: string): number | null {
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
if (!normalizado) return null
const n = Number(normalizado)
if (Number.isNaN(n)) return null
return n
}
/**
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
*
* @param value - O valor numérico de fato emitido
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
* @returns String formatada amigável para exibição em pt-BR
*/
export function formatarNumero(value: number | null | undefined, casas: number | null) {
if (value === null || value === undefined) return ""
if (casas === null) return String(value).replace(/\./g, ",")
const fixed = Number(value).toFixed(Math.max(0, casas))
return fixed.replace(/\./g, ",")
}
/**
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
*
* @param texto - String de entrada
* @returns Apenas dígitos de 0 a 9
*/
export function somenteNumeros(texto: string) {
return (texto ?? "").replace(/\D+/g, "")
}
/**
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
*
* @param texto - Texto misto de entrada
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
*/
export function somenteNumerosESeparadorDecimal(texto: string) {
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
const matchSep = limpo.match(/[.,]/)
if (!matchSep) return limpo
const sep = matchSep[0]
const idx = limpo.indexOf(sep)
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
const antes = antesRaw.length ? antesRaw : "0"
return `${antes}${sep}${depois}`
}
/**
* Trunca a parte decimal de um texto numérico ao número máximo de casas permitidas.
* Aplicado durante a digitação para evitar que o usuário ultrapasse a precisão configurada.
*
* @param texto - Texto numérico já higienizado (com separador único)
* @param casas - Número máximo de casas decimais; `null` = sem limite
* @returns Texto com a parte decimal truncada no limite especificado
*/
export function limitarCasasDecimais(texto: string, casas: number | null) {
if (casas === null) return texto
if (casas <= 0) return texto.replace(/[.,]/g, "")
const matchSep = texto.match(/[.,]/)
if (!matchSep) return texto
const sep = matchSep[0]
const idx = texto.indexOf(sep)
const inteiro = texto.slice(0, idx)
const frac = texto.slice(idx + 1)
return `${inteiro}${sep}${frac.slice(0, casas)}`
}
/**
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
*
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
* @returns A conversão parcial estrita ou `null` caso falhe
*/
export function valorParcialDecimal(texto: string): number | null {
const m = texto.match(/^(\d+)[.,]$/)
if (!m) return null
const n = Number(m[1])
return Number.isNaN(n) ? null : n
}

View file

@ -27,126 +27,19 @@
<script lang="ts">
import { computed, defineComponent, type PropType, ref, watch } from "vue"
import type { PadroesEntradas } from "./tiposEntradas"
import "./EliEntradaNumero.css"
import {
casasDecimaisFromPrecisao,
formatarNumero,
limitarCasasDecimais,
parseNumero,
somenteNumeros,
somenteNumerosESeparadorDecimal,
valorParcialDecimal,
} from "./EliEntradaNumero.helpers"
type EntradaNumero = PadroesEntradas["numero"]
function casasDecimaisFromPrecisao(precisao: number): number {
if (!Number.isFinite(precisao) || precisao <= 0) return 0
if (precisao >= 1) return 0
// Preferência por contar casas decimais na representação (ex.: 0.01 -> 2)
const texto = precisao.toString()
if (texto.includes("e-")) {
const [, exp] = texto.split("e-")
const n = Number(exp)
return Number.isFinite(n) ? n : 0
}
const idx = texto.indexOf(".")
if (idx === -1) return 0
const dec = texto.slice(idx + 1).replace(/0+$/, "")
return dec.length
}
/**
* Analisa a entrada de texto e converte em um valor numérico válido (float).
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
*
* @param texto - O texto bruto digitado ou vindo do estado
* @returns O número processado ou `null` caso inválido ou vazio
*/
function parseNumero(texto: string): number | null {
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
if (!normalizado) return null
const n = Number(normalizado)
if (Number.isNaN(n)) return null
return n
}
/**
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
* Em caso de precisão nula (modo livre), preserva a semântica de exibição direta sem forçar o preenchimento de zeros (toFixed).
*
* @param value - O valor numérico de fato emitido
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
* @returns String formatada amigável para exibição em pt-BR
*/
function formatarNumero(
value: number | null | undefined,
casas: number | null,
) {
if (value === null || value === undefined) return ""
if (casas === null) return String(value).replace(/\./g, ",")
// Garantimos que o texto visual corresponda ao value (fixed).
const fixed = Number(value).toFixed(Math.max(0, casas))
// Exibe com vírgula (pt-BR)
return fixed.replace(/\./g, ",")
}
/**
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
*
* @param texto - String de entrada
* @returns Apenas dígitos de 0 a 9
*/
function somenteNumeros(texto: string) {
return (texto ?? "").replace(/\D+/g, "")
}
/**
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
* Colapsa múltiplos separadores e normaliza se o separador vier no início (",4" => "0,4").
*
* @param texto - Texto misto de entrada
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
*/
function somenteNumerosESeparadorDecimal(texto: string) {
// Mantém apenas números e UM separador decimal ("," ou "."), sem sinais.
// Implementação baseada em regex + colapso de separadores.
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
const matchSep = limpo.match(/[.,]/)
if (!matchSep) return limpo
const sep = matchSep[0]
const idx = limpo.indexOf(sep)
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
// Se o usuário começa pelo separador (",4"), normalizamos para "0,4".
const antes = antesRaw.length ? antesRaw : "0"
return `${antes}${sep}${depois}`
}
function limitarCasasDecimais(texto: string, casas: number | null) {
if (casas === null) return texto
if (casas <= 0) return texto.replace(/[.,]/g, "")
const matchSep = texto.match(/[.,]/)
if (!matchSep) return texto
const sep = matchSep[0]
const idx = texto.indexOf(sep)
const inteiro = texto.slice(0, idx)
const frac = texto.slice(idx + 1)
return `${inteiro}${sep}${frac.slice(0, casas)}`
}
/**
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
* Identifica se a string tem este formato pendente e retorna temporariamente sua base inteira
* sem permitir que o valor se torne nulo.
*
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
* @returns A conversão parcial estrita ou `null` caso falhe
*/
function valorParcialDecimal(texto: string): number | null {
// Regra (B): se o usuário digitou "1," ou "1." emite 1.
const m = texto.match(/^(\d+)[.,]$/)
if (!m) return null
const n = Number(m[1])
return Number.isNaN(n) ? null : n
}
export default defineComponent({
name: "EliEntradaNumero",
inheritAttrs: false,
@ -229,12 +122,8 @@ export default defineComponent({
}
// Modo livre (sem precisao) ou inteiro: aceita números e 1 separador (no caso livre)
const base = isInteiro.value
? somenteNumeros(texto)
: somenteNumerosESeparadorDecimal(texto)
const textoFiltrado = isInteiro.value
? base
: limitarCasasDecimais(base, casasDecimais.value)
const base = isInteiro.value ? somenteNumeros(texto) : somenteNumerosESeparadorDecimal(texto)
const textoFiltrado = isInteiro.value ? base : limitarCasasDecimais(base, casasDecimais.value)
// Emissão:
// - vazio => null
@ -242,9 +131,7 @@ export default defineComponent({
// - caso geral => parse normal
let out: EntradaNumero["value"] = null
if (textoFiltrado) {
const parcial = isInteiro.value
? null
: valorParcialDecimal(textoFiltrado)
const parcial = isInteiro.value ? null : valorParcialDecimal(textoFiltrado)
const n = parcial ?? parseNumero(textoFiltrado)
out = (n === null ? null : n) as EntradaNumero["value"]
}
@ -269,19 +156,3 @@ export default defineComponent({
})
</script>
<style>
.eli-entrada__prefixo,
.eli-entrada__sufixo {
opacity: 0.75;
font-size: 0.9em;
white-space: nowrap;
}
.eli-entrada__prefixo {
margin-right: 6px;
}
.eli-entrada__sufixo {
margin-left: 6px;
}
</style>

View file

@ -23,14 +23,7 @@
</template>
<script lang="ts">
import {
computed,
defineComponent,
onMounted,
type PropType,
ref,
watch,
} from "vue"
import { computed, defineComponent, onMounted, type PropType, ref, watch } from "vue"
import { VSelect } from "vuetify/components"
import type { PadroesEntradas } from "./tiposEntradas"

View file

@ -65,8 +65,7 @@ export default defineComponent({
const inputMode = computed<string | undefined>(() => {
if (formato.value === "telefone") return "tel"
if (formato.value === "cpfCnpj" || formato.value === "cep")
return "numeric"
if (formato.value === "cpfCnpj" || formato.value === "cep") return "numeric"
return undefined
})

View file

@ -7,6 +7,16 @@ import EliEntradaTexto from "./EliEntradaTexto.vue"
import type { TipoEntrada } from "./tiposEntradas"
/**
* Mapa (dispatch table) que associa cada `TipoEntrada` ao seu componente Vue correspondente.
* Usado pelo sistema de filtro avançado para renderizar dinamicamente o campo correto
* a partir da configuração declarada em `tipoEliTabelaConsulta.filtroAvancado[].entrada`.
*
* Adicionar um novo tipo de entrada requer:
* 1. Criar o componente Vue
* 2. Adicionar a chave em `TipoEntrada` (tiposEntradas.ts)
* 3. Registrar aqui no mapa
*/
export const registryTabelaCelulas = {
texto: EliEntradaTexto,
numero: EliEntradaNumero,

View file

@ -2,6 +2,14 @@ function somenteNumeros(v: string): string {
return v.replace(/\D+/g, "")
}
/**
* Formata um CPF ou CNPJ aplicando a máscara brasileira padrão durante a digitação.
* Detecta automaticamente o tipo pelo comprimento (≤11 dígitos = CPF, >11 = CNPJ).
* Não valida o dígito verificador — apenas aplica a formatação visual.
*
* @param v - Valor bruto (pode conter letras, pontos, traços etc.)
* @returns String formatada no padrão `000.000.000-00` (CPF) ou `00.000.000/0000-00` (CNPJ)
*/
export function formatarCpfCnpj(v: string): string {
const d = somenteNumeros(v)

View file

@ -2,6 +2,20 @@
width: 100%;
}
.eli-tabela__table-wrapper {
width: 100%;
overflow-x: auto;
}
.eli-tabela__td--estados {
padding: 0;
}
.eli-tabela__estados-inline {
border: none;
border-radius: 0;
}
.eli-tabela__table {
width: 100%;
border-collapse: separate;
@ -29,21 +43,30 @@
overflow: visible;
}
.eli-tabela__tbody .eli-tabela__tr .eli-tabela__td {
background: var(--cor-eli-superficie, #ffffff);
}
.eli-tabela__tbody .eli-tabela__tr--zebra .eli-tabela__td {
background: rgba(15, 23, 42, 0.02);
background: var(--cor-eli-primaria-zebra, #F6F9E8);
}
.eli-tabela__th,
.eli-tabela__td {
padding: 10px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid var(--cor-eli-borda);
vertical-align: top;
}
.eli-tabela__th {
text-align: left;
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
font-size: 0.82rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--cor-eli-neutro-escuro, #3E4436);
background: var(--cor-eli-primaria-suave, #BFC99C);
border-bottom: 2px solid var(--cor-eli-primaria-bg, #EEF3D0);
}
.eli-tabela__th--ordenavel {
@ -71,16 +94,16 @@
.eli-tabela__th-botao:hover,
.eli-tabela__th-botao:focus-visible {
color: rgba(15, 23, 42, 0.85);
color: var(--cor-eli-primaria-texto, #2E3F00);
}
.eli-tabela__th-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria, #688C00);
outline-offset: 2px;
}
.eli-tabela__th-botao--ativo {
color: rgba(15, 23, 42, 0.85);
color: var(--cor-eli-primaria-texto, #2E3F00);
}
.eli-tabela__th-texto {
@ -110,14 +133,19 @@
}
.eli-tabela--erro {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid rgba(220, 53, 69, 0.35);
border-radius: 12px;
padding: 12px;
}
.eli-tabela--carregando {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
@ -134,12 +162,33 @@
}
.eli-tabela--vazio {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__estado-icone {
flex-shrink: 0;
color: rgba(15, 23, 42, 0.45);
}
.eli-tabela__estado-icone--erro {
color: var(--cor-eli-erro);
}
.eli-tabela__estado-icone--spin {
animation: eli-tabela-spin 1s linear infinite;
}
@keyframes eli-tabela-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.eli-tabela__th--acoes {
text-align: right;
white-space: nowrap;
@ -222,8 +271,8 @@
padding: 0 14px;
border-radius: var(--eli-tabela-cabecalho-controle-radius);
border: none;
background: rgba(37, 99, 235, 0.12);
color: rgba(37, 99, 235, 0.95);
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria-escura);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
@ -233,11 +282,11 @@
.eli-tabela__acoes-cabecalho-botao:hover,
.eli-tabela__acoes-cabecalho-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
background: var(--cor-eli-primaria-clara);
}
.eli-tabela__acoes-cabecalho-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -274,7 +323,7 @@
}
.eli-tabela__acoes-toggle:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -289,7 +338,7 @@
padding: 6px 0;
margin: 0;
list-style: none;
background: #ffffff;
background: var(--cor-eli-superficie);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
@ -365,7 +414,7 @@
}
.eli-tabela__expander-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -445,8 +494,8 @@
.eli-tabela__checkbox:checked,
.eli-tabela__checkbox:indeterminate {
background-color: #1976d2;
border-color: #1976d2;
background-color: var(--cor-eli-primaria);
border-color: var(--cor-eli-primaria);
}
.eli-tabela__checkbox:checked::after {
@ -473,6 +522,6 @@
}
.eli-tabela__checkbox:focus-visible {
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.3);
box-shadow: 0 0 0 2px var(--cor-eli-primaria-clara);
}

View file

@ -31,57 +31,60 @@
<EliTabelaModalFiltroAvancado
:aberto="modalFiltroAberto"
:filtrosBase="tabela.filtroAvancado ?? []"
:filtrosBase="(tabela.filtroAvancado ?? []) as FiltroBaseModal[]"
:modelo="filtrosUi"
:modeloFormula="filtrosFormula"
@fechar="fecharModalFiltro"
@limpar="limparFiltrosAvancados"
@salvar="salvarFiltrosAvancados"
/>
<table class="eli-tabela__table">
<EliTabelaHead
:colunas="colunasEfetivas"
:temAcoes="temAcoes"
:temColunasInvisiveis="temColunasInvisiveis"
:colunaOrdenacao="colunaOrdenacao"
:direcaoOrdenacao="direcaoOrdenacao"
:mostrarSelecao="mostrarSelecao"
:todosSelecionados="todosSelecionados"
:algunsSelecionados="algunsSelecionados"
@alternar-ordenacao="alternarOrdenacao"
@alternar-selecao-todos="alternarSelecaoTodos"
/>
<div class="eli-tabela__table-wrapper">
<table class="eli-tabela__table">
<EliTabelaHead
:colunas="colunasEfetivas"
:temAcoes="temAcoes"
:temColunasInvisiveis="temColunasInvisiveis"
:colunaOrdenacao="colunaOrdenacao"
:direcaoOrdenacao="direcaoOrdenacao"
:mostrarSelecao="mostrarSelecao"
:todosSelecionados="todosSelecionados"
:algunsSelecionados="algunsSelecionados"
@alternar-ordenacao="alternarOrdenacao"
@alternar-selecao-todos="alternarSelecaoTodos"
/>
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
<tr>
<td :colspan="totalColunas" style="padding: 0;">
<EliTabelaEstados
:carregando="carregando"
:erro="erro"
:mensagemVazio="tabela.mensagemVazio"
style="border: none; border-radius: 0;"
/>
</td>
</tr>
</tbody>
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
<tr>
<td :colspan="totalColunas" class="eli-tabela__td--estados">
<EliTabelaEstados
:carregando="carregando"
:erro="erro"
:mensagemVazio="tabela.mensagemVazio"
class="eli-tabela__estados-inline"
/>
</td>
</tr>
</tbody>
<EliTabelaBody
v-else
:colunas="colunasEfetivas"
:colunasInvisiveis="colunasInvisiveisEfetivas"
:temColunasInvisiveis="temColunasInvisiveis"
:linhasExpandidas="linhasExpandidas"
:linhas="linhasPaginadas"
:temAcoes="temAcoes"
:menuAberto="menuAberto"
:possuiAcoes="possuiAcoes"
:toggleMenu="toggleMenu"
:alternarLinhaExpandida="alternarLinhaExpandida"
:mostrarSelecao="mostrarSelecao"
:isSelecionado="isSelecionado"
:alternarSelecao="alternarSelecao"
/>
</table>
<EliTabelaBody
v-else
:colunas="colunasEfetivas"
:colunasInvisiveis="colunasInvisiveisEfetivas"
:temColunasInvisiveis="temColunasInvisiveis"
:linhasExpandidas="linhasExpandidas"
:linhas="linhasPaginadas"
:temAcoes="temAcoes"
:menuAberto="menuAberto"
:possuiAcoes="possuiAcoes"
:toggleMenu="toggleMenu"
:alternarLinhaExpandida="alternarLinhaExpandida"
:mostrarSelecao="mostrarSelecao"
:isSelecionado="isSelecionado"
:alternarSelecao="alternarSelecao"
/>
</table>
</div>
<EliTabelaMenuAcoes
ref="menuPopup"
@ -108,28 +111,16 @@
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
import { codigosResposta } from "p-respostas"
/** Dependências do Vue (Composition API) */
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
type PropType,
ref,
watch,
} from "vue"
// operadores usados no tipo de configuração; o operador aplicado vem travado no filtroAvancado.
import {
carregarConfigColunas,
type EliTabelaColunasConfig,
salvarConfigColunas,
} from "./colunasStorage"
import { defineComponent, type PropType } from "vue"
import "./EliTabela.css"
import EliTabelaBody from "./EliTabelaBody.vue"
/** Componentes auxiliares */
import EliTabelaCabecalho from "./EliTabelaCabecalho.vue"
import EliTabelaDebug from "./EliTabelaDebug.vue"
import EliTabelaEstados from "./EliTabelaEstados.vue"
@ -138,18 +129,12 @@ import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue"
import EliTabelaModalColunas from "./EliTabelaModalColunas.vue"
import EliTabelaModalFiltroAvancado from "./EliTabelaModalFiltroAvancado.vue"
import EliTabelaPaginacao from "./EliTabelaPaginacao.vue"
import {
carregarFiltroAvancado,
limparFiltroAvancado,
salvarFiltroAvancado,
} from "./filtroAvancadoStorage"
/** Tipos da configuração/contrato da tabela */
import type {
parametrosConsulta,
tipoEliColuna,
tipoEliTabelaConsulta,
tipoFiltro,
} from "./types-eli-tabela"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
import { useEliTabela } from "./useEliTabela"
import type { FiltroBase } from "./useFiltroAvancado"
// biome-ignore lint/correctness/noUnusedVariables: tipo usado no template SFC no cast :filtrosBase
type FiltroBaseModal = FiltroBase<string>
export default defineComponent({
name: "EliTabela",
@ -170,821 +155,22 @@ export default defineComponent({
tabela: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic table
type: Object as PropType<tipoEliTabelaConsulta<any>>,
required: true,
required: true as const,
},
/** Array de itens selecionados (v-model:selecionados) */
selecionados: {
type: Array as PropType<unknown[]>,
default: () => [],
},
/** Ocultar painel de debug lateral (EliTabelaDebug) */
esconderDebug: {
type: Boolean,
default: false,
},
},
emits: ["update:selecionados"],
setup(props, { emit }) {
/** Flag para habilitar elementos de debug */
const isDev = import.meta.env.DEV
/** Estados de carregamento/erro e dados retornados */
const carregando = ref(false)
const erro = ref<string | null>(null)
const linhas = ref<unknown[]>([])
const quantidade = ref<number>(0)
/** Controle de visibilidade das ações por linha */
const acoesVisiveis = ref<boolean[][]>([])
/** Estado do menu de ações (aberto, elemento e posição) */
const menuAberto = ref<number | null>(null)
// O componente EliTabelaMenuAcoes expõe `menuEl` (ref do elemento <ul>)
const menuPopup = ref<{ menuEl: { value: HTMLElement | null } } | null>(
null,
)
const menuPopupPos = ref({ top: 0, left: 0 })
/** Filtros e ordenação */
const valorBusca = ref<string>("")
const paginaAtual = ref(1)
const colunaOrdenacao = ref<string | null>(null)
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
/** Filtro avançado (config + estado modal) */
const modalFiltroAberto = ref(false)
type LinhaFiltroUI = {
chave: string
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
valor: any
}
const filtrosUi = ref<Array<LinhaFiltroUI>>(
// biome-ignore lint/suspicious/noExplicitAny: dynamic load
carregarFiltroAvancado<any>(props.tabela.nome) as any,
)
function abrirModalFiltro() {
modalFiltroAberto.value = true
}
function fecharModalFiltro() {
modalFiltroAberto.value = false
}
function limparFiltrosAvancados() {
filtrosUi.value = []
limparFiltroAvancado(props.tabela.nome)
modalFiltroAberto.value = false
// Se o usuário estiver usando filtro avançado, a busca deixa de ter efeito.
// Mantemos a regra combinatória (busca tem prioridade), então limpamos a busca.
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
function salvarFiltrosAvancados(novo: any[]) {
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
filtrosUi.value = (novo ?? []) as any
// biome-ignore lint/suspicious/noExplicitAny: dynamic storage
salvarFiltroAvancado(props.tabela.nome, (novo ?? []) as any)
modalFiltroAberto.value = false
// Ao aplicar filtros, limpamos a busca para garantir que os filtros sejam efetivos.
// (busca tem prioridade sobre filtros)
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
// biome-ignore lint/suspicious/noExplicitAny: generic
const filtrosAvancadosAtivos = computed<tipoFiltro<any>[]>(() => {
const base = props.tabela.filtroAvancado ?? []
return (
(filtrosUi.value ?? [])
.filter((f) => f && f.chave !== undefined)
.map((f) => {
const b = base.find((x) => String(x.chave) === String(f.chave))
if (!b) return null
// O filtro já vem pronto da função definida na configuração
return b.filtro(f.valor)
})
// biome-ignore lint/suspicious/noExplicitAny: generic
.filter(Boolean) as tipoFiltro<any>[]
)
})
/** Alias reativo da prop tabela */
const tabela = computed(() => props.tabela)
/** Seleção Múltipla */
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
const chaveSelecao = computed(
() => props.tabela.chave_selecao as string | undefined,
)
function obterChaveLinha(linha: unknown): unknown {
if (chaveSelecao.value && linha && typeof linha === "object") {
return (linha as Record<string, unknown>)[chaveSelecao.value]
}
return linha
}
function isSelecionado(linha: unknown): boolean {
const chave = obterChaveLinha(linha)
if (chaveSelecao.value) {
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
}
return props.selecionados.includes(linha)
}
function alternarSelecao(linha: unknown) {
const atual = [...props.selecionados]
const chave = obterChaveLinha(linha)
let index = -1
if (chaveSelecao.value) {
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
} else {
index = atual.indexOf(linha)
}
if (index === -1) {
atual.push(linha)
} else {
atual.splice(index, 1)
}
emit("update:selecionados", atual)
}
const todosSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
})
const algunsSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return (
linhasPaginadas.value.some((linha) => isSelecionado(linha)) &&
!todosSelecionados.value
)
})
function alternarSelecaoTodos(forcarMarcar?: boolean) {
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
let atual = [...props.selecionados]
if (marcarTodos) {
// Adiciona os que faltam na página atual
for (const linha of linhasPaginadas.value) {
if (!isSelecionado(linha)) {
atual.push(linha)
}
}
} else {
// Remove os da página atual
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
if (chaveSelecao.value) {
atual = atual.filter(
(s) => !chavesPagina.includes(obterChaveLinha(s)),
)
} else {
atual = atual.filter((s) => !chavesPagina.includes(s))
}
}
emit("update:selecionados", atual)
}
/** Exibição da busca e ações do cabeçalho */
const exibirBusca = computed(() =>
Boolean(props.tabela.mostrarCaixaDeBusca),
)
const acoesSuperiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
)
const acoesInferiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
)
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
const parametrosConsultaAtuais = computed(() => {
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
const params: parametrosConsulta<any> = {
offSet: offset,
limit: limite,
}
const busca = (valorBusca.value ?? "").trim()
if (busca) {
params.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) params.filtros = filtros
}
if (colunaOrdenacao.value) {
params.coluna_ordem = colunaOrdenacao.value
params.direcao_ordem = direcaoOrdenacao.value
}
return {
...params,
atualizarConsulta: async () => {
await carregar()
},
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
editarLista: async (novaLista: any[]) => {
linhas.value = novaLista
return novaLista
},
}
})
/** Colunas: visibilidade/ordem com persistência */
const modalColunasAberto = ref(false)
const configColunas = ref<EliTabelaColunasConfig>(
carregarConfigColunas(props.tabela.nome),
)
/** Linhas expandidas (para exibir colunas invisíveis) */
const linhasExpandidas = ref<Record<number, boolean>>({})
const rotulosColunas = computed(() =>
props.tabela.colunas.map((c) => c.rotulo),
)
const colunasInvisiveisEfetivas = computed(() => {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const colunas = props.tabela.colunas as Array<tipoEliColuna<any>>
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
// ordenação: usa a lista (salva ou derivada do default) e adiciona novas ao final
const ordemSalva = invisiveisBaseRotulos
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, tipoEliColuna<any>>()
for (const c of base) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const ordenadas: Array<tipoEliColuna<any>> = []
for (const r of ordemSalva) {
const c = mapa.get(r)
if (c) ordenadas.push(c)
}
for (const c of base) {
if (!ordenadas.includes(c)) ordenadas.push(c)
}
return ordenadas
})
const temColunasInvisiveis = computed(
() => colunasInvisiveisEfetivas.value.length > 0,
)
const colunasEfetivas = computed(() => {
const colunas = props.tabela.colunas
const todosRotulos = rotulosColunas.value
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
(props.tabela.colunas as Array<tipoEliColuna<any>>)
.filter((c) => c.visivel === false)
.map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
// base visiveis: remove invisiveis
const visiveisBaseRotulos = todosRotulos.filter(
(r) => !invisiveisSet.has(r),
)
const visiveisSet = new Set(visiveisBaseRotulos)
// aplica ordem salva; novas (sem definicao) entram no fim, respeitando ordem original
const ordemSalva = configTemDados
? (configColunas.value.visiveis ?? [])
: []
const ordemFinal: string[] = []
for (const r of ordemSalva) {
if (visiveisSet.has(r)) ordemFinal.push(r)
}
for (const r of visiveisBaseRotulos) {
if (!ordemFinal.includes(r)) ordemFinal.push(r)
}
// mapeia rótulo -> coluna, preservando duplicatas (se existirem) pelo primeiro match.
// OBS: pressupoe rotulo unico; se repetir, comportamento fica indefinido.
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, any>()
for (const c of colunas) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
})
const totalColunas = computed(() => {
return (
colunasEfetivas.value.length +
(temAcoes.value ? 1 : 0) +
(temColunasInvisiveis.value ? 1 : 0)
)
})
function abrirModalColunas() {
modalColunasAberto.value = true
}
function fecharModalColunas() {
modalColunasAberto.value = false
}
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
configColunas.value = cfg
salvarConfigColunas(props.tabela.nome, cfg)
modalColunasAberto.value = false
// ao mudar colunas, fecha detalhes expandidos
linhasExpandidas.value = {}
}
function alternarLinhaExpandida(indice: number) {
const atual = Boolean(linhasExpandidas.value[indice])
linhasExpandidas.value = {
...linhasExpandidas.value,
[indice]: !atual,
}
}
/** Registros por consulta (normaliza para inteiro positivo) */
const registrosPorConsulta = computed(() => {
const valor = props.tabela.registros_por_consulta
if (typeof valor === "number" && valor > 0) {
return Math.floor(valor)
}
return 10
})
/** Total de páginas calculado com base no total retornado pela API */
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value
if (!limite || limite <= 0) return 1
const total = quantidade.value ?? 0
if (!total) return 1
return Math.max(1, Math.ceil(total / limite))
})
/** As linhas já vêm paginadas do backend */
const linhasPaginadas = computed(() => linhas.value ?? [])
/** Quantidade exibida é a quantidade total retornada pela consulta */
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
/** Indica se existem ações por linha */
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
const exibirFiltroAvancado = computed(
() => (props.tabela.filtroAvancado ?? []).length > 0,
)
/** Sequencial para evitar race conditions entre consultas */
let carregamentoSequencial = 0
/** Calcula a posição do menu de ações na viewport */
function atualizarPosicaoMenu(anchor: HTMLElement) {
const rect = anchor.getBoundingClientRect()
const gap = 8
// Alinha no canto inferior direito do botão.
// Se estourar a tela para baixo, abre para cima.
const alturaMenu = menuPopup.value?.menuEl?.value?.offsetHeight ?? 0
const larguraMenu = menuPopup.value?.menuEl?.value?.offsetWidth ?? 180
let top = rect.bottom + gap
const left = rect.right - larguraMenu
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
top = rect.top - gap - alturaMenu
}
menuPopupPos.value = {
top: Math.max(gap, Math.round(top)),
left: Math.max(gap, Math.round(left)),
}
}
/** Fecha o menu quando ocorre clique fora */
function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) return
const alvo = evento.target as Node
if (menuPopup.value?.menuEl?.value?.contains(alvo)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] click fora => fechar menu", {
menuAberto: menuAberto.value,
})
}
menuAberto.value = null
}
/** Alterna ordenação e recarrega os dados */
function alternarOrdenacao(chave?: string) {
if (!chave) return
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value =
direcaoOrdenacao.value === "asc" ? "desc" : "asc"
void carregar()
return
}
colunaOrdenacao.value = chave
direcaoOrdenacao.value = "asc"
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
/** Atualiza a busca e reinicia paginação, se necessário */
function atualizarBusca(texto: string) {
if (valorBusca.value === texto) return
valorBusca.value = texto
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
/** Navega para a página solicitada com limites */
function irParaPagina(pagina: number) {
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
if (alvo !== paginaAtual.value) {
paginaAtual.value = alvo
}
}
/**
* Lista ações visíveis por linha, respeitando regras sync/async de `exibir`.
*/
function acoesDisponiveisPorLinha(i: number) {
const acoesLinha = props.tabela.acoesLinha ?? []
const visibilidade = acoesVisiveis.value[i] ?? []
return acoesLinha
.map((acao, indice) => {
const fallbackVisivel =
acao.exibir === undefined
? true
: typeof acao.exibir === "boolean"
? acao.exibir
: false
return {
acao,
indice,
visivel: visibilidade[indice] ?? fallbackVisivel,
}
})
.filter((item) => item.visivel)
}
/** Informa se a linha possui ações disponíveis */
function possuiAcoes(i: number) {
return acoesDisponiveisPorLinha(i).length > 0
}
/** Abre/fecha o menu de ações da linha e posiciona o popup */
function toggleMenu(i: number, evento?: MouseEvent) {
if (!possuiAcoes(i)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu (antes)", {
i,
atual: menuAberto.value,
})
}
if (menuAberto.value === i) {
menuAberto.value = null
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => fechou", { i })
}
return
}
menuAberto.value = i
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => abriu", { i })
}
// posiciona assim que abrir
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
if (anchor) {
// primeiro posicionamento (antes do menu medir)
atualizarPosicaoMenu(anchor)
// reposiciona no próximo frame para pegar altura real
requestAnimationFrame(() => atualizarPosicaoMenu(anchor))
}
}
/**
* Executa consulta, aplica filtros e resolve visibilidade de ações.
* Usa o contador sequencial para ignorar respostas antigas.
*/
async function carregar() {
const idCarregamento = ++carregamentoSequencial
carregando.value = true
erro.value = null
try {
acoesVisiveis.value = []
menuAberto.value = null
linhasExpandidas.value = {}
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
const parametrosConsulta: {
// biome-ignore lint/suspicious/noExplicitAny: generic
filtros?: tipoFiltro<any>[]
coluna_ordem?: never
direcao_ordem?: "asc" | "desc"
offSet: number
limit: number
texto_busca?: string
} = {
offSet: offset,
limit: limite,
}
// Regra combinatória definida: busca tem prioridade.
const busca = (valorBusca.value ?? "").trim()
if (busca) {
parametrosConsulta.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) parametrosConsulta.filtros = filtros
}
if (colunaOrdenacao.value) {
parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never
parametrosConsulta.direcao_ordem = direcaoOrdenacao.value
}
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] consulta(parametros)", parametrosConsulta)
}
const tabelaConfig = props.tabela
const res = await tabelaConfig.consulta(parametrosConsulta)
if (idCarregamento !== carregamentoSequencial) return
if (res.cod !== codigosResposta.sucesso) {
linhas.value = []
quantidade.value = 0
erro.value = res.mensagem
return
}
const valores = res.valor?.valores ?? []
// biome-ignore lint/suspicious/noExplicitAny: dynamic response
const total = (res.valor as any)?.quantidade ?? valores.length
linhas.value = valores
quantidade.value = Number(total) || 0
const totalPaginasRecalculado = Math.max(
1,
Math.ceil((quantidade.value || 0) / limite),
)
if (paginaAtual.value > totalPaginasRecalculado)
paginaAtual.value = totalPaginasRecalculado
const acoesLinhaConfiguradas = tabelaConfig.acoesLinha ?? []
if (!acoesLinhaConfiguradas.length) {
acoesVisiveis.value = []
return
}
const preResultado = valores.map(() =>
acoesLinhaConfiguradas.map((acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
return false
}),
)
acoesVisiveis.value = preResultado
const visibilidade = await Promise.all(
valores.map(async (linha) =>
Promise.all(
acoesLinhaConfiguradas.map(async (acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
try {
const resultado = acao.exibir(linha as never)
return Boolean(await Promise.resolve(resultado))
} catch {
return false
}
}),
),
),
)
if (idCarregamento === carregamentoSequencial) {
acoesVisiveis.value = visibilidade
}
} catch (e) {
console.error("[EliTabela] Erro capturado em carregar():", e)
if (idCarregamento !== carregamentoSequencial) return
linhas.value = []
quantidade.value = 0
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
} finally {
if (idCarregamento === carregamentoSequencial) {
carregando.value = false
}
}
}
/** Ciclo de vida: registra listener global e carrega dados iniciais */
onMounted(() => {
document.addEventListener("click", handleClickFora)
void carregar()
})
/** Ciclo de vida: remove listener global */
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickFora)
})
/** Watch: ao desabilitar busca, limpa termo e recarrega */
watch(
() => props.tabela.mostrarCaixaDeBusca,
(mostrar) => {
if (!mostrar && valorBusca.value) {
valorBusca.value = ""
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
},
)
/** Watch: mudança de página dispara nova consulta */
watch(paginaAtual, (nova, antiga) => {
if (nova !== antiga) void carregar()
})
/** Watch: troca de configuração reseta estados e recarrega */
watch(
() => props.tabela,
() => {
menuAberto.value = null
colunaOrdenacao.value = null
direcaoOrdenacao.value = "asc"
valorBusca.value = ""
modalColunasAberto.value = false
modalFiltroAberto.value = false
configColunas.value = carregarConfigColunas(props.tabela.nome)
// biome-ignore lint/suspicious/noExplicitAny: dynamic reload
filtrosUi.value = carregarFiltroAvancado<any>(props.tabela.nome) as any
linhasExpandidas.value = {}
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
},
)
/** Watch: alteração do limite de registros reinicia paginação */
watch(
() => props.tabela.registros_por_consulta,
() => {
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
},
)
/** Watch: mudança nas linhas fecha o menu aberto */
watch(linhas, () => {
menuAberto.value = null
linhasExpandidas.value = {}
})
/** Exposição para o template (state, computed, helpers e actions) */
return {
// state
isDev,
tabela,
carregando,
erro,
linhas,
linhasPaginadas,
filtrosAvancadosAtivos,
quantidadeFiltrada,
quantidade,
menuAberto,
valorBusca,
paginaAtual,
colunaOrdenacao,
direcaoOrdenacao,
totalPaginas,
registrosPorConsulta,
// computed
exibirBusca,
exibirFiltroAvancado,
acoesSuperiores,
temAcoesSuperiores,
acoesInferiores,
temAcoesInferiores,
parametrosConsultaAtuais,
temAcoes,
totalColunas,
colunasEfetivas,
rotulosColunas,
modalColunasAberto,
configColunas,
temColunasInvisiveis,
colunasInvisiveisEfetivas,
linhasExpandidas,
// selecao
mostrarSelecao,
isSelecionado,
alternarSelecao,
todosSelecionados,
algunsSelecionados,
alternarSelecaoTodos,
abrirModalColunas,
abrirModalFiltro,
fecharModalColunas,
salvarModalColunas,
modalFiltroAberto,
filtrosUi,
salvarFiltrosAvancados,
limparFiltrosAvancados,
fecharModalFiltro,
alternarLinhaExpandida,
// actions
alternarOrdenacao,
atualizarBusca,
irParaPagina,
acoesDisponiveisPorLinha,
possuiAcoes,
toggleMenu,
// popup
menuPopup,
menuPopupPos,
}
return useEliTabela(props, emit)
},
})
</script>
<style src="./EliTabela.css"></style>

View file

@ -102,12 +102,12 @@ export default defineComponent({
props: {
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
colunasInvisiveis: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
temColunasInvisiveis: {
@ -119,7 +119,7 @@ export default defineComponent({
required: true,
},
linhas: {
type: Array as PropType<Array<unknown>>,
type: Array as PropType<unknown[]>,
required: true,
},
temAcoes: {

View file

@ -8,6 +8,7 @@
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas"
@click="emitColunas"
>
<Columns3 :size="15" :stroke-width="2" />
Colunas
</button>
@ -17,6 +18,7 @@
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--filtro"
@click="emitFiltroAvancado"
>
<Filter :size="15" :stroke-width="2" />
Filtro
</button>
<EliTabelaCaixaDeBusca :modelo="valorBusca" @buscar="emitBuscar" />
@ -46,12 +48,13 @@
</template>
<script lang="ts">
import { Columns3, Filter } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import EliTabelaCaixaDeBusca from "./EliTabelaCaixaDeBusca.vue"
export default defineComponent({
name: "EliTabelaCabecalho",
components: { EliTabelaCaixaDeBusca },
components: { Columns3, EliTabelaCaixaDeBusca, Filter },
props: {
exibirBusca: {
type: Boolean,

View file

@ -82,7 +82,7 @@ export default defineComponent({
border-radius: var(--eli-tabela-cabecalho-controle-radius, 8px);
border: 1px solid rgba(15, 23, 42, 0.15);
overflow: hidden;
background: #fff;
background: var(--cor-eli-superficie);
/* Herda do container do cabeçalho (.eli-tabela__cabecalho) quando presente. */
height: var(--eli-tabela-cabecalho-controle-altura, 34px);
@ -104,7 +104,7 @@ export default defineComponent({
}
.eli-tabela__busca-input::placeholder {
color: rgba(107, 114, 128, 0.85);
color: var(--cor-eli-neutro);
}
.eli-tabela__busca-botao {
@ -112,8 +112,8 @@ export default defineComponent({
align-items: center;
justify-content: center;
border: none;
background: rgba(37, 99, 235, 0.12);
color: rgba(37, 99, 235, 0.95);
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria-escura);
height: 100%;
padding: 0 12px;
cursor: pointer;
@ -126,12 +126,12 @@ export default defineComponent({
.eli-tabela__busca-botao:hover,
.eli-tabela__busca-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
color: rgba(37, 99, 235, 1);
background: var(--cor-eli-primaria-clara);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__busca-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
</style>

View file

@ -2,17 +2,21 @@
<!-- Debug visual (somente DEV): expõe estados internos úteis para testes e diagnóstico -->
<div
v-if="isDev"
style="position: fixed; left: 8px; bottom: 8px; z-index: 999999; background: rgba(185,28,28,0.9); color: #fff; padding: 6px 10px; border-radius: 8px; font-size: 12px; max-width: 500px;"
style="position: fixed; right: 0; top: 0; bottom: 0; width: 360px; z-index: 999999; background: rgba(20, 20, 20, 0.95); color: #32CD32; padding: 20px 16px; font-family: 'Courier New', Courier, monospace; font-size: 12px; overflow-y: auto; border-left: 1px solid #333; display: flex; flex-direction: column; gap: 6px;"
>
<div><b>EliTabela debug</b></div>
<div>menuAberto: {{ menuAberto }}</div>
<div>menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}</div>
<slot />
<div style="font-weight: bold; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #333; padding-bottom: 8px;">
EliTabela // Log Terminal
</div>
<div style="color: #bbb;">menuAberto: <span style="color: #fff;">{{ menuAberto }}</span></div>
<div style="color: #bbb;">menuPos: top=<span style="color: #fff;">{{ menuPopupPos.top }}</span>, left=<span style="color: #fff;">{{ menuPopupPos.left }}</span></div>
<div style="margin-top: 8px; white-space: pre-wrap; word-break: break-all;">
<slot />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue"
import { defineComponent, onMounted, onUnmounted, type PropType, watch } from "vue"
export default defineComponent({
name: "EliTabelaDebug",
@ -30,5 +34,39 @@ export default defineComponent({
required: true,
},
},
setup(props) {
const applyStyle = () => {
let styleTag = document.getElementById("eli-tabela-debug-style")
if (props.isDev) {
if (!styleTag) {
styleTag = document.createElement("style")
styleTag.id = "eli-tabela-debug-style"
styleTag.textContent = `
html, body {
overflow-x: auto !important;
}
#app, .v-application__wrap, .v-application {
padding-right: 360px !important;
box-sizing: border-box !important;
}
.v-container, .stack {
overflow-x: auto !important;
max-width: 100% !important;
}
`
document.head.appendChild(styleTag)
}
} else {
if (styleTag) styleTag.remove()
}
}
watch(() => props.isDev, applyStyle)
onMounted(applyStyle)
onUnmounted(() => {
const styleTag = document.getElementById("eli-tabela-debug-style")
if (styleTag) styleTag.remove()
})
},
})
</script>

View file

@ -24,7 +24,7 @@ export default defineComponent({
},
colunasInvisiveis: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
},

View file

@ -1,26 +1,33 @@
<template>
<!-- Estado de carregamento: consulta em andamento -->
<div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true">
Carregando...
<LoaderCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--spin" :size="22" :stroke-width="2" />
<span>Carregando…</span>
</div>
<!-- Estado de erro: mostra mensagem retornada pela consulta -->
<div v-else-if="erro" class="eli-tabela eli-tabela--erro" role="alert">
<div class="eli-tabela__erro-titulo">Erro</div>
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
<AlertCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--erro" :size="18" :stroke-width="2" />
<div>
<div class="eli-tabela__erro-titulo">Erro</div>
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
</div>
</div>
<!-- Estado vazio: consulta sem registros -->
<div v-else class="eli-tabela eli-tabela--vazio">
{{ mensagemVazio ?? "Nenhum registro encontrado." }}
<Inbox class="eli-tabela__estado-icone" :size="22" :stroke-width="1.5" />
<span>{{ mensagemVazio ?? "Nenhum registro encontrado." }}</span>
</div>
</template>
<script lang="ts">
import { AlertCircle, Inbox, LoaderCircle } from "@lucide/vue"
import { defineComponent, type PropType } from "vue"
export default defineComponent({
name: "EliTabelaEstados",
components: { AlertCircle, Inbox, LoaderCircle },
props: {
carregando: {
type: Boolean,

View file

@ -70,7 +70,7 @@ export default defineComponent({
props: {
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
temAcoes: {
@ -109,9 +109,8 @@ export default defineComponent({
"alternar-selecao-todos": () => true,
},
setup(_props, { emit }) {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
function isOrdenavel(coluna: any) {
return coluna?.coluna_ordem !== undefined && coluna?.coluna_ordem !== null
function isOrdenavel(coluna: tipoEliColuna<unknown>): boolean {
return coluna.coluna_ordem !== undefined && coluna.coluna_ordem !== null
}
function emitAlternarOrdenacao(chave: string) {

View file

@ -67,7 +67,7 @@ export default defineComponent({
},
acoes: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
type: Array as PropType<Array<ItemAcao<any>>>,
type: Array as PropType<ItemAcao<any>[]>,
required: true,
},
linha: {

View file

@ -0,0 +1,342 @@
.eli-tabela-modal-colunas__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-colunas__modal {
width: min(820px, 100%);
background: var(--cor-eli-superficie);
border-radius: 16px;
border: 1px solid var(--cor-eli-borda);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.eli-tabela-modal-colunas__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-primaria-header, #ADCC44);
}
.eli-tabela-modal-colunas__header-info {
display: flex;
align-items: center;
gap: 8px;
}
.eli-tabela-modal-colunas__header-icone {
color: var(--cor-eli-primaria-escura);
flex-shrink: 0;
}
.eli-tabela-modal-colunas__titulo {
font-size: 0.95rem;
font-weight: 600;
color: var(--cor-eli-primaria-texto);
margin: 0;
}
.eli-tabela-modal-colunas__fechar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
color: var(--cor-eli-primaria-escura);
transition: background 0.2s ease;
}
.eli-tabela-modal-colunas__fechar:hover,
.eli-tabela-modal-colunas__fechar:focus-visible {
background: rgba(15, 23, 42, 0.1);
}
.eli-tabela-modal-colunas__fechar:focus-visible {
outline: 2px solid var(--cor-eli-primaria);
outline-offset: 2px;
}
/* ── Instrução ── */
.eli-tabela-modal-colunas__instrucao {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--cor-eli-fundo);
border-bottom: 1px solid var(--cor-eli-borda);
font-size: 0.78rem;
color: var(--cor-eli-neutro);
line-height: 1.4;
}
.eli-tabela-modal-colunas__instrucao-icone {
flex-shrink: 0;
color: var(--cor-eli-borda-forte);
}
/* ── Conteúdo ── */
.eli-tabela-modal-colunas__conteudo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
}
.eli-tabela-modal-colunas__coluna {
display: flex;
flex-direction: column;
padding: 12px;
}
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
border-left: 1px solid var(--cor-eli-borda);
}
/* ── Cabeçalho das colunas ── */
.eli-tabela-modal-colunas__coluna-cabecalho {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 8px;
margin-bottom: 8px;
}
.eli-tabela-modal-colunas__coluna-cabecalho--visiveis {
color: var(--cor-eli-primaria-escura);
background: var(--cor-eli-primaria-bg);
}
.eli-tabela-modal-colunas__coluna-cabecalho--invisiveis {
color: var(--cor-eli-neutro-escuro);
background: var(--cor-eli-fundo);
}
.eli-tabela-modal-colunas__coluna-contador {
margin-left: auto;
font-size: 0.72rem;
font-weight: 700;
padding: 1px 7px;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.08);
color: inherit;
}
/* ── Lista ── */
.eli-tabela-modal-colunas__lista {
flex: 1;
min-height: 220px;
border: 1.5px dashed var(--cor-eli-borda);
border-radius: 10px;
padding: 8px;
position: relative;
overflow: hidden;
transition: border-color 0.2s ease, background 0.2s ease;
}
.eli-tabela-modal-colunas__lista--visiveis {
background: rgba(104, 140, 0, 0.02);
}
.eli-tabela-modal-colunas__lista--invisiveis {
background: var(--cor-eli-fundo);
}
/* ── Item ── */
.eli-tabela-modal-colunas__item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-superficie);
cursor: grab;
user-select: none;
transition: box-shadow 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
margin-top: 6px;
}
.eli-tabela-modal-colunas__item:hover {
border-color: var(--cor-eli-borda-forte);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__item:active {
cursor: grabbing;
}
.eli-tabela-modal-colunas__item--invisivel {
opacity: 0.72;
}
.eli-tabela-modal-colunas__item--arrastando {
opacity: 0.15;
border-style: dashed;
border-color: var(--cor-eli-primaria-media);
}
/* ── Partes do item ── */
.eli-tabela-modal-colunas__item-handle {
color: var(--cor-eli-borda-forte);
flex-shrink: 0;
transition: color 0.15s ease;
}
.eli-tabela-modal-colunas__item:hover .eli-tabela-modal-colunas__item-handle {
color: var(--cor-eli-neutro);
}
.eli-tabela-modal-colunas__item-texto {
flex: 1;
min-width: 0;
font-size: 0.875rem;
color: var(--cor-eli-neutro-escuro);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eli-tabela-modal-colunas__item-badge-icone {
flex-shrink: 0;
}
.eli-tabela-modal-colunas__item-badge-icone--visivel {
color: var(--cor-eli-primaria-acao);
}
.eli-tabela-modal-colunas__item-badge-icone--invisivel {
color: var(--cor-eli-borda-forte);
}
/* ── Estado vazio ── */
.eli-tabela-modal-colunas__vazio {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 12px;
text-align: center;
font-size: 0.8rem;
color: var(--cor-eli-neutro);
line-height: 1.5;
}
.eli-tabela-modal-colunas__vazio-icone {
color: var(--cor-eli-borda-forte);
}
.eli-tabela-modal-colunas__vazio-icone--ok {
color: var(--cor-eli-primaria-acao);
}
/* ── Animações TransitionGroup ── */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.list-leave-active {
position: absolute;
width: calc(100% - 16px);
}
/* ── Footer ── */
.eli-tabela-modal-colunas__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-fundo);
}
.eli-tabela-modal-colunas__botao {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-superficie);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.eli-tabela-modal-colunas__botao--sec {
color: var(--cor-eli-neutro-escuro);
}
.eli-tabela-modal-colunas__botao--sec:hover,
.eli-tabela-modal-colunas__botao--sec:focus-visible {
background: var(--cor-eli-fundo);
border-color: var(--cor-eli-borda-forte);
}
.eli-tabela-modal-colunas__botao--sec:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
.eli-tabela-modal-colunas__botao--prim {
border: none;
background: var(--cor-eli-primaria);
color: #fff;
}
.eli-tabela-modal-colunas__botao--prim:hover,
.eli-tabela-modal-colunas__botao--prim:focus-visible {
background: var(--cor-eli-primaria-escura);
}
.eli-tabela-modal-colunas__botao--prim:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
/* ── Responsivo ── */
@media (max-width: 720px) {
.eli-tabela-modal-colunas__conteudo {
grid-template-columns: 1fr;
}
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
border-left: none;
border-top: 1px solid var(--cor-eli-borda);
}
}

View file

@ -5,78 +5,124 @@
class="eli-tabela-modal-colunas__modal"
role="dialog"
aria-modal="true"
aria-label="Configurar colunas"
aria-labelledby="eli-modal-colunas-titulo"
>
<!-- Header -->
<header class="eli-tabela-modal-colunas__header">
<h3 class="eli-tabela-modal-colunas__titulo">Colunas</h3>
<button type="button" class="eli-tabela-modal-colunas__fechar" aria-label="Fechar" @click="emitFechar">
×
<div class="eli-tabela-modal-colunas__header-info">
<Columns2 :size="18" :stroke-width="2" class="eli-tabela-modal-colunas__header-icone" />
<h3 id="eli-modal-colunas-titulo" class="eli-tabela-modal-colunas__titulo">Configurar colunas</h3>
</div>
<button
type="button"
class="eli-tabela-modal-colunas__fechar"
aria-label="Fechar modal de colunas"
@click="emitFechar"
>
<X :size="16" :stroke-width="2.5" />
</button>
</header>
<!-- Instrução contextual -->
<div class="eli-tabela-modal-colunas__instrucao">
<GripVertical :size="14" :stroke-width="2" class="eli-tabela-modal-colunas__instrucao-icone" />
Arraste para reordenar ou mover entre as listas
</div>
<!-- Conteúdo: duas colunas -->
<div class="eli-tabela-modal-colunas__conteudo">
<!-- Visíveis -->
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Visíveis</div>
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--visiveis">
<Eye :size="14" :stroke-width="2" />
<span>Visíveis</span>
<span class="eli-tabela-modal-colunas__coluna-contador">{{ visiveisLocal.length }}</span>
</div>
<TransitionGroup
name="list"
tag="div"
class="eli-tabela-modal-colunas__lista"
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--visiveis"
@dragover.prevent="onDragOverLista('visiveis')"
@drop="onDragEnd"
>
<div
v-for="rotulo in visiveisLocal"
:key="`vis-${rotulo}`"
class="eli-tabela-modal-colunas__item"
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--visivel"
:class="{
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
}"
draggable="true"
:title="`Arraste para mover '${rotulo}'`"
@dragstart="onDragStart($event, rotulo)"
@dragend="onDragEnd"
@dragover.stop.prevent="onDragOverItem(rotulo, 'visiveis')"
@drop.stop.prevent="onDragEnd"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
<Eye :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--visivel" aria-hidden="true" />
</div>
<!-- Estado vazio -->
<div v-if="visiveisLocal.length === 0" key="vazio-visiveis" class="eli-tabela-modal-colunas__vazio">
<EyeOff :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone" />
<span>Nenhuma coluna visível.<br>Arraste itens da lista ao lado.</span>
</div>
</TransitionGroup>
</div>
<!-- Invisíveis -->
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Invisíveis</div>
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--invisiveis">
<EyeOff :size="14" :stroke-width="2" />
<span>Ocultas</span>
<span class="eli-tabela-modal-colunas__coluna-contador">{{ invisiveisLocal.length }}</span>
</div>
<TransitionGroup
name="list"
tag="div"
class="eli-tabela-modal-colunas__lista"
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--invisiveis"
@dragover.prevent="onDragOverLista('invisiveis')"
@drop="onDragEnd"
>
<div
v-for="rotulo in invisiveisLocal"
:key="`inv-${rotulo}`"
class="eli-tabela-modal-colunas__item"
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--invisivel"
:class="{
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
}"
draggable="true"
:title="`Arraste para mover '${rotulo}'`"
@dragstart="onDragStart($event, rotulo)"
@dragend="onDragEnd"
@dragover.stop.prevent="onDragOverItem(rotulo, 'invisiveis')"
@drop.stop.prevent="onDragEnd"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
<EyeOff :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--invisivel" aria-hidden="true" />
</div>
<!-- Estado vazio -->
<div v-if="invisiveisLocal.length === 0" key="vazio-invisiveis" class="eli-tabela-modal-colunas__vazio">
<Eye :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone eli-tabela-modal-colunas__vazio-icone--ok" />
<span>Todas as colunas estão visíveis.</span>
</div>
</TransitionGroup>
</div>
</div>
<!-- Footer -->
<footer class="eli-tabela-modal-colunas__footer">
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--sec" @click="emitFechar">
<X :size="14" :stroke-width="2.5" />
Cancelar
</button>
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--prim" @click="emitSalvar">
<Check :size="14" :stroke-width="2.5" />
Salvar
</button>
</footer>
@ -86,30 +132,23 @@
</template>
<script lang="ts">
import { defineComponent, type PropType, ref, watch } from "vue"
import { Check, Columns2, Eye, EyeOff, GripVertical, X } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import "./EliTabelaModalColunas.css"
import type { EliTabelaColunasConfig } from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
type OrigemLista = "visiveis" | "invisiveis"
import { useColunasOrdenacao } from "./useColunasOrdenacao"
export default defineComponent({
name: "EliTabelaModalColunas",
components: { Columns2, Eye, EyeOff, GripVertical, X, Check },
props: {
aberto: {
type: Boolean,
required: true,
},
rotulosColunas: {
type: Array as PropType<string[]>,
required: true,
},
configInicial: {
type: Object as PropType<EliTabelaColunasConfig>,
required: true,
},
aberto: { type: Boolean, required: true },
rotulosColunas: { type: Array as PropType<string[]>, required: true },
configInicial: { type: Object as PropType<EliTabelaColunasConfig>, required: true },
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
},
@ -122,58 +161,12 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const visiveisLocal = ref<string[]>([])
const invisiveisLocal = ref<string[]>([])
const itemSendoArrastado = ref<string | null>(null)
function sincronizarEstado() {
const todos = props.rotulosColunas
const configTemDados =
(props.configInicial.visiveis?.length ?? 0) > 0 ||
(props.configInicial.invisiveis?.length ?? 0) > 0
const invisiveisPadraoSet = new Set(
props.colunas.filter((c) => c.visivel === false).map((c) => c.rotulo),
)
const invisiveisSet = configTemDados
? new Set(props.configInicial.invisiveis ?? [])
: invisiveisPadraoSet
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
// ordenação: aplica ordem salva (visiveis) e adiciona novas ao final
const ordemSalva = props.configInicial.visiveis ?? []
const setVis = new Set(baseVisiveis)
const ordenadas: string[] = []
for (const r of ordemSalva) {
if (setVis.has(r)) ordenadas.push(r)
}
for (const r of baseVisiveis) {
if (!ordenadas.includes(r)) ordenadas.push(r)
}
visiveisLocal.value = ordenadas
// invisíveis: somente as que existem na tabela
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
}
watch(
() =>
[
props.aberto,
props.rotulosColunas,
props.configInicial,
props.colunas,
] as const,
() => {
if (props.aberto) sincronizarEstado()
},
{ deep: true, immediate: true },
)
const ordenacao = useColunasOrdenacao({
aberto: computed(() => props.aberto),
rotulosColunas: computed(() => props.rotulosColunas),
configInicial: computed(() => props.configInicial),
colunas: computed(() => props.colunas),
})
function emitFechar() {
emit("fechar")
@ -181,242 +174,17 @@ export default defineComponent({
function emitSalvar() {
emit("salvar", {
visiveis: [...visiveisLocal.value],
invisiveis: [...invisiveisLocal.value],
visiveis: [...ordenacao.visiveisLocal.value],
invisiveis: [...ordenacao.invisiveisLocal.value],
})
}
function onDragStart(e: DragEvent, rotulo: string) {
itemSendoArrastado.value = rotulo
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", rotulo)
e.dataTransfer.effectAllowed = "move"
}
}
function onDragEnd() {
itemSendoArrastado.value = null
}
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
const origem =
origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
if (!origem) return
const origemArr =
origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const destinoArr =
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const dragIdx = origemArr.indexOf(draggedRotulo)
const alvoIdx = destinoArr.indexOf(alvoRotulo)
origemArr.splice(dragIdx, 1)
destinoArr.splice(alvoIdx, 0, draggedRotulo)
}
function onDragOverLista(destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo) return
const destinoArr =
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
destinoArr.push(draggedRotulo)
}
return {
visiveisLocal,
invisiveisLocal,
itemSendoArrastado,
...ordenacao,
emitFechar,
emitSalvar,
onDragStart,
onDragEnd,
onDragOverItem,
onDragOverLista,
}
},
})
</script>
<style>
.eli-tabela-modal-colunas__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-colunas__modal {
width: min(860px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
.eli-tabela-modal-colunas__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__titulo {
font-size: 1rem;
margin: 0;
}
.eli-tabela-modal-colunas__fechar {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 22px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-colunas__fechar:hover,
.eli-tabela-modal-colunas__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-colunas__conteudo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.eli-tabela-modal-colunas__coluna-titulo {
font-weight: 600;
margin-bottom: 8px;
}
.eli-tabela-modal-colunas__lista {
min-height: 260px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 12px;
padding: 10px;
background: rgba(15, 23, 42, 0.01);
position: relative;
overflow: hidden;
}
.eli-tabela-modal-colunas__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
cursor: grab;
user-select: none;
transition: box-shadow 0.15s, opacity 0.15s, transform 0.15s;
}
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
margin-top: 8px;
}
.eli-tabela-modal-colunas__item:active {
cursor: grabbing;
}
.eli-tabela-modal-colunas__item--arrastando {
opacity: 0.1;
background: #f8fafc;
border-style: dashed;
}
/* Animações de reposicionamento e listas do Vue TransitionGroup */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.list-leave-active {
/* position absolute para suavizar a animação de saída de outras listas */
position: absolute;
width: calc(100% - 20px);
}
.eli-tabela-modal-colunas__item-handle {
color: rgba(15, 23, 42, 0.55);
font-size: 14px;
}
.eli-tabela-modal-colunas__item-texto {
flex: 1;
min-width: 0;
}
.eli-tabela-modal-colunas__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__botao {
height: 34px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.eli-tabela-modal-colunas__botao--sec:hover,
.eli-tabela-modal-colunas__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-colunas__botao--prim {
border: none;
background: rgba(37, 99, 235, 0.95);
color: #fff;
}
.eli-tabela-modal-colunas__botao--prim:hover,
.eli-tabela-modal-colunas__botao--prim:focus-visible {
background: rgba(37, 99, 235, 1);
}
@media (max-width: 720px) {
.eli-tabela-modal-colunas__conteudo {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,443 @@
.eli-tabela-modal-filtro__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-filtro__modal {
width: min(980px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
/* ── Header ──────────────────────────────────── */
.eli-tabela-modal-filtro__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__header-titulo {
display: flex;
align-items: center;
gap: 10px;
}
.eli-tabela-modal-filtro__titulo {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: rgba(15, 23, 42, 0.9);
}
.eli-tabela-modal-filtro__header-icone {
color: var(--cor-eli-primaria);
flex-shrink: 0;
}
.eli-tabela-modal-filtro__contador {
font-size: 0.75rem;
font-weight: 500;
color: var(--cor-eli-primaria);
background: var(--cor-eli-primaria-bg);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.eli-tabela-modal-filtro__fechar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
color: rgba(15, 23, 42, 0.55);
transition: background 0.15s, color 0.15s;
}
.eli-tabela-modal-filtro__fechar:hover,
.eli-tabela-modal-filtro__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
color: rgba(15, 23, 42, 0.9);
}
/* ── Conteúdo ────────────────────────────────── */
.eli-tabela-modal-filtro__conteudo {
padding: 20px;
}
.eli-tabela-modal-filtro__vazio {
opacity: 0.65;
font-size: 0.85rem;
padding: 12px 0 4px;
}
/* ── Estado vazio orientado ──────────────────── */
.eli-tabela-modal-filtro__estado-vazio {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 16px;
color: rgba(15, 23, 42, 0.35);
border: 1px dashed rgba(15, 23, 42, 0.12);
border-radius: 10px;
background: rgba(15, 23, 42, 0.015);
}
.eli-tabela-modal-filtro__estado-vazio span {
font-size: 0.85rem;
font-weight: 500;
}
/* ── Lista de filtros (2 colunas) ────────────── */
.eli-tabela-modal-filtro__lista {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.eli-tabela-modal-filtro__linha {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
background: rgba(15, 23, 42, 0.015);
transition: border-color 0.15s;
}
.eli-tabela-modal-filtro__linha:hover {
border-color: var(--cor-eli-primaria-clara);
}
.eli-tabela-modal-filtro__linha-header {
display: flex;
align-items: center;
gap: 8px;
}
.eli-tabela-modal-filtro__rotulo-filtro {
font-size: 0.82rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.65);
flex: 1;
min-width: 0;
}
.eli-tabela-modal-filtro__id-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 24px;
border-radius: 6px;
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: default;
user-select: none;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__entrada {
min-width: 0;
}
.eli-tabela-modal-filtro__remover {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
color: rgba(15, 23, 42, 0.35);
transition: background 0.15s, color 0.15s;
margin-left: auto;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__remover:hover,
.eli-tabela-modal-filtro__remover:focus-visible {
background: rgba(239, 68, 68, 0.08);
color: rgba(220, 38, 38, 0.9);
border-color: rgba(239, 68, 68, 0.2);
}
/* ── Rodapé: select + fórmula lado a lado ────── */
.eli-tabela-modal-filtro__rodape-filtros {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(15, 23, 42, 0.07);
display: flex;
gap: 12px;
align-items: flex-end;
}
.eli-tabela-modal-filtro__acoes {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__select {
height: 36px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
background: #fff;
font-size: 0.875rem;
color: rgba(15, 23, 42, 0.85);
transition: border-color 0.15s;
}
.eli-tabela-modal-filtro__select:focus {
outline: none;
border-color: var(--cor-eli-primaria);
}
/* ── Fórmula de combinação ───────────────────── */
.eli-tabela-modal-filtro__formula-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-left: auto;
}
.eli-tabela-modal-filtro__formula-label {
font-size: 0.82rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.55);
white-space: nowrap;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__formula-ajuda {
position: relative;
display: inline-flex;
align-items: center;
color: rgba(15, 23, 42, 0.35);
cursor: help;
transition: color 0.15s;
}
.eli-tabela-modal-filtro__formula-ajuda:hover,
.eli-tabela-modal-filtro__formula-ajuda:focus-visible {
color: var(--cor-eli-primaria);
outline: none;
}
.eli-tabela-modal-filtro__formula-ajuda:hover .eli-tabela-modal-filtro__formula-tooltip,
.eli-tabela-modal-filtro__formula-ajuda:focus-visible .eli-tabela-modal-filtro__formula-tooltip {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.eli-tabela-modal-filtro__formula-tooltip {
position: absolute;
bottom: calc(100% + 10px);
right: 0;
width: 280px;
background: #0f172a;
color: #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
font-size: 0.8rem;
line-height: 1.55;
z-index: 100;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.eli-tabela-modal-filtro__formula-tooltip::after {
content: '';
position: absolute;
top: 100%;
right: 6px;
border: 6px solid transparent;
border-top-color: #0f172a;
}
.eli-tabela-modal-filtro__formula-tooltip strong {
font-size: 0.82rem;
color: var(--cor-eli-fundo);
display: block;
margin-bottom: 6px;
}
.eli-tabela-modal-filtro__formula-tooltip p {
margin: 6px 0 4px;
color: #94a3b8;
}
.eli-tabela-modal-filtro__formula-tooltip ul {
margin: 4px 0 8px 14px;
padding: 0;
}
.eli-tabela-modal-filtro__formula-tooltip li {
margin-bottom: 3px;
color: #cbd5e1;
}
.eli-tabela-modal-filtro__formula-tooltip code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
color: #7dd3fc;
font-size: 0.78rem;
background: rgba(255, 255, 255, 0.07);
border-radius: 4px;
padding: 1px 4px;
}
.eli-tabela-modal-filtro__formula-input {
width: 220px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.85rem;
background: var(--cor-eli-fundo);
transition: border-color 0.15s, background 0.15s;
box-sizing: border-box;
}
.eli-tabela-modal-filtro__formula-input:focus {
outline: none;
border-color: var(--cor-eli-primaria);
background: #fff;
}
.eli-tabela-modal-filtro__formula-tooltip-nota {
margin-top: 8px;
opacity: 0.75;
}
/* ── Footer ──────────────────────────────────── */
.eli-tabela-modal-filtro__footer {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__footer-spacer {
flex: 1;
}
/* ── Botões ──────────────────────────────────── */
.eli-tabela-modal-filtro__botao {
display: inline-flex;
align-items: center;
gap: 5px;
height: 36px;
padding: 0 16px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.eli-tabela-modal-filtro__botao:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eli-tabela-modal-filtro__botao--sec:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.05);
}
.eli-tabela-modal-filtro__botao--prim {
border: none;
background: var(--cor-eli-primaria);
color: #fff;
}
.eli-tabela-modal-filtro__botao--prim:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--prim:focus-visible {
background: var(--cor-eli-primaria-escura);
}
.eli-tabela-modal-filtro__botao--perigo {
color: rgba(220, 38, 38, 0.85);
border-color: rgba(220, 38, 38, 0.18);
background: #fff;
}
.eli-tabela-modal-filtro__botao--perigo:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--perigo:focus-visible {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(220, 38, 38, 0.3);
color: rgba(220, 38, 38, 1);
}
/* ── Responsivo ──────────────────────────────── */
@media (max-width: 600px) {
.eli-tabela-modal-filtro__lista {
grid-template-columns: 1fr;
}
.eli-tabela-modal-filtro__rodape-filtros {
flex-direction: column;
align-items: stretch;
}
.eli-tabela-modal-filtro__acoes {
flex-wrap: wrap;
}
}
/* ── Transição de abertura/fechamento ────────── */
.eli-tabela-modal-filtro-enter-active,
.eli-tabela-modal-filtro-leave-active {
transition: opacity 0.2s ease;
}
.eli-tabela-modal-filtro-enter-active .eli-tabela-modal-filtro__modal,
.eli-tabela-modal-filtro-leave-active .eli-tabela-modal-filtro__modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.eli-tabela-modal-filtro-enter-from,
.eli-tabela-modal-filtro-leave-to {
opacity: 0;
}
.eli-tabela-modal-filtro-enter-from .eli-tabela-modal-filtro__modal,
.eli-tabela-modal-filtro-leave-to .eli-tabela-modal-filtro__modal {
transform: scale(0.96);
opacity: 0;
}

View file

@ -1,403 +1,182 @@
<template>
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
<header class="eli-tabela-modal-filtro__header">
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
×
</button>
</header>
<Transition name="eli-tabela-modal-filtro">
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
<header class="eli-tabela-modal-filtro__header">
<div class="eli-tabela-modal-filtro__header-titulo">
<Filter class="eli-tabela-modal-filtro__header-icone" :size="18" :stroke-width="2" />
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
<span v-if="linhas.length" class="eli-tabela-modal-filtro__contador">
{{ linhas.length }} {{ linhas.length === 1 ? 'filtro' : 'filtros' }}
</span>
</div>
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
<X :size="18" :stroke-width="2" />
</button>
</header>
<div class="eli-tabela-modal-filtro__conteudo">
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
Nenhum filtro configurado na tabela.
</div>
<div class="eli-tabela-modal-filtro__conteudo">
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
Nenhum filtro configurado na tabela.
</div>
<div v-else class="eli-tabela-modal-filtro__lista">
<div v-for="(linha, idx) in linhas" :key="String(linha.chave)" class="eli-tabela-modal-filtro__linha">
<div class="eli-tabela-modal-filtro__entrada">
<component
:is="componenteEntrada(linha.entrada)"
:value="linha.valor"
@update:value="(v: any) => (linha.valor = v)"
@input="(v: any) => (linha.valor = v)"
:opcoes="opcoesEntrada(linha.entrada)"
density="compact"
/>
<div v-else>
<!-- Lista de filtros ativos -->
<div v-if="linhas.length" class="eli-tabela-modal-filtro__lista">
<div
v-for="(linha, idx) in linhas"
:key="String(linha.chave)"
class="eli-tabela-modal-filtro__linha"
>
<div class="eli-tabela-modal-filtro__linha-header">
<span
class="eli-tabela-modal-filtro__id-badge"
:title="`Identificador na fórmula: {c${idx + 1}}`"
>
c{{ idx + 1 }}
</span>
<span class="eli-tabela-modal-filtro__rotulo-filtro">{{ rotuloDoFiltroAtivo(linha.chave) }}</span>
<button
type="button"
class="eli-tabela-modal-filtro__remover"
title="Remover filtro"
aria-label="Remover filtro"
@click="remover(idx)"
>
<Trash2 :size="14" :stroke-width="2" />
</button>
</div>
<div class="eli-tabela-modal-filtro__entrada">
<component
:is="componenteEntrada(linha.entrada)"
v-bind="({ value: linha.valor, opcoes: opcoesEntrada(linha.entrada), density: 'compact' } as Record<string, unknown>)"
@update:value="(v: unknown) => (linha.valor = v)"
@input="(v: unknown) => (linha.valor = v)"
/>
</div>
</div>
</div>
<button
type="button"
class="eli-tabela-modal-filtro__remover"
title="Remover"
aria-label="Remover"
@click="remover(idx)"
>
×
</button>
<!-- Estado vazio orientado -->
<div v-else class="eli-tabela-modal-filtro__estado-vazio">
<Filter :size="32" :stroke-width="1.5" />
<span>Selecione um filtro abaixo para começar</span>
</div>
<!-- Rodapé: adicionar filtro + fórmula lado a lado -->
<div class="eli-tabela-modal-filtro__rodape-filtros">
<div class="eli-tabela-modal-filtro__acoes">
<select
v-model="chaveParaAdicionar"
class="eli-tabela-modal-filtro__select"
:disabled="!opcoesParaAdicionar.length"
>
<option disabled value="">Selecione um filtro…</option>
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
{{ rotuloDoFiltro(o) }}
</option>
</select>
<button
type="button"
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim"
@click="adicionar"
:disabled="!chaveParaAdicionar"
>
<Plus :size="15" :stroke-width="2.5" />
Adicionar
</button>
</div>
<!-- Fórmula: só aparece com 2+ filtros (com 1 filtro, fórmula não faz sentido) -->
<div v-if="linhas.length >= 2" class="eli-tabela-modal-filtro__formula-container">
<label class="eli-tabela-modal-filtro__formula-label">Fórmula</label>
<input
v-model="formulaCustomizada"
type="text"
class="eli-tabela-modal-filtro__formula-input"
:placeholder="'Ex: ' + formulaGerada"
/>
<span class="eli-tabela-modal-filtro__formula-ajuda" tabindex="0" aria-label="Ajuda sobre fórmulas">
<CircleHelp :size="15" :stroke-width="2" />
<div class="eli-tabela-modal-filtro__formula-tooltip" role="tooltip">
<strong>Como usar a fórmula</strong>
<p>Use os identificadores <code>{c1}</code>, <code>{c2}</code>, <code>{c3}</code>… correspondentes a cada filtro acima:</p>
<ul>
<li><code>E</code> — ambos devem ser verdadeiros</li>
<li><code>OU</code> — pelo menos um deve ser verdadeiro</li>
<li><code>NAO</code> — negação de uma condição</li>
<li><code>( )</code> — agrupa condições</li>
</ul>
<p><strong>Exemplo:</strong></p>
<code>{c1} OU ({c2} E NAO {c3})</code>
<p class="eli-tabela-modal-filtro__formula-tooltip-nota">Deixe em branco para combinar todos com E.</p>
</div>
</span>
</div>
</div>
</div>
</div>
<div class="eli-tabela-modal-filtro__acoes">
<select
v-model="chaveParaAdicionar"
class="eli-tabela-modal-filtro__select"
:disabled="!opcoesParaAdicionar.length"
>
<option disabled value="">Selecione um filtro…</option>
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
{{ rotuloDoFiltro(o) }}
</option>
</select>
<footer class="eli-tabela-modal-filtro__footer">
<button
type="button"
class="eli-tabela-modal-filtro__botao"
@click="adicionar"
:disabled="!chaveParaAdicionar"
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--perigo"
:disabled="!linhas.length"
@click="emitLimpar"
>
Adicionar
Limpar
</button>
</div>
<div class="eli-tabela-modal-filtro__footer-spacer"></div>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
Cancelar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
Aplicar
</button>
</footer>
</div>
<footer class="eli-tabela-modal-filtro__footer">
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitLimpar">
Limpar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
Cancelar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
Aplicar
</button>
</footer>
</div>
</div>
</Transition>
</template>
<script lang="ts">
import { computed, defineComponent, type PropType, ref, watch } from "vue"
import {
EliEntradaDataHora,
EliEntradaNumero,
EliEntradaTexto,
} from "../EliEntrada"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
type FiltroBase<T> = NonNullable<
tipoEliTabelaConsulta<T>["filtroAvancado"]
>[number]
type LinhaFiltro = {
chave: string
entrada: ComponenteEntrada
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
valor: any
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
function rotuloDoFiltro(f: FiltroBase<any>) {
return f.rotulo
}
import { CircleHelp, Filter, Plus, Trash2, X } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import type { FiltroBase } from "./useFiltroAvancado"
// biome-ignore lint/correctness/noUnusedImports: rotuloDoFiltro é usado no template Vue (invisível ao linter estático)
import { rotuloDoFiltro, useFiltroAvancado } from "./useFiltroAvancado"
import "./EliTabelaModalFiltroAvancado.css"
export default defineComponent({
name: "EliTabelaModalFiltroAvancado",
components: { CircleHelp, Filter, Plus, Trash2, X },
props: {
aberto: { type: Boolean, required: true },
filtrosBase: {
// biome-ignore lint/suspicious/noExplicitAny: generic component
type: Array as PropType<Array<FiltroBase<any>>>,
required: true,
},
filtrosBase: { type: Array as PropType<FiltroBase<string>[]>, required: true },
modelo: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic model
type: Array as PropType<Array<any>>,
/** Linhas persistidas do pai — normalizadas internamente via normalizarModelo() */
type: Array as PropType<Array<{ chave: string; valor: unknown }>>,
required: true,
},
modeloFormula: { type: String, default: "" },
},
emits: {
fechar: () => true,
limpar: () => true,
// biome-ignore lint/suspicious/noExplicitAny: dynamic lines
salvar: (_linhas: any[]) => true,
/** Dado serializável emitido pelo botão Aplicar */
salvar: (_linhas: Array<{ chave: string; valor: unknown }>, _formula?: string) => true,
},
setup(props, { emit }) {
const linhas = ref<Array<LinhaFiltro>>([])
const chaveParaAdicionar = ref<string>("")
const chavesDisponiveis = computed(() =>
(props.filtrosBase ?? []).map((b) => String(b.chave)),
)
const opcoesParaAdicionar = computed(() => {
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
return (props.filtrosBase ?? []).filter(
(b) => !usadas.has(String(b.chave)),
)
return useFiltroAvancado({
aberto: computed(() => props.aberto),
filtrosBase: computed(() => props.filtrosBase),
modelo: computed(() => props.modelo),
modeloFormula: computed(() => props.modeloFormula),
emit,
})
function componenteEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return EliEntradaNumero
if (tipo === "dataHora") return EliEntradaDataHora
return EliEntradaTexto
}
function opcoesEntrada(entrada: ComponenteEntrada) {
// o rótulo vem do próprio componente (entrada[1].rotulo)
// biome-ignore lint/suspicious/noExplicitAny: dynamic options
return (entrada?.[1] as any) ?? { rotulo: "" }
}
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return null
return ""
}
function normalizarModelo() {
const base = props.filtrosBase ?? []
const modelo = Array.isArray(props.modelo) ? props.modelo : []
linhas.value = modelo
// biome-ignore lint/suspicious/noExplicitAny: dynamic model item
.map((m: any) => {
const baseItem =
base.find((b) => String(b.chave) === String(m.chave)) ?? base[0]
if (!baseItem) return null
const entrada = (baseItem.entrada ?? m.entrada) as ComponenteEntrada
const chave = String(baseItem.chave ?? m.chave)
const val = m.valor ?? valorInicialPorEntrada(entrada)
return {
chave: chave,
entrada,
valor: val,
} as LinhaFiltro
})
.filter(Boolean) as LinhaFiltro[]
// se algum filtro mudou a chave para valor inválido, ajusta
linhas.value = linhas.value.filter((l) =>
chavesDisponiveis.value.includes(String(l.chave)),
)
}
watch(
() => [props.aberto, props.filtrosBase, props.modelo] as const,
() => {
if (props.aberto) normalizarModelo()
},
{ deep: true, immediate: true },
)
function adicionar() {
if (!chaveParaAdicionar.value) return
const b0 = (props.filtrosBase ?? []).find(
(b) => String(b.chave) === String(chaveParaAdicionar.value),
)
if (!b0) return
// evita repetição
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
linhas.value.push({
chave: String(b0.chave),
entrada: b0.entrada,
valor: valorInicialPorEntrada(b0.entrada),
})
chaveParaAdicionar.value = ""
}
function remover(idx: number) {
linhas.value.splice(idx, 1)
}
function emitFechar() {
emit("fechar")
}
function emitLimpar() {
emit("limpar")
}
function emitSalvar() {
emit(
"salvar",
linhas.value.map((l) => ({
chave: l.chave,
valor: l.valor,
})),
)
}
return {
linhas,
opcoesParaAdicionar,
chaveParaAdicionar,
componenteEntrada,
opcoesEntrada,
adicionar,
remover,
emitFechar,
emitSalvar,
emitLimpar,
rotuloDoFiltro,
}
},
})
</script>
<style>
.eli-tabela-modal-filtro__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-filtro__modal {
width: min(980px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
.eli-tabela-modal-filtro__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__titulo {
font-size: 1rem;
margin: 0;
}
.eli-tabela-modal-filtro__fechar {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 22px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-filtro__fechar:hover,
.eli-tabela-modal-filtro__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__conteudo {
padding: 16px;
}
.eli-tabela-modal-filtro__vazio {
opacity: 0.75;
font-size: 0.9rem;
}
.eli-tabela-modal-filtro__lista {
display: grid;
gap: 10px;
}
.eli-tabela-modal-filtro__linha {
display: grid;
grid-template-columns: 1fr 34px;
gap: 10px;
align-items: center;
}
.eli-tabela-modal-filtro__select {
height: 34px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
background: #fff;
}
.eli-tabela-modal-filtro__entrada {
min-width: 0;
}
.eli-tabela-modal-filtro__remover {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-filtro__remover:hover,
.eli-tabela-modal-filtro__remover:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__acoes {
margin-top: 12px;
}
.eli-tabela-modal-filtro__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__botao {
height: 34px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.eli-tabela-modal-filtro__botao:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.eli-tabela-modal-filtro__botao--sec:hover,
.eli-tabela-modal-filtro__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__botao--prim {
border: none;
background: rgba(37, 99, 235, 0.95);
color: #fff;
}
.eli-tabela-modal-filtro__botao--prim:hover,
.eli-tabela-modal-filtro__botao--prim:focus-visible {
background: rgba(37, 99, 235, 1);
}
@media (max-width: 880px) {
.eli-tabela-modal-filtro__linha {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,87 @@
.eli-tabela__paginacao {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.eli-tabela__pagina-botao {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: var(--cor-eli-superficie);
font-size: 0.875rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.82);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease;
}
.eli-tabela__pagina-botao:hover,
.eli-tabela__pagina-botao:focus-visible {
background-color: var(--cor-eli-primaria-bg);
border-color: var(--cor-eli-primaria-clara);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__pagina-botao:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
.eli-tabela__pagina-botao:disabled {
cursor: default;
opacity: 0.5;
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.75);
}
.eli-tabela__pagina-botao--ativo {
background: var(--cor-eli-primaria-bg);
border-color: var(--cor-eli-primaria);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__pagina-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
color: var(--cor-eli-neutro);
font-size: 0.9rem;
}
.eli-tabela__acao-inferior {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--cor-eli-primaria);
background: var(--cor-eli-superficie);
color: var(--cor-eli-primaria);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acao-inferior:hover,
.eli-tabela__acao-inferior:focus-visible {
background-color: var(--cor-eli-primaria-bg);
}
.eli-tabela__acao-inferior:focus-visible {
outline: 2px solid var(--cor-eli-primaria);
outline-offset: 2px;
}

View file

@ -77,7 +77,7 @@
<script lang="ts">
import { computed, defineComponent, type PropType } from "vue"
import "./EliTabelaPaginacao.css"
export default defineComponent({
name: "EliTabelaPaginacao",
props: {
@ -209,9 +209,7 @@ export default defineComponent({
}
const anteriorDesabilitado = computed(() => props.pagina <= 1)
const proximaDesabilitada = computed(
() => props.pagina >= props.totalPaginas,
)
const proximaDesabilitada = computed(() => props.pagina >= props.totalPaginas)
const paginaAtual = computed(() => props.pagina)
const totalPaginasExibidas = computed(() => props.totalPaginas)
@ -227,92 +225,3 @@ export default defineComponent({
})
</script>
<style>
.eli-tabela__paginacao {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.eli-tabela__pagina-botao {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
font-size: 0.875rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.82);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease;
}
.eli-tabela__pagina-botao:hover,
.eli-tabela__pagina-botao:focus-visible {
background-color: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__pagina-botao:disabled {
cursor: default;
opacity: 0.5;
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.75);
}
.eli-tabela__pagina-botao--ativo {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
color: rgba(107, 114, 128, 0.85);
font-size: 0.9rem;
}
.eli-tabela__acao-inferior {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid #16a34a; /* default green */
background: #ffffff;
color: #16a34a;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acao-inferior:hover,
.eli-tabela__acao-inferior:focus-visible {
background-color: #f0fdf4;
}
.eli-tabela__acao-inferior:focus-visible {
outline: 2px solid #16a34a;
outline-offset: 2px;
}
</style>

View file

@ -29,15 +29,11 @@ export default defineComponent({
},
setup(props) {
const tipo = computed(() => props.celula[0] as tipoTabelaCelula)
const dados = computed(
() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula],
)
const dados = computed(() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula])
// Observação: mantemos o registry tipado, mas o TS do template não consegue
// fazer narrowing do componente com base em `tipo`, então tipamos como `Component`.
const Componente = computed(
() => registryTabelaCelulas[tipo.value] as unknown as Component,
)
const Componente = computed(() => registryTabelaCelulas[tipo.value] as unknown as Component)
const dadosParaComponente = computed(() => dados.value)

View file

@ -12,7 +12,13 @@
</template>
<script lang="ts">
import { dayjsbr } from "p-comuns"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import "dayjs/locale/pt-br"
dayjs.extend(relativeTime)
dayjs.locale("pt-br")
import { computed, defineComponent, type PropType } from "vue"
import type { tiposTabelaCelulas } from "./tiposTabelaCelulas"
@ -24,27 +30,27 @@ export default defineComponent({
required: false,
},
},
setup({ dados }) {
setup(props) {
const textoData = computed(() => {
const valorIso = dados?.valor
const valorIso = props.dados?.valor
if (!valorIso) return ""
const formato = dados?.formato ?? "data"
const formato = props.dados?.formato ?? "data"
if (formato === "relativo") {
return dayjsbr(valorIso).fromNow()
return dayjs(valorIso).fromNow()
}
if (formato === "data_hora") {
// Padrão pt-BR simples (sem depender de locale do dayjsbr)
return dayjsbr(valorIso).format("DD/MM/YYYY HH:mm")
return dayjs(valorIso).format("DD/MM/YYYY HH:mm")
}
// formato === "data"
return dayjsbr(valorIso).format("DD/MM/YYYY")
return dayjs(valorIso).format("DD/MM/YYYY")
})
return { dados, textoData }
return { textoData }
},
})
</script>
@ -56,7 +62,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -66,7 +72,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -35,12 +35,12 @@ export default defineComponent({
type: Object as PropType<tiposTabelaCelulas["numero"]>,
},
},
setup({ dados }) {
setup(props) {
const textoNumero = computed(() => {
// Mantemos o comportamento anterior (trocar "." por ","), mas agora suportamos prefixo/sufixo.
const numero = String(dados?.numero).replace(".", ",")
const prefixo = dados?.prefixo?.trim()
const sufixo = dados?.sufixo?.trim()
const numero = String(props.dados?.numero).replace(".", ",")
const prefixo = props.dados?.prefixo?.trim()
const sufixo = props.dados?.sufixo?.trim()
const inicio = prefixo ? `${prefixo} ` : ""
const fim = sufixo ? ` ${sufixo}` : ""
@ -48,7 +48,7 @@ export default defineComponent({
return `${inicio}${numero}${fim}`
})
return { dados, textoNumero }
return { textoNumero }
},
})
</script>
@ -60,7 +60,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -70,7 +70,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -37,9 +37,6 @@ export default defineComponent({
required: false,
},
},
setup({ dados }) {
return { dados }
},
})
</script>

View file

@ -26,9 +26,6 @@ export default defineComponent({
return {}
},
methods: {},
setup({ dados }) {
return { dados }
},
})
</script>
@ -39,7 +36,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -49,7 +46,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -23,9 +23,6 @@ export default defineComponent({
type: Object as PropType<tiposTabelaCelulas["textoTruncado"]>,
},
},
setup({ dados }) {
return { dados }
},
})
</script>
@ -45,7 +42,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -55,7 +52,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -6,6 +6,16 @@ import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue"
import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue"
import type { tipoTabelaCelula } from "./tiposTabelaCelulas"
/**
* Mapa (dispatch table) que associa cada `tipoTabelaCelula` ao seu componente Vue de renderização.
* Usado por `EliTabelaCelula.vue` para resolver dinamicamente qual componente renderizar
* a partir do tipo retornado por `tipoEliColuna.celula(linha)`.
*
* Adicionar um novo tipo de célula requer:
* 1. Criar o componente `EliTabelaCelula<Tipo>.vue`
* 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts)
* 3. Registrar aqui no mapa
*/
export const registryTabelaCelulas = {
textoSimples: EliTabelaCelulaTextoSimples,
textoTruncado: EliTabelaCelulaTextoTruncado,

View file

@ -1,12 +1,18 @@
export type EliTabelaColunasConfig = {
/** Rotulos das colunas visiveis (em ordem). */
/** Rótulos das colunas visíveis (em ordem). */
visiveis: string[]
/** Rotulos das colunas invisiveis. */
/** Rótulos das colunas invisíveis. */
invisiveis: string[]
}
const STORAGE_PREFIX = "eli:tabela"
/**
* Retorna a chave de `localStorage` utilizada para persistir a configuração de colunas de uma tabela.
*
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
* @returns Chave no formato `eli:tabela:<nomeTabela>:colunas`
*/
export function storageKeyColunas(nomeTabela: string) {
return `${STORAGE_PREFIX}:${nomeTabela}:colunas`
}
@ -26,9 +32,14 @@ function normalizarConfig(valor: unknown): EliTabelaColunasConfig {
return { visiveis, invisiveis }
}
export function carregarConfigColunas(
nomeTabela: string,
): EliTabelaColunasConfig {
/**
* Carrega do `localStorage` a configuração de visibilidade e ordem das colunas de uma tabela.
* Em caso de dado corrompido ou ausente, retorna configuração vazia (sem colunas fixadas).
*
* @param nomeTabela - Identificador único da tabela
* @returns Configuração salva ou `{ visiveis: [], invisiveis: [] }` como fallback seguro
*/
export function carregarConfigColunas(nomeTabela: string): EliTabelaColunasConfig {
try {
const raw = window.localStorage.getItem(storageKeyColunas(nomeTabela))
if (!raw) return { visiveis: [], invisiveis: [] }
@ -38,24 +49,34 @@ export function carregarConfigColunas(
}
}
export function salvarConfigColunas(
nomeTabela: string,
config: EliTabelaColunasConfig,
) {
/**
* Persiste no `localStorage` a configuração de visibilidade e ordem das colunas.
* Falhas de armazenamento (modo privado, storage cheio) são silenciadas.
*
* @param nomeTabela - Identificador único da tabela
* @param config - Configuração com listas de colunas visíveis e invisíveis
*/
export function salvarConfigColunas(nomeTabela: string, config: EliTabelaColunasConfig) {
try {
window.localStorage.setItem(
storageKeyColunas(nomeTabela),
JSON.stringify(normalizarConfig(config)),
)
} catch {
// ignore
// Silencia erros de storage (modo privado, quota excedida)
}
}
/**
* Remove do `localStorage` a configuração de colunas salva para uma tabela.
* Usado para restaurar o estado padrão de visibilidade definido nas props.
*
* @param nomeTabela - Identificador único da tabela
*/
export function limparConfigColunas(nomeTabela: string) {
try {
window.localStorage.removeItem(storageKeyColunas(nomeTabela))
} catch {
// ignore
// Silencia erros de storage
}
}

View file

@ -1,43 +1,77 @@
export type EliTabelaFiltroAvancadoSalvo<T> = Array<{
coluna: keyof T
valor: T[keyof T]
}>
export type EliTabelaFiltroAvancadoSalvo<T> = {
linhas: Array<{
chave: keyof T
/** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */
valor: unknown
}>
formula?: string
}
function key(nomeTabela: string) {
return `eli_tabela:${nomeTabela}:filtro_avancado`
}
export function carregarFiltroAvancado<T>(
nomeTabela: string,
): EliTabelaFiltroAvancadoSalvo<T> {
/**
* Carrega do `localStorage` as linhas de filtro avançado salvas para uma tabela.
* Trata dois formatos legados: array direto de filtros (v1) e objeto `{ linhas, formula }` (v2).
* Em caso de dado ausente, corrompido ou formato desconhecido, retorna estado vazio.
*
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
* @returns Estado de filtros salvo, ou `{ linhas: [], formula: '' }` como fallback seguro
*/
export function carregarFiltroAvancado<T>(nomeTabela: string): EliTabelaFiltroAvancadoSalvo<T> {
const vazio: EliTabelaFiltroAvancadoSalvo<T> = { linhas: [], formula: "" }
try {
const raw = localStorage.getItem(key(nomeTabela))
if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
if (!raw) return vazio
const parsed = JSON.parse(raw)
return Array.isArray(parsed)
? (parsed as EliTabelaFiltroAvancadoSalvo<T>)
: ([] as unknown as EliTabelaFiltroAvancadoSalvo<T>)
if (Array.isArray(parsed)) {
return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo<T>
}
if (parsed && typeof parsed === "object") {
return {
linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [],
formula: typeof parsed.formula === "string" ? parsed.formula : "",
} as EliTabelaFiltroAvancadoSalvo<T>
}
return vazio
} catch {
return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
return vazio
}
}
/**
* Persiste no `localStorage` o estado do filtro avançado de uma tabela.
* Salva tanto as linhas de filtro quanto a fórmula booleana customizada.
* Falhas de armazenamento são silenciadas para não interromper o fluxo do usuário.
*
* @param nomeTabela - Identificador único da tabela
* @param filtros - Estado completo dos filtros a persistir
*/
export function salvarFiltroAvancado<T>(
nomeTabela: string,
filtros: EliTabelaFiltroAvancadoSalvo<T>,
) {
try {
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? []))
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? { linhas: [] }))
} catch {
// ignore
// Silencia erros de storage (modo privado, quota excedida)
}
}
/**
* Remove do `localStorage` os filtros avançados salvos para uma tabela.
* Chamado ao executar "Limpar Filtros" no modal de filtro avançado.
*
* @param nomeTabela - Identificador único da tabela
*/
export function limparFiltroAvancado(nomeTabela: string) {
try {
localStorage.removeItem(key(nomeTabela))
} catch {
// ignore
// Silencia erros de storage
}
}

View file

@ -1,14 +1,9 @@
import type { LucideIcon } from "@lucide/vue"
import type { tipoFiltro26 } from "p-comuns"
export type tipoFiltro<T> = tipoFiltro26<T>
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
import type { tipoResposta } from "p-respostas"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type {
tiposTabelaCelulas,
tipoTabelaCelula,
} from "./celulas/tiposTabelaCelulas"
import type { tiposTabelaCelulas, tipoTabelaCelula } from "./celulas/tiposTabelaCelulas"
export type tipoComponenteCelulaBase<T extends tipoTabelaCelula> = readonly [
T,
@ -19,6 +14,20 @@ export type tipoComponenteCelula = {
[K in tipoTabelaCelula]: tipoComponenteCelulaBase<K>
}[tipoTabelaCelula]
/**
* Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela.
* Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`,
* garantindo que não seja possível passar dados incorretos para um tipo de célula.
*
* Uso esperado dentro de `tipoEliColuna.celula`:
* ```ts
* celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome })
* ```
*
* @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`)
* @param dados - Dados tipados para o tipo de célula escolhido
* @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue`
*/
export const celulaTabela = <T extends tipoTabelaCelula>(
tipo: T,
dados: tiposTabelaCelulas[T],
@ -72,7 +81,7 @@ export type tipoEliTabelaAcao<T> = {
}
export type parametrosConsulta<T> = {
filtros?: tipoFiltro26<T>[]
filtros?: tipoFiltroDeltaColunaTipada<Extract<keyof T, string>>[]
coluna_ordem?: keyof T
direcao_ordem?: "asc" | "desc"
offSet?: number
@ -152,7 +161,7 @@ export type tipoEliTabelaConsulta<T> = {
/** Rótulo exibido no select de adicionar filtro */
rotulo: string
/** vai gerar filtro e mescla com os filtros já existentes */
filtro: (valor: unknown) => tipoFiltro26<T>
filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada<Extract<keyof T, string>> | undefined
/** componente de entrada que vai receber o valor para o filtro */
entrada: ComponenteEntrada
}[]

View file

@ -0,0 +1,139 @@
/**
* useColunasOrdenacao
* Composable que encapsula a lógica de drag-and-drop e sincronização de estado
* do modal de configuração de colunas da EliTabela.
*
* Responsável por:
* - Sincronizar colunas visíveis/invisíveis com a configuração persistida
* - Aplicar ordem salva e adicionar novas colunas ao final
* - Gerenciar eventos de arrastar-e-soltar entre as duas listas
*
* Extraído de `EliTabelaModalColunas.vue` para manter o componente abaixo de 250 linhas.
*/
import { type Ref, ref, watch } from "vue"
import type { EliTabelaColunasConfig } from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
type OrigemLista = "visiveis" | "invisiveis"
interface UseColunasOrdenacaoOptions {
aberto: Ref<boolean>
rotulosColunas: Ref<string[]>
configInicial: Ref<EliTabelaColunasConfig>
// biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai
colunas: Ref<tipoEliColuna<any>[]>
}
/**
* Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas.
* Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização)
* @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela
* @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis)
* @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão)
* @returns Estado reativo e handlers de drag-and-drop prontos para o template
*/
export function useColunasOrdenacao({
aberto,
rotulosColunas,
configInicial,
colunas,
}: UseColunasOrdenacaoOptions) {
const visiveisLocal = ref<string[]>([])
const invisiveisLocal = ref<string[]>([])
const itemSendoArrastado = ref<string | null>(null)
function sincronizarEstado() {
const todos = rotulosColunas.value
const configTemDados =
(configInicial.value.visiveis?.length ?? 0) > 0 ||
(configInicial.value.invisiveis?.length ?? 0) > 0
const invisiveisPadraoSet = new Set(
colunas.value.filter((c) => c.visivel === false).map((c) => c.rotulo),
)
const invisiveisSet = configTemDados
? new Set(configInicial.value.invisiveis ?? [])
: invisiveisPadraoSet
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
const ordemSalva = configInicial.value.visiveis ?? []
const setVis = new Set(baseVisiveis)
const ordenadas: string[] = []
for (const r of ordemSalva) {
if (setVis.has(r)) ordenadas.push(r)
}
for (const r of baseVisiveis) {
if (!ordenadas.includes(r)) ordenadas.push(r)
}
visiveisLocal.value = ordenadas
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
}
watch(
() => [aberto.value, rotulosColunas.value, configInicial.value, colunas.value] as const,
() => {
if (aberto.value) sincronizarEstado()
},
{ deep: true, immediate: true },
)
function onDragStart(e: DragEvent, rotulo: string) {
itemSendoArrastado.value = rotulo
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", rotulo)
e.dataTransfer.effectAllowed = "move"
}
}
function onDragEnd() {
itemSendoArrastado.value = null
}
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
const origem = origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
if (!origem) return
const origemArr = origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const dragIdx = origemArr.indexOf(draggedRotulo)
const alvoIdx = destinoArr.indexOf(alvoRotulo)
origemArr.splice(dragIdx, 1)
destinoArr.splice(alvoIdx, 0, draggedRotulo)
}
function onDragOverLista(destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo) return
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
destinoArr.push(draggedRotulo)
}
return {
visiveisLocal,
invisiveisLocal,
itemSendoArrastado,
onDragStart,
onDragEnd,
onDragOverItem,
onDragOverLista,
}
}

View file

@ -0,0 +1,105 @@
/**
* useEliTabela
* Orquestrador principal da EliTabela — compõe todos os sub-composables
* e retorna o estado unificado para o template do componente.
*
* Composables compostos:
* - `useEliTabelaConsulta` — carregamento, paginação, ordenação, busca
* - `useEliTabelaFiltrosAvancados` — filtros avançados, modal e persistência
* - `useEliTabelaSelecao` — seleção múltipla de linhas
* - `useEliTabelaColunas` — visibilidade, ordem e modal de colunas
* - `useEliTabelaMenu` — menu de ações por linha e posicionamento de popup
*
* @see EliTabela.vue — componente que consome este composable
*/
import { onMounted, ref, watch } from "vue"
import { carregarConfigColunas } from "./colunasStorage"
import { carregarFiltroAvancado } from "./filtroAvancadoStorage"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
import { useEliTabelaColunas } from "./useEliTabelaColunas"
import { useEliTabelaConsulta } from "./useEliTabelaConsulta"
import { useEliTabelaFiltrosAvancados } from "./useEliTabelaFiltrosAvancados"
import { useEliTabelaMenu } from "./useEliTabelaMenu"
import { useEliTabelaSelecao } from "./useEliTabelaSelecao"
// biome-ignore lint/suspicious/noExplicitAny: dynamic table — genérico passado por prop
type TabelaProps = tipoEliTabelaConsulta<any>
export function useEliTabela(
props: { tabela: TabelaProps; selecionados: unknown[]; esconderDebug: boolean },
emit: (event: "update:selecionados", value: unknown[]) => void,
) {
// Estado compartilhado entre sub-composables (passado por ref para reatividade)
const paginaAtual = ref(1)
const valorBusca = ref<string>("")
// Filtros avançados (precisa de carregar que ainda não existe; passa proxy que usará o da consulta)
// Usamos um ref de função para quebrar a dependência circular: filtros precisam de carregar,
// e consulta precisa de filtrosAvancadosAtivos.
let _carregarProxy: () => Promise<void> = async () => {
/* será atribuído após consulta ser criada */
}
const carregarProxy = () => _carregarProxy()
const filtros = useEliTabelaFiltrosAvancados(props, paginaAtual, valorBusca, carregarProxy)
// Colunas: temAcoes vem da consulta — usamos um ref intermediário
const _temAcoesRef = ref(false)
const colunas = useEliTabelaColunas(props, _temAcoesRef)
const consulta = useEliTabelaConsulta(
props,
filtros.filtrosAvancadosAtivos,
paginaAtual,
valorBusca,
colunas.linhasExpandidas,
)
// Conecta o proxy ao carregar real
_carregarProxy = consulta.carregar
// Sincroniza temAcoes para o composable de colunas
// biome-ignore lint/suspicious/noExplicitAny: ref sync interno
;(_temAcoesRef as any).value = consulta.temAcoes.value
watch(consulta.temAcoes, (v) => {
_temAcoesRef.value = v
})
const selecao = useEliTabelaSelecao(props, emit, consulta.linhasPaginadas)
const menu = useEliTabelaMenu(props, consulta.acoesVisiveis)
// Watch: troca de configuração de tabela reseta todos os estados e recarrega
watch(
() => props.tabela,
() => {
menu.menuAberto.value = null
consulta.colunaOrdenacao.value = null
consulta.direcaoOrdenacao.value = "asc"
valorBusca.value = ""
colunas.modalColunasAberto.value = false
filtros.modalFiltroAberto.value = false
colunas.configColunas.value = carregarConfigColunas(props.tabela.nome)
const filtrosRecarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
filtros.filtrosUi.value = filtrosRecarregados.linhas
colunas.linhasExpandidas.value = {}
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void consulta.carregar()
},
)
onMounted(() => void consulta.carregar())
return {
...consulta,
...filtros,
...selecao,
...colunas,
menuAberto: menu.menuAberto,
menuPopup: menu.menuPopup,
menuPopupPos: menu.menuPopupPos,
possuiAcoes: menu.possuiAcoes,
toggleMenu: menu.toggleMenu,
acoesDisponiveisPorLinha: menu.acoesDisponiveisPorLinha,
paginaAtual,
valorBusca,
}
}

View file

@ -0,0 +1,129 @@
/**
* useEliTabelaColunas
* Gerencia visibilidade, ordenação e persistência da configuração de colunas da EliTabela.
* Controla também o modal de colunas e o estado de linhas expandidas (detalhe de colunas invisíveis).
*
* @param props - Props do componente (tabela.colunas, tabela.nome)
* @param temAcoes - Computed que indica se existem ações por linha (para totalColunas)
*/
import { computed, type Ref, ref } from "vue"
import {
carregarConfigColunas,
type EliTabelaColunasConfig,
salvarConfigColunas,
} from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: colunas dinâmicas — genérico passado por prop
type PropsColunas = { tabela: { colunas: tipoEliColuna<any>[]; nome: string } }
export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref<boolean>) {
const modalColunasAberto = ref(false)
const configColunas = ref<EliTabelaColunasConfig>(carregarConfigColunas(props.tabela.nome))
const linhasExpandidas = ref<Record<number, boolean>>({})
const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo))
const colunasInvisiveisEfetivas = computed(() => {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const colunas = props.tabela.colunas as tipoEliColuna<any>[]
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, tipoEliColuna<any>>()
for (const c of base) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const ordenadas: tipoEliColuna<any>[] = []
for (const r of invisiveisBaseRotulos) {
const c = mapa.get(r)
if (c) ordenadas.push(c)
}
for (const c of base) {
if (!ordenadas.includes(c)) ordenadas.push(c)
}
return ordenadas
})
const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0)
const colunasEfetivas = computed(() => {
const colunas = props.tabela.colunas
const todosRotulos = rotulosColunas.value
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
(props.tabela.colunas as tipoEliColuna<any>[])
.filter((c) => c.visivel === false)
.map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r))
const visiveisSet = new Set(visiveisBaseRotulos)
const ordemSalva = configTemDados ? (configColunas.value.visiveis ?? []) : []
const ordemFinal: string[] = []
for (const r of ordemSalva) {
if (visiveisSet.has(r)) ordemFinal.push(r)
}
for (const r of visiveisBaseRotulos) {
if (!ordemFinal.includes(r)) ordemFinal.push(r)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, any>()
for (const c of colunas) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
})
const totalColunas = computed(
() =>
colunasEfetivas.value.length +
(temAcoes.value ? 1 : 0) +
(temColunasInvisiveis.value ? 1 : 0),
)
function abrirModalColunas() {
modalColunasAberto.value = true
}
function fecharModalColunas() {
modalColunasAberto.value = false
}
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
configColunas.value = cfg
salvarConfigColunas(props.tabela.nome, cfg)
modalColunasAberto.value = false
linhasExpandidas.value = {}
}
function alternarLinhaExpandida(indice: number) {
const atual = Boolean(linhasExpandidas.value[indice])
linhasExpandidas.value = { ...linhasExpandidas.value, [indice]: !atual }
}
return {
modalColunasAberto,
configColunas,
linhasExpandidas,
rotulosColunas,
colunasEfetivas,
colunasInvisiveisEfetivas,
temColunasInvisiveis,
totalColunas,
abrirModalColunas,
fecharModalColunas,
salvarModalColunas,
alternarLinhaExpandida,
}
}

View file

@ -0,0 +1,247 @@
/**
* useEliTabelaConsulta
* Gerencia o ciclo de consultas da EliTabela: carregamento, paginação, ordenação e busca.
* Usa um contador sequencial para descartar respostas de consultas antigas (race condition).
*
* @param props - Props do componente (tabela, esconderDebug)
* @param filtrosAvancadosAtivos - Computed com filtros ativos para envio à API
* @param paginaAtual - Ref da página atual (compartilhada com filtros e busca)
* @param valorBusca - Ref da busca textual (compartilhada com filtros avançados)
* @param linhasExpandidas - Ref das linhas expandidas (resetada ao carregar)
*/
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
import { codigosResposta } from "p-respostas"
import { computed, type Ref, ref, watch } from "vue"
import type { parametrosConsulta, tipoEliTabelaConsulta } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: genérico passado por prop
type PropsConsulta = { tabela: tipoEliTabelaConsulta<any>; esconderDebug: boolean }
export function useEliTabelaConsulta(
props: PropsConsulta,
// biome-ignore lint/suspicious/noExplicitAny: genérico
filtrosAvancadosAtivos: Ref<tipoFiltroDeltaColunaTipada<any>[]>,
paginaAtual: Ref<number>,
valorBusca: Ref<string>,
linhasExpandidas: Ref<Record<number, boolean>>,
) {
const isDev = import.meta.env.DEV && !props.esconderDebug
const carregando = ref(false)
const erro = ref<string | null>(null)
const linhas = ref<unknown[]>([])
const quantidade = ref<number>(0)
const acoesVisiveis = ref<boolean[][]>([])
const colunaOrdenacao = ref<string | null>(null)
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
const tabela = computed(() => props.tabela)
const registrosPorConsulta = computed(() => {
const valor = props.tabela.registros_por_consulta
return typeof valor === "number" && valor > 0 ? Math.floor(valor) : 10
})
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value
if (!limite || limite <= 0) return 1
const total = quantidade.value ?? 0
return total ? Math.max(1, Math.ceil(total / limite)) : 1
})
const linhasPaginadas = computed(() => linhas.value ?? [])
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca))
const acoesSuperiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
)
const acoesInferiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
)
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
const parametrosConsultaAtuais = computed(() => {
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
const params: parametrosConsulta<any> = { offSet: offset, limit: limite }
const busca = (valorBusca.value ?? "").trim()
if (busca) {
params.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) params.filtros = filtros
}
if (colunaOrdenacao.value) {
params.coluna_ordem = colunaOrdenacao.value
params.direcao_ordem = direcaoOrdenacao.value
}
return {
...params,
atualizarConsulta: async () => {
await carregar()
},
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
editarLista: async (novaLista: any[]) => {
linhas.value = novaLista
return novaLista
},
}
})
let carregamentoSequencial = 0
async function carregar() {
const idCarregamento = ++carregamentoSequencial
carregando.value = true
erro.value = null
try {
acoesVisiveis.value = []
linhasExpandidas.value = {}
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
const parametros: {
// biome-ignore lint/suspicious/noExplicitAny: generic
filtros?: tipoFiltroDeltaColunaTipada<any>[]
coluna_ordem?: never
direcao_ordem?: "asc" | "desc"
offSet: number
limit: number
texto_busca?: string
} = { offSet: offset, limit: limite }
const busca = (valorBusca.value ?? "").trim()
if (busca) {
parametros.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) parametros.filtros = filtros
}
if (colunaOrdenacao.value) {
parametros.coluna_ordem = colunaOrdenacao.value as never
parametros.direcao_ordem = direcaoOrdenacao.value
}
if (isDev) console.log("[EliTabela] consulta(parametros)", parametros) // eslint-disable-line no-console
const res = await props.tabela.consulta(parametros)
if (idCarregamento !== carregamentoSequencial) return
if (res.cod !== codigosResposta.sucesso) {
linhas.value = []
quantidade.value = 0
erro.value = res.mensagem
return
}
const valores = res.valor?.valores ?? []
const total = res.valor?.quantidade ?? valores.length
linhas.value = valores
quantidade.value = Number(total) || 0
const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidade.value || 0) / limite))
if (paginaAtual.value > totalPaginasRecalculado) paginaAtual.value = totalPaginasRecalculado
const acoesConf = props.tabela.acoesLinha ?? []
if (!acoesConf.length) {
acoesVisiveis.value = []
return
}
acoesVisiveis.value = valores.map(() =>
acoesConf.map((a) =>
a.exibir === undefined ? true : typeof a.exibir === "boolean" ? a.exibir : false,
),
)
const visibilidade = await Promise.all(
valores.map((linha) =>
Promise.all(
acoesConf.map(async (acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
try {
return Boolean(await Promise.resolve(acao.exibir(linha as never)))
} catch {
return false
}
}),
),
),
)
if (idCarregamento === carregamentoSequencial) acoesVisiveis.value = visibilidade
} catch (e) {
console.error("[EliTabela] Erro capturado em carregar():", e)
if (idCarregamento !== carregamentoSequencial) return
linhas.value = []
quantidade.value = 0
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
} finally {
if (idCarregamento === carregamentoSequencial) carregando.value = false
}
}
function alternarOrdenacao(chave?: string) {
if (!chave) return
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc"
void carregar()
return
}
colunaOrdenacao.value = chave
direcaoOrdenacao.value = "asc"
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function atualizarBusca(texto: string) {
if (valorBusca.value === texto) return
valorBusca.value = texto
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function irParaPagina(pagina: number) {
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
if (alvo !== paginaAtual.value) paginaAtual.value = alvo
}
watch(paginaAtual, (nova, antiga) => {
if (nova !== antiga) void carregar()
})
watch(linhas, () => {
linhasExpandidas.value = {}
})
watch(
() => props.tabela.mostrarCaixaDeBusca,
(mostrar) => {
if (!mostrar && valorBusca.value) {
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
},
)
watch(
() => props.tabela.registros_por_consulta,
() => {
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
},
)
return {
isDev,
tabela,
carregando,
erro,
linhas,
linhasPaginadas,
quantidade,
quantidadeFiltrada,
acoesVisiveis,
colunaOrdenacao,
direcaoOrdenacao,
registrosPorConsulta,
totalPaginas,
temAcoes,
exibirBusca,
acoesSuperiores,
acoesInferiores,
temAcoesSuperiores,
temAcoesInferiores,
parametrosConsultaAtuais,
carregar,
alternarOrdenacao,
atualizarBusca,
irParaPagina,
}
}

View file

@ -0,0 +1,128 @@
/**
* useEliTabelaFiltrosAvancados
* Gerencia o estado de filtros avançados da EliTabela, incluindo:
* - Linhas de filtro ativas (`filtrosUi`) e fórmula booleana (`filtrosFormula`)
* - Computed `filtrosAvancadosAtivos` que converte as linhas em FiltroDelta para a API
* - Abertura/fechamento e limpeza do modal de filtro avançado
*
* A fórmula é armazenada no formato `{chave}` (ex: `{email} E {nome}`) e convertida
* para IDs globais `{c1} {c2}` durante a montagem do FiltroDelta final.
*
* @param props - Props do componente (tabela.filtroAvancado, tabela.nome)
* @param paginaAtual - Ref da página atual (para resetar ao aplicar filtros)
* @param valorBusca - Ref da busca textual (limpa ao aplicar filtros avançados)
* @param carregar - Função de recarregamento dos dados
*/
import type { tipoCondicaoFiltroDelta, tipoFiltroDeltaColunaTipada } from "p-comuns"
import { computed, type Ref, ref } from "vue"
import {
carregarFiltroAvancado,
limparFiltroAvancado,
salvarFiltroAvancado,
} from "./filtroAvancadoStorage"
// biome-ignore lint/suspicious/noExplicitAny: dinâmico — genérico passado por prop
type PropsFiltros = { tabela: { filtroAvancado?: any[]; nome: string } }
type LinhaFiltroUI = { chave: string; valor: unknown }
export function useEliTabelaFiltrosAvancados(
props: PropsFiltros,
paginaAtual: Ref<number>,
valorBusca: Ref<string>,
carregar: () => Promise<void>,
) {
const _filtrosCarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
const filtrosUi = ref<LinhaFiltroUI[]>(_filtrosCarregados.linhas)
const filtrosFormula = ref<string>(_filtrosCarregados.formula ?? "")
const modalFiltroAberto = ref(false)
const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0)
// biome-ignore lint/suspicious/noExplicitAny: generic
const filtrosAvancadosAtivos = computed<tipoFiltroDeltaColunaTipada<any>[]>(() => {
const base = props.tabela.filtroAvancado ?? []
const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined)
if (ativos.length === 0) return []
const filtrosIndividuais = ativos
.map((f) => {
const b = base.find((x) => String(x.chave) === String(f.chave))
if (!b) return null
return { chave: f.chave, filtro: b.filtro(f.valor) }
})
.filter(
(f): f is { chave: string; filtro: tipoFiltroDeltaColunaTipada<string> } =>
f !== null && f.filtro !== undefined && f.filtro !== null,
)
if (filtrosFormula.value && filtrosIndividuais.length > 1) {
const condicoesFinais: tipoCondicaoFiltroDelta[] = []
let formulaFinal = filtrosFormula.value
filtrosIndividuais.forEach(({ chave }, i) => {
formulaFinal = formulaFinal.replace(new RegExp(`\\{${chave}\\}`, "g"), `{c${i + 1}}`)
})
let globalIdx = 1
for (let i = 0; i < filtrosIndividuais.length; i++) {
const { filtro } = filtrosIndividuais[i]
const idMap: Record<string, string> = {}
for (const cond of filtro.condicoes) {
const novoId = `{c${globalIdx++}}`
idMap[cond.id] = novoId
condicoesFinais.push({ ...cond, id: novoId })
}
let formulaLocal = filtro.formula
for (const [oldId, newId] of Object.entries(idMap)) {
formulaLocal = formulaLocal.replace(new RegExp(`\\{${oldId.slice(1, -1)}\\}`, "g"), newId)
}
const subst = filtro.condicoes.length > 1 ? `(${formulaLocal})` : formulaLocal
formulaFinal = formulaFinal.replace(new RegExp(`\\{c${i + 1}\\}`, "g"), subst)
}
return [{ condicoes: condicoesFinais, formula: formulaFinal }]
}
return filtrosIndividuais.map((f) => f.filtro)
})
function abrirModalFiltro() {
modalFiltroAberto.value = true
}
function fecharModalFiltro() {
modalFiltroAberto.value = false
}
function limparFiltrosAvancados() {
filtrosUi.value = []
filtrosFormula.value = ""
limparFiltroAvancado(props.tabela.nome)
modalFiltroAberto.value = false
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function salvarFiltrosAvancados(novoLinhas: LinhaFiltroUI[], novaFormula?: string) {
filtrosUi.value = novoLinhas ?? []
filtrosFormula.value = novaFormula ?? ""
salvarFiltroAvancado<Record<string, unknown>>(props.tabela.nome, {
linhas: filtrosUi.value,
formula: filtrosFormula.value,
})
modalFiltroAberto.value = false
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
return {
filtrosUi,
filtrosFormula,
modalFiltroAberto,
exibirFiltroAvancado,
filtrosAvancadosAtivos,
abrirModalFiltro,
fecharModalFiltro,
limparFiltrosAvancados,
salvarFiltrosAvancados,
}
}

View file

@ -0,0 +1,91 @@
/**
* useEliTabelaMenu
* Gerencia o menu de ações por linha da EliTabela.
* Controla abertura/fechamento, posicionamento do popup e detecção de clique externo.
*
* @param props - Props do componente (tabela.acoesLinha)
* @param acoesVisiveis - Ref com matriz de visibilidade das ações por linha
*/
import { nextTick, onBeforeUnmount, onMounted, type Ref, ref } from "vue"
import type { tipoEliTabelaAcao } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: genérico de linha passado por prop
type PropsMenu = { tabela: { acoesLinha?: tipoEliTabelaAcao<any>[] } }
export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref<boolean[][]>) {
const menuAberto = ref<number | null>(null)
const menuPopup = ref<{ menuEl: HTMLElement | null } | null>(null)
const menuPopupPos = ref({ top: 0, left: 0 })
function atualizarPosicaoMenu(anchor: HTMLElement) {
const rect = anchor.getBoundingClientRect()
const gap = 8
const alturaMenu = menuPopup.value?.menuEl?.offsetHeight ?? 0
const larguraMenu = menuPopup.value?.menuEl?.offsetWidth ?? 180
let top = rect.bottom + gap
const left = rect.right - larguraMenu
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
top = rect.top - gap - alturaMenu
}
menuPopupPos.value = {
top: Math.max(gap, Math.round(top)),
left: Math.max(gap, Math.round(left)),
}
}
function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) return
const alvo = evento.target as Node
if (menuPopup.value?.menuEl?.contains(alvo)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value })
}
menuAberto.value = null
}
function acoesDisponiveisPorLinha(i: number) {
const acoesLinha = props.tabela.acoesLinha ?? []
const visibilidade = acoesVisiveis.value[i] ?? []
return acoesLinha
.map((acao, indice) => {
const fallbackVisivel =
acao.exibir === undefined ? true : typeof acao.exibir === "boolean" ? acao.exibir : false
return { acao, indice, visivel: visibilidade[indice] ?? fallbackVisivel }
})
.filter((item) => item.visivel)
}
function possuiAcoes(i: number) {
return acoesDisponiveisPorLinha(i).length > 0
}
function toggleMenu(i: number, evento?: MouseEvent) {
if (!possuiAcoes(i)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value })
}
if (menuAberto.value === i) {
menuAberto.value = null
return
}
menuAberto.value = i
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => abriu", { i })
}
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
if (anchor) {
atualizarPosicaoMenu(anchor)
nextTick(() => {
if (menuAberto.value !== null) atualizarPosicaoMenu(anchor)
})
}
}
onMounted(() => document.addEventListener("click", handleClickFora))
onBeforeUnmount(() => document.removeEventListener("click", handleClickFora))
return { menuAberto, menuPopup, menuPopupPos, possuiAcoes, toggleMenu, acoesDisponiveisPorLinha }
}

View file

@ -0,0 +1,96 @@
/**
* useEliTabelaSelecao
* Gerencia a seleção múltipla de linhas na EliTabela.
* Suporta seleção por objeto completo ou por chave configurável (`chave_selecao`).
*
* @param props - Props do componente (selecionados, tabela.chave_selecao, tabela.mostrar_selecao)
* @param emit - Função de emit para `update:selecionados`
* @param linhasPaginadas - Ref com as linhas da página atual (para todosSelecionados/algunsSelecionados)
*/
import { computed, type Ref } from "vue"
type PropsSelecao = {
selecionados: unknown[]
tabela: { mostrar_selecao?: boolean; chave_selecao?: string | number | symbol }
}
export function useEliTabelaSelecao(
props: PropsSelecao,
emit: (event: "update:selecionados", value: unknown[]) => void,
linhasPaginadas: Ref<unknown[]>,
) {
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
const chaveSelecao = computed(() => props.tabela.chave_selecao as string | undefined)
function obterChaveLinha(linha: unknown): unknown {
if (chaveSelecao.value && linha && typeof linha === "object") {
return (linha as Record<string, unknown>)[chaveSelecao.value]
}
return linha
}
function isSelecionado(linha: unknown): boolean {
const chave = obterChaveLinha(linha)
if (chaveSelecao.value) {
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
}
return props.selecionados.includes(linha)
}
function alternarSelecao(linha: unknown) {
const atual = [...props.selecionados]
const chave = obterChaveLinha(linha)
let index = -1
if (chaveSelecao.value) {
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
} else {
index = atual.indexOf(linha)
}
if (index === -1) {
atual.push(linha)
} else {
atual.splice(index, 1)
}
emit("update:selecionados", atual)
}
const todosSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
})
const algunsSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.some((linha) => isSelecionado(linha)) && !todosSelecionados.value
})
function alternarSelecaoTodos(forcarMarcar?: boolean) {
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
let atual = [...props.selecionados]
if (marcarTodos) {
for (const linha of linhasPaginadas.value) {
if (!isSelecionado(linha)) atual.push(linha)
}
} else {
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
if (chaveSelecao.value) {
atual = atual.filter((s) => !chavesPagina.includes(obterChaveLinha(s)))
} else {
atual = atual.filter((s) => !chavesPagina.includes(s))
}
}
emit("update:selecionados", atual)
}
return {
mostrarSelecao,
isSelecionado,
alternarSelecao,
todosSelecionados,
algunsSelecionados,
alternarSelecaoTodos,
}
}

View file

@ -0,0 +1,211 @@
/**
* useFiltroAvancado
* Composable que encapsula a lógica do modal de filtro avançado da EliTabela.
*
* Responsável por:
* - Gerenciar a lista de linhas de filtro ativas
* - Converter fórmulas booleanas entre notação de exibição (`{c1}`) e persistência (`{chave}`)
* - Normalizar filtros carregados do localStorage (compatibilidade com formatos legados)
* - Resolver o componente de entrada correto por tipo (`texto`, `numero`, `dataHora`)
*
* Extraído de `EliTabelaModalFiltroAvancado.vue` para manter o componente abaixo de 250 linhas.
*/
import { computed, type Ref, ref, watch } from "vue"
import { EliEntradaDataHora, EliEntradaNumero, EliEntradaTexto } from "../EliEntrada"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
export type FiltroBase<T> = NonNullable<tipoEliTabelaConsulta<T>["filtroAvancado"]>[number]
export type LinhaFiltro = {
chave: string
entrada: ComponenteEntrada
/** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */
valor: unknown
}
/**
* Retorna o rótulo de exibição de uma definição de filtro.
* Utilizado no template do modal para exibir o nome do campo ao usuário.
*
* @param f - Definição de filtro da configuração da tabela
* @returns Rótulo textual do filtro
*/
export function rotuloDoFiltro(f: FiltroBase<string>) {
return f.rotulo
}
interface UseFiltroAvancadoOptions {
aberto: Ref<boolean>
filtrosBase: Ref<FiltroBase<string>[]>
modelo: Ref<Array<{ chave: string; valor: unknown }>>
modeloFormula: Ref<string>
emit: {
(event: "fechar"): void
(event: "limpar"): void
(event: "salvar", linhas: Array<{ chave: string; valor: unknown }>, formula?: string): void
}
}
/**
* Composable principal do modal de filtro avançado da EliTabela.
* Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto
* @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela
* @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai)
* @param options.modeloFormula - Ref com a fórmula booleana persistida no pai
* @param options.emit - Função de emit do componente Vue pai
* @returns Estado reativo e ações do modal de filtro
*/
export function useFiltroAvancado({
aberto,
filtrosBase,
modelo,
modeloFormula,
emit,
}: UseFiltroAvancadoOptions) {
const linhas = ref<LinhaFiltro[]>([])
const chaveParaAdicionar = ref<string>("")
const formulaCustomizada = ref("")
const formulaGerada = computed(() => linhas.value.map((_, i) => `{c${i + 1}}`).join(" E "))
function formulaParaExibicao(formula: string): string {
let resultado = formula
linhas.value.forEach((l, i) => {
resultado = resultado.replace(new RegExp(`\\{${l.chave}\\}`, "g"), `{c${i + 1}}`)
})
return resultado
}
function formulaParaPersistencia(formula: string): string {
let resultado = formula
for (let i = linhas.value.length; i >= 1; i--) {
const chave = linhas.value[i - 1]?.chave
if (chave) {
resultado = resultado.replace(new RegExp(`\\{c${i}\\}`, "g"), `{${chave}}`)
}
}
return resultado
}
const chavesDisponiveis = computed(() => (filtrosBase.value ?? []).map((b) => String(b.chave)))
const opcoesParaAdicionar = computed(() => {
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
return (filtrosBase.value ?? []).filter((b) => !usadas.has(String(b.chave)))
})
function componenteEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return EliEntradaNumero
if (tipo === "dataHora") return EliEntradaDataHora
return EliEntradaTexto
}
function opcoesEntrada(entrada: ComponenteEntrada) {
const opcoes: Record<string, unknown> =
typeof entrada?.[1] === "object" && entrada[1] !== null
? { ...(entrada[1] as Record<string, unknown>) }
: {}
return { ...opcoes, rotulo: "" }
}
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
if (entrada?.[0] === "numero") return null
return ""
}
function normalizarModelo() {
const base = filtrosBase.value ?? []
const modeloArr = Array.isArray(modelo.value) ? modelo.value : []
linhas.value = modeloArr
.map((m: unknown) => {
if (!m || typeof m !== "object") return null
const item = m as Record<string, unknown>
const baseItem = base.find((b) => String(b.chave) === String(item["chave"])) ?? base[0]
if (!baseItem) return null
const entrada = (baseItem.entrada ?? item["entrada"]) as ComponenteEntrada
const chave = String(baseItem.chave ?? item["chave"])
const val = item["valor"] ?? valorInicialPorEntrada(entrada)
return { chave, entrada, valor: val } as LinhaFiltro
})
.filter(Boolean) as LinhaFiltro[]
linhas.value = linhas.value.filter((l) => chavesDisponiveis.value.includes(String(l.chave)))
if (modeloFormula.value) {
formulaCustomizada.value = formulaParaExibicao(modeloFormula.value)
} else {
formulaCustomizada.value = formulaGerada.value
}
}
watch(
() => [aberto.value, filtrosBase.value, modelo.value] as const,
() => {
if (aberto.value) normalizarModelo()
},
{ deep: true, immediate: true },
)
function adicionar() {
if (!chaveParaAdicionar.value) return
const b0 = (filtrosBase.value ?? []).find(
(b) => String(b.chave) === String(chaveParaAdicionar.value),
)
if (!b0) return
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
linhas.value.push({
chave: String(b0.chave),
entrada: b0.entrada,
valor: valorInicialPorEntrada(b0.entrada),
})
formulaCustomizada.value = formulaGerada.value
chaveParaAdicionar.value = ""
}
function remover(idx: number) {
linhas.value.splice(idx, 1)
formulaCustomizada.value = formulaGerada.value
}
function emitFechar() {
emit("fechar")
}
function emitLimpar() {
emit("limpar")
}
function emitSalvar() {
const formulaPersistida = formulaParaPersistencia(formulaCustomizada.value)
emit(
"salvar",
linhas.value.map((l) => ({ chave: l.chave, valor: l.valor })),
formulaPersistida,
)
}
function rotuloDoFiltroAtivo(chave: string): string {
const base = (filtrosBase.value ?? []).find((b) => String(b.chave) === chave)
return base ? rotuloDoFiltro(base) : chave
}
return {
linhas,
opcoesParaAdicionar,
chaveParaAdicionar,
formulaCustomizada,
formulaGerada,
componenteEntrada,
opcoesEntrada,
adicionar,
remover,
emitFechar,
emitSalvar,
emitLimpar,
rotuloDoFiltro,
rotuloDoFiltroAtivo,
}
}