-
- Nenhum filtro configurado na tabela.
-
+
+
+ Nenhum filtro configurado na tabela.
+
-
-
-
-
(linha.valor = v)"
- @input="(v: any) => (linha.valor = v)"
- :opcoes="opcoesEntrada(linha.entrada)"
- density="compact"
- />
+
+
+
+
+
+
+
+ (linha.valor = v)"
+ @input="(v: unknown) => (linha.valor = v)"
+ />
+
+
-
- ×
-
+
+
+
+ Selecione um filtro abaixo para começar
+
+
+
+
+
+
+ Selecione um filtro…
+
+ {{ rotuloDoFiltro(o) }}
+
+
+
+
+ Adicionar
+
+
+
+
+
+
-
-
- Selecione um filtro…
-
- {{ rotuloDoFiltro(o) }}
-
-
+
+
+
+ Cancelar
+
+
+ Aplicar
+
+
-
-
-
+
+
-
diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.css b/src/componentes/EliTabela/EliTabelaPaginacao.css
new file mode 100644
index 0000000..c72a470
--- /dev/null
+++ b/src/componentes/EliTabela/EliTabelaPaginacao.css
@@ -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;
+}
diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.vue b/src/componentes/EliTabela/EliTabelaPaginacao.vue
index bc5687c..d021fb0 100644
--- a/src/componentes/EliTabela/EliTabelaPaginacao.vue
+++ b/src/componentes/EliTabela/EliTabelaPaginacao.vue
@@ -77,7 +77,7 @@
-
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue
index 6ddb949..35a4ac3 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue
@@ -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)
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue
index 7e7adfd..709496d 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue
@@ -12,7 +12,13 @@
@@ -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;
}
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue
index a5b5611..f35a788 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue
@@ -35,12 +35,12 @@ export default defineComponent({
type: Object as PropType
,
},
},
- 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 }
},
})
@@ -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;
}
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue
index 80e5b03..e2e80f8 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue
@@ -37,9 +37,6 @@ export default defineComponent({
required: false,
},
},
- setup({ dados }) {
- return { dados }
- },
})
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue
index 7e61dfb..61771f0 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue
@@ -26,9 +26,6 @@ export default defineComponent({
return {}
},
methods: {},
- setup({ dados }) {
- return { dados }
- },
})
@@ -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;
}
diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue
index 30eba16..6995eb2 100644
--- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue
+++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue
@@ -23,9 +23,6 @@ export default defineComponent({
type: Object as PropType,
},
},
- setup({ dados }) {
- return { dados }
- },
})
@@ -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;
}
diff --git a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts
index 049d06b..3ed9819 100644
--- a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts
+++ b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts
@@ -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.vue`
+ * 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts)
+ * 3. Registrar aqui no mapa
+ */
export const registryTabelaCelulas = {
textoSimples: EliTabelaCelulaTextoSimples,
textoTruncado: EliTabelaCelulaTextoTruncado,
diff --git a/src/componentes/EliTabela/colunasStorage.ts b/src/componentes/EliTabela/colunasStorage.ts
index bb2b9c6..5cbe999 100644
--- a/src/componentes/EliTabela/colunasStorage.ts
+++ b/src/componentes/EliTabela/colunasStorage.ts
@@ -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::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
}
}
diff --git a/src/componentes/EliTabela/filtroAvancadoStorage.ts b/src/componentes/EliTabela/filtroAvancadoStorage.ts
index fdf8230..33eba84 100644
--- a/src/componentes/EliTabela/filtroAvancadoStorage.ts
+++ b/src/componentes/EliTabela/filtroAvancadoStorage.ts
@@ -1,43 +1,77 @@
-export type EliTabelaFiltroAvancadoSalvo = Array<{
- coluna: keyof T
-
- valor: T[keyof T]
-}>
+export type EliTabelaFiltroAvancadoSalvo = {
+ 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(
- nomeTabela: string,
-): EliTabelaFiltroAvancadoSalvo {
+/**
+ * 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(nomeTabela: string): EliTabelaFiltroAvancadoSalvo {
+ const vazio: EliTabelaFiltroAvancadoSalvo = { linhas: [], formula: "" }
try {
const raw = localStorage.getItem(key(nomeTabela))
- if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo
+ if (!raw) return vazio
+
const parsed = JSON.parse(raw)
- return Array.isArray(parsed)
- ? (parsed as EliTabelaFiltroAvancadoSalvo)
- : ([] as unknown as EliTabelaFiltroAvancadoSalvo)
+ if (Array.isArray(parsed)) {
+ return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo
+ }
+
+ if (parsed && typeof parsed === "object") {
+ return {
+ linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [],
+ formula: typeof parsed.formula === "string" ? parsed.formula : "",
+ } as EliTabelaFiltroAvancadoSalvo
+ }
+
+ return vazio
} catch {
- return [] as unknown as EliTabelaFiltroAvancadoSalvo
+ 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(
nomeTabela: string,
filtros: EliTabelaFiltroAvancadoSalvo,
) {
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
}
}
diff --git a/src/componentes/EliTabela/types-eli-tabela.ts b/src/componentes/EliTabela/types-eli-tabela.ts
index 8171d39..70fb000 100644
--- a/src/componentes/EliTabela/types-eli-tabela.ts
+++ b/src/componentes/EliTabela/types-eli-tabela.ts
@@ -1,14 +1,9 @@
import type { LucideIcon } from "@lucide/vue"
-import type { tipoFiltro26 } from "p-comuns"
-
-export type tipoFiltro = tipoFiltro26
+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 = readonly [
T,
@@ -19,6 +14,20 @@ export type tipoComponenteCelula = {
[K in tipoTabelaCelula]: tipoComponenteCelulaBase
}[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 = (
tipo: T,
dados: tiposTabelaCelulas[T],
@@ -72,7 +81,7 @@ export type tipoEliTabelaAcao = {
}
export type parametrosConsulta = {
- filtros?: tipoFiltro26[]
+ filtros?: tipoFiltroDeltaColunaTipada>[]
coluna_ordem?: keyof T
direcao_ordem?: "asc" | "desc"
offSet?: number
@@ -152,7 +161,7 @@ export type tipoEliTabelaConsulta = {
/** Rótulo exibido no select de adicionar filtro */
rotulo: string
/** vai gerar filtro e mescla com os filtros já existentes */
- filtro: (valor: unknown) => tipoFiltro26
+ filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada> | undefined
/** componente de entrada que vai receber o valor para o filtro */
entrada: ComponenteEntrada
}[]
diff --git a/src/componentes/EliTabela/useColunasOrdenacao.ts b/src/componentes/EliTabela/useColunasOrdenacao.ts
new file mode 100644
index 0000000..7f23781
--- /dev/null
+++ b/src/componentes/EliTabela/useColunasOrdenacao.ts
@@ -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
+ rotulosColunas: Ref
+ configInicial: Ref
+ // biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai
+ colunas: Ref[]>
+}
+
+/**
+ * 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([])
+ const invisiveisLocal = ref([])
+ const itemSendoArrastado = ref(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,
+ }
+}
diff --git a/src/componentes/EliTabela/useEliTabela.ts b/src/componentes/EliTabela/useEliTabela.ts
new file mode 100644
index 0000000..973b52f
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabela.ts
@@ -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
+
+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("")
+
+ // 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 = 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>(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,
+ }
+}
diff --git a/src/componentes/EliTabela/useEliTabelaColunas.ts b/src/componentes/EliTabela/useEliTabelaColunas.ts
new file mode 100644
index 0000000..6b6bb19
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabelaColunas.ts
@@ -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[]; nome: string } }
+
+export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref) {
+ const modalColunasAberto = ref(false)
+ const configColunas = ref(carregarConfigColunas(props.tabela.nome))
+ const linhasExpandidas = ref>({})
+
+ 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[]
+ 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>()
+ 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[] = []
+ 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[])
+ .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()
+ 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,
+ }
+}
diff --git a/src/componentes/EliTabela/useEliTabelaConsulta.ts b/src/componentes/EliTabela/useEliTabelaConsulta.ts
new file mode 100644
index 0000000..6cfb853
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabelaConsulta.ts
@@ -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; esconderDebug: boolean }
+
+export function useEliTabelaConsulta(
+ props: PropsConsulta,
+ // biome-ignore lint/suspicious/noExplicitAny: genérico
+ filtrosAvancadosAtivos: Ref[]>,
+ paginaAtual: Ref,
+ valorBusca: Ref,
+ linhasExpandidas: Ref>,
+) {
+ const isDev = import.meta.env.DEV && !props.esconderDebug
+ const carregando = ref(false)
+ const erro = ref(null)
+ const linhas = ref([])
+ const quantidade = ref(0)
+ const acoesVisiveis = ref([])
+ const colunaOrdenacao = ref(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 = { 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[]
+ 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,
+ }
+}
diff --git a/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts b/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts
new file mode 100644
index 0000000..dcdc843
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts
@@ -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,
+ valorBusca: Ref,
+ carregar: () => Promise,
+) {
+ const _filtrosCarregados = carregarFiltroAvancado>(props.tabela.nome)
+ const filtrosUi = ref(_filtrosCarregados.linhas)
+ const filtrosFormula = ref(_filtrosCarregados.formula ?? "")
+ const modalFiltroAberto = ref(false)
+
+ const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0)
+
+ // biome-ignore lint/suspicious/noExplicitAny: generic
+ const filtrosAvancadosAtivos = computed[]>(() => {
+ 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 } =>
+ 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 = {}
+ 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>(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,
+ }
+}
diff --git a/src/componentes/EliTabela/useEliTabelaMenu.ts b/src/componentes/EliTabela/useEliTabelaMenu.ts
new file mode 100644
index 0000000..eb8c58a
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabelaMenu.ts
@@ -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[] } }
+
+export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref) {
+ const menuAberto = ref(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 }
+}
diff --git a/src/componentes/EliTabela/useEliTabelaSelecao.ts b/src/componentes/EliTabela/useEliTabelaSelecao.ts
new file mode 100644
index 0000000..cebd5b3
--- /dev/null
+++ b/src/componentes/EliTabela/useEliTabelaSelecao.ts
@@ -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,
+) {
+ 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)[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,
+ }
+}
diff --git a/src/componentes/EliTabela/useFiltroAvancado.ts b/src/componentes/EliTabela/useFiltroAvancado.ts
new file mode 100644
index 0000000..010420a
--- /dev/null
+++ b/src/componentes/EliTabela/useFiltroAvancado.ts
@@ -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 = NonNullable["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) {
+ return f.rotulo
+}
+
+interface UseFiltroAvancadoOptions {
+ aberto: Ref
+ filtrosBase: Ref[]>
+ modelo: Ref>
+ modeloFormula: Ref
+ 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([])
+ const chaveParaAdicionar = ref("")
+ 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 =
+ typeof entrada?.[1] === "object" && entrada[1] !== null
+ ? { ...(entrada[1] as Record) }
+ : {}
+ 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
+ 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,
+ }
+}
diff --git a/src/design-tokens.css b/src/design-tokens.css
new file mode 100644
index 0000000..4b840ad
--- /dev/null
+++ b/src/design-tokens.css
@@ -0,0 +1,46 @@
+/**
+ * Design Tokens — Paleta Institucional Azteca
+ *
+ * Arquivo centralizado de variáveis CSS usado por todos os componentes Eli.
+ * Importado no ponto de entrada do projeto (main.ts ou App.vue).
+ *
+ * Escala primária: Verde floresta (#688C00)
+ * Escala de apoio: Âmbar terroso
+ * Escala neutra: Cinza musgo
+ */
+
+:root {
+ /* ── Escala primária — Verde floresta ── */
+ --cor-eli-primaria-bg: #EEF3D0;
+ --cor-eli-primaria-zebra: #F6F9E8;
+ --cor-eli-primaria-suave: #BFC99C;
+ --cor-eli-primaria-header: #ADCC44;
+ --cor-eli-primaria-clara: #D4E290;
+ --cor-eli-primaria-media: #ADCC44;
+ --cor-eli-primaria-acao: #8AAF00;
+ --cor-eli-primaria: #688C00;
+ --cor-eli-primaria-escura: #4A6500;
+ --cor-eli-primaria-texto: #2E3F00;
+
+ /* ── Escala de apoio — Âmbar terroso ── */
+ --cor-eli-alerta-bg: #FAF3DF;
+ --cor-eli-alerta-clara: #F5DC8A;
+ --cor-eli-alerta: #DDB847;
+ --cor-eli-alerta-forte: #8C6A00;
+
+ /* ── Escala neutra — Cinza musgo ── */
+ --cor-eli-fundo: #F2F3EE;
+ --cor-eli-borda: #D5D8CC;
+ --cor-eli-borda-forte: #B5BAA8;
+ --cor-eli-neutro: #5E6553;
+ --cor-eli-neutro-escuro: #3E4436;
+ --cor-eli-superficie: #FFFFFF;
+
+ /* ── Semânticas ── */
+ --cor-eli-sucesso: #2E7D32;
+ --cor-eli-sucesso-bg: #E8F5E9;
+ --cor-eli-erro: #C62828;
+ --cor-eli-erro-bg: #FFEBEE;
+ --cor-eli-info: #1565C0;
+ --cor-eli-info-bg: #E3F2FD;
+}
diff --git a/src/index.ts b/src/index.ts
index 97391f7..c1c04b0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
import type { App, Plugin } from "vue"
import "./styles/eli-vue-fonts.css"
+import "./design-tokens.css"
import { EliBotao } from "./componentes/botao"
import { EliCartao } from "./componentes/cartao"
import {
@@ -13,7 +14,7 @@ import { EliTabela } from "./componentes/EliTabela"
import { EliBadge } from "./componentes/indicador"
import { EliOlaMundo } from "./componentes/ola_mundo"
-export { criarFiltro26 } from "p-comuns"
+export { iniciarFiltroDelta } from "p-comuns"
export * from "./componentes/EliEntrada"
// Exportar tudo (componentes + types + helpers) de Tabela e Entrada
export * from "./componentes/EliTabela"
diff --git a/src/playground/App.vue b/src/playground/App.vue
index dde1d02..b27ddd8 100644
--- a/src/playground/App.vue
+++ b/src/playground/App.vue
@@ -1,33 +1,78 @@
-
- Playground — eli-vue
+
+
+
+
+ Playground — eli-vue
+
+ Botão
+ Indicador
+ Cartão
+ Entradas
+ Data e hora
+ Tabela
+ Demo
+
+
-
- Botão
- Indicador
- Cartão
- Entradas
- Data e hora
- Tabela
- Demo
-
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+ Console
+
+
+
LIMPAR
+
-
-
-
-
-
-
-
-
+
+
+
+
+
@@ -429,6 +84,15 @@ export default defineComponent({
}
.stack :deep(.eli-tabela) {
- max-width: 900px;
+ max-width: 100%;
+}
+
+.debug {
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.04);
+ border-radius: 8px;
+ overflow: auto;
+ font-size: 12px;
+ margin-bottom: 8px;
}
diff --git a/src/playground/useTabelaPlayground.ts b/src/playground/useTabelaPlayground.ts
new file mode 100644
index 0000000..165d9b1
--- /dev/null
+++ b/src/playground/useTabelaPlayground.ts
@@ -0,0 +1,171 @@
+/**
+ * useTabelaPlayground
+ * Composable principal do playground da EliTabela.
+ * Gerencia estado de CRUD (modal, formulário, erros) e compõe as 3 instâncias de tabela de demonstração.
+ * A configuração de colunas, filtros e consulta está em `useTabelaPlaygroundConfig.ts`.
+ *
+ * @returns Estado reativo, configurações de tabela e ações de CRUD para o template
+ * @see useTabelaPlaygroundConfig.ts — definições de colunas, filtros e consulta
+ * @see tabela.playground.vue — componente que consome este composable
+ */
+import { Plus } from "@lucide/vue"
+import { codigosResposta } from "p-respostas"
+import { inject, ref } from "vue"
+import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela"
+import { PLAYGROUND_CONSOLE_KEY, type PlaygroundConsole } from "./App.vue"
+import { ApiSimulacao } from "./api_simulador"
+import type { tipoEmpreendedores } from "./api_simulador/dados"
+import { colunasTabela, criarAcoesLinha, filtrosAvancados } from "./useTabelaPlaygroundConfig"
+import { consultar } from "./useTabelaPlaygroundConsultar"
+
+export function useTabelaPlayground() {
+ const playgroundConsole = inject(PLAYGROUND_CONSOLE_KEY)
+
+ function adicionarLog(acao: string, req: unknown, res: unknown) {
+ if (playgroundConsole) playgroundConsole.adicionarLog(`ApiSimulacao: ${acao}`, req, res)
+ }
+
+ const versaoTabelaOk = ref(0)
+ const selecionados = ref([])
+ const mostrarModalCadastro = ref(false)
+ const salvando = ref(false)
+ const novoCadastro = ref>({
+ empreendedor: "",
+ empreendimento: "",
+ documento: "",
+ email: "",
+ telefone: "",
+ faturamento: 0,
+ peso_kg: 0,
+ areas_atuacao: [],
+ })
+ const erroCadastro = ref(undefined)
+
+ let atualizarTabelaCallback: (() => Promise) | undefined
+
+ // biome-ignore lint/suspicious/noExplicitAny: playground — parâmetros dinâmicos da ação de tabela
+ function abrirModalCadastro(params?: any) {
+ if (params?.atualizarConsulta) atualizarTabelaCallback = params.atualizarConsulta
+ novoCadastro.value = {
+ empreendedor: "",
+ empreendimento: "",
+ documento: "",
+ email: "",
+ telefone: "",
+ faturamento: 0,
+ peso_kg: 0,
+ areas_atuacao: [],
+ }
+ erroCadastro.value = undefined
+ mostrarModalCadastro.value = true
+ }
+
+ async function salvarNovoCadastro() {
+ salvando.value = true
+ erroCadastro.value = undefined
+ try {
+ const payload: tipoEmpreendedores = {
+ ...novoCadastro.value,
+ codigo: "novo",
+ criado_em: new Date().toISOString(),
+ atualizado_em: new Date().toISOString(),
+ }
+ const resposta = await ApiSimulacao("criar", payload, (req, res) => {
+ console.groupCollapsed("🌐 [ApiSimulacao] criar")
+ console.info("Req:", req)
+ console.info("Res:", res)
+ console.groupEnd()
+ adicionarLog("criar", req, res)
+ })
+ if (resposta.eErro) {
+ erroCadastro.value = resposta.mensagem
+ return
+ }
+ if (atualizarTabelaCallback) await atualizarTabelaCallback()
+ else versaoTabelaOk.value++
+ mostrarModalCadastro.value = false
+ } catch (e) {
+ erroCadastro.value = String(e)
+ } finally {
+ salvando.value = false
+ }
+ }
+
+ function removerCadastro(linha: tipoEmpreendedores) {
+ if (confirm(`Tem certeza que deseja remover ${linha.empreendedor}?`)) {
+ ApiSimulacao("deletar", { codigo: linha.codigo }, (req, res) => {
+ console.groupCollapsed("🌐 [ApiSimulacao] deletar")
+ console.info("Req:", req)
+ console.info("Res:", res)
+ console.groupEnd()
+ adicionarLog("deletar", req, res)
+ }).then(() => {
+ versaoTabelaOk.value++
+ })
+ }
+ }
+
+ const acoesLinha = criarAcoesLinha(removerCadastro)
+ const consultarFn: tipoEliTabelaConsulta["consulta"] = (params) =>
+ consultar(params, adicionarLog)
+
+ const tabelaOk: tipoEliTabelaConsulta = {
+ nome: "ExemploPlaygroundData",
+ registros_por_consulta: 10,
+ mostrarCaixaDeBusca: true,
+ mostrar_selecao: true,
+ chave_selecao: "empreendedor",
+ acoesTabela: [
+ { posicao: "superior", icone: Plus, rotulo: "Novo (Superior)", acao: abrirModalCadastro },
+ { posicao: "inferior", icone: Plus, rotulo: "Novo (Inferior)", acao: abrirModalCadastro },
+ ],
+ colunas: colunasTabela,
+ acoesLinha,
+ filtroAvancado: filtrosAvancados,
+ consulta: consultarFn,
+ }
+
+ const tabelaVazia: tipoEliTabelaConsulta = {
+ nome: "Exemplo Vazio",
+ registros_por_consulta: 10,
+ mostrarCaixaDeBusca: true,
+ colunas: colunasTabela,
+ mensagemVazio: "Nada para mostrar aqui.",
+ acoesLinha,
+ consulta: async () => ({
+ cod: codigosResposta.sucesso,
+ eCerto: true,
+ eErro: false,
+ mensagem: undefined,
+ valor: { quantidade: 0, valores: [] },
+ }),
+ }
+
+ const tabelaErro: tipoEliTabelaConsulta = {
+ nome: "Exemplo",
+ registros_por_consulta: tabelaOk.registros_por_consulta,
+ mostrarCaixaDeBusca: tabelaOk.mostrarCaixaDeBusca,
+ colunas: colunasTabela,
+ acoesLinha,
+ consulta: async () => ({
+ cod: codigosResposta.erroConhecido,
+ eCerto: false,
+ eErro: true,
+ mensagem: "Falha ao buscar dados",
+ valor: undefined,
+ }),
+ }
+
+ return {
+ tabelaOk,
+ tabelaVazia,
+ tabelaErro,
+ versaoTabelaOk,
+ mostrarModalCadastro,
+ salvando,
+ novoCadastro,
+ salvarNovoCadastro,
+ erroCadastro,
+ selecionados,
+ }
+}
diff --git a/src/playground/useTabelaPlaygroundConfig.ts b/src/playground/useTabelaPlaygroundConfig.ts
new file mode 100644
index 0000000..8ebc242
--- /dev/null
+++ b/src/playground/useTabelaPlaygroundConfig.ts
@@ -0,0 +1,190 @@
+/**
+ * useTabelaPlaygroundConfig
+ * Definições de colunas, filtros avançados, ações por linha e função consultar do playground.
+ * Extraído de useTabelaPlayground.ts para manter cada arquivo abaixo de 250 linhas.
+ *
+ * @see useTabelaPlayground.ts — composable que consome estas configurações
+ */
+import { BadgeCheck, Eye, Pencil, Trash2 } from "@lucide/vue"
+import { iniciarFiltroDelta } from "p-comuns"
+
+import type { ComponenteEntrada } from "@/componentes/EliEntrada/tiposEntradas"
+import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela"
+import { celulaTabela } from "@/componentes/EliTabela"
+
+import type { tipoEmpreendedores } from "./api_simulador/dados"
+
+const { criarFiltroDelta } = iniciarFiltroDelta>()
+
+/** Ações por linha usadas nas 3 instâncias de tabela do playground */
+export function criarAcoesLinha(
+ removerCadastro: (linha: tipoEmpreendedores) => void,
+): tipoEliTabelaConsulta["acoesLinha"] {
+ return [
+ {
+ icone: Eye,
+ cor: "#2563eb",
+ rotulo: "Detalhes",
+ acao: (linha) => {
+ console.log("Visualizar detalhes de", linha.empreendedor)
+ },
+ },
+ {
+ icone: Trash2,
+ cor: "#dc2626",
+ rotulo: "Remover",
+ acao: (linha) => {
+ removerCadastro(linha)
+ },
+ exibir: (linha) => !linha.empreendimento.toLowerCase().includes("café"),
+ },
+ ]
+}
+
+/** Colunas da tabela de empreendedores */
+export const colunasTabela: tipoEliTabelaConsulta["colunas"] = [
+ {
+ rotulo: "Empreendedor",
+ celula: (l) => celulaTabela("textoSimples", { texto: l.empreendedor }),
+ coluna_ordem: "empreendedor",
+ visivel: true,
+ },
+ {
+ rotulo: "Empreendimento",
+ celula: (l) => celulaTabela("textoSimples", { texto: l.empreendimento }),
+ coluna_ordem: "empreendimento",
+ visivel: true,
+ },
+ {
+ rotulo: "Documento",
+ celula: (l) => celulaTabela("textoSimples", { texto: l.documento }),
+ coluna_ordem: "documento",
+ visivel: false,
+ },
+ {
+ rotulo: "E-mail",
+ celula: (l) =>
+ celulaTabela("textoTruncado", {
+ texto: l.email,
+ acao: () => {
+ alert(`Clicou em ${l.email}`)
+ },
+ }),
+ coluna_ordem: "email",
+ visivel: true,
+ },
+ {
+ rotulo: "Telefone",
+ celula: (l) => celulaTabela("textoSimples", { texto: l.telefone }),
+ coluna_ordem: "telefone",
+ visivel: true,
+ },
+ {
+ rotulo: "Faturamento",
+ celula: (l) =>
+ celulaTabela("numero", {
+ numero: l.faturamento,
+ prefixo: "R$",
+ acao: () => alert(`Faturamento de ${l.empreendedor}: R$ ${l.faturamento}`),
+ }),
+ visivel: true,
+ },
+ {
+ rotulo: "Peso",
+ celula: (l) => celulaTabela("numero", { numero: l.peso_kg, sufixo: "kg" }),
+ visivel: false,
+ },
+ {
+ rotulo: "Status",
+ celula: (l) =>
+ celulaTabela("tags", {
+ opcoes: [
+ { rotulo: "Ativo", cor: "success", icone: BadgeCheck },
+ {
+ rotulo: "Editar",
+ cor: "primary",
+ icone: Pencil,
+ acao: () => alert(`Editar ${l.empreendedor}`),
+ },
+ ],
+ }),
+ visivel: true,
+ },
+ {
+ rotulo: "Criado em",
+ celula: (l) => celulaTabela("data", { valor: l.criado_em, formato: "data" }),
+ visivel: false,
+ },
+ {
+ rotulo: "Atualizado",
+ celula: (l) =>
+ celulaTabela("data", {
+ valor: l.atualizado_em,
+ formato: "relativo",
+ acao: () => alert(`Atualizado em: ${l.atualizado_em}`),
+ }),
+ visivel: true,
+ },
+]
+
+/** Filtros avançados da tabela de empreendedores */
+export const filtrosAvancados: tipoEliTabelaConsulta["filtroAvancado"] = [
+ {
+ chave: "empreendedor",
+ rotulo: "Empreendedor",
+ filtro: (valor: unknown) => {
+ if (!valor) return undefined
+ return criarFiltroDelta({
+ condicoes: [
+ { id: "{c1}", coluna: "empreendedor", operador: "like", valor: valor as string },
+ ],
+ formula: "{c1}",
+ })
+ },
+ entrada: ["texto", { rotulo: "Empreendedor" }] as ComponenteEntrada,
+ },
+ {
+ chave: "empreendimento",
+ rotulo: "Empreendimento",
+ filtro: (valor: unknown) => {
+ if (!valor) return undefined
+ return criarFiltroDelta({
+ condicoes: [
+ { id: "{c1}", coluna: "empreendimento", operador: "like", valor: valor as string },
+ ],
+ formula: "{c1}",
+ })
+ },
+ entrada: ["texto", { rotulo: "Empreendimento" }] as ComponenteEntrada,
+ },
+ {
+ chave: "documento",
+ rotulo: "Documento",
+ filtro: (valor: unknown) => {
+ if (!valor) return undefined
+ return criarFiltroDelta({
+ condicoes: [{ id: "{c1}", coluna: "documento", operador: "like", valor: valor as string }],
+ formula: "{c1}",
+ })
+ },
+ entrada: ["texto", { rotulo: "Documento", formato: "cpfCnpj" }] as ComponenteEntrada,
+ },
+ {
+ chave: "email",
+ rotulo: "E-mail",
+ filtro: (valor: unknown) => {
+ if (!valor) return undefined
+ return criarFiltroDelta({
+ condicoes: [{ id: "{c1}", coluna: "email", operador: "like", valor: valor as string }],
+ formula: "{c1}",
+ })
+ },
+ entrada: ["texto", { rotulo: "E-mail", formato: "email" }] as ComponenteEntrada,
+ },
+]
+
+/**
+ * Executa a consulta na ApiSimulacao, traduzindo busca textual e filtros avançados para FiltroDelta.
+ * @param parametrosConsulta - Parâmetros vindos do EliTabela (offSet, limit, filtros, texto_busca)
+ * @param adicionarLog - Callback para registrar no painel de debug do playground
+ */
diff --git a/src/playground/useTabelaPlaygroundConsultar.ts b/src/playground/useTabelaPlaygroundConsultar.ts
new file mode 100644
index 0000000..9849d9d
--- /dev/null
+++ b/src/playground/useTabelaPlaygroundConsultar.ts
@@ -0,0 +1,102 @@
+/**
+ * consultarPlayground
+ * Executa a consulta na ApiSimulacao traduzindo busca textual e filtros avançados para FiltroDelta.
+ * Extraído de useTabelaPlaygroundConfig.ts para manter cada arquivo abaixo de 250 linhas.
+ *
+ * @see useTabelaPlaygroundConfig.ts — definições de colunas e filtros
+ * @see useTabelaPlayground.ts — composable que usa este consultar
+ */
+import { iniciarFiltroDelta } from "p-comuns"
+import { codigosResposta } from "p-respostas"
+import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela"
+import { ApiSimulacao } from "./api_simulador"
+import type { tipoEmpreendedores } from "./api_simulador/dados"
+
+const { criarFiltroDelta } = iniciarFiltroDelta>()
+
+export async function consultar(
+ parametrosConsulta: Parameters["consulta"]>[0],
+ adicionarLog: (acao: string, req: unknown, res: unknown) => void,
+): ReturnType["consulta"]> {
+ try {
+ // biome-ignore lint/suspicious/noExplicitAny: playground — mock de API com estrutura dinâmica
+ const req: any = { ...(parametrosConsulta || {}) }
+ if (req.texto_busca) {
+ const termo = `${req.texto_busca}`
+ const fBusca = criarFiltroDelta({
+ condicoes: [
+ { id: "{c1}", coluna: "empreendedor", operador: "like", valor: termo },
+ { id: "{c2}", coluna: "empreendimento", operador: "like", valor: termo },
+ ],
+ formula: "{c1} OU {c2}",
+ })
+ if (req.filtros) {
+ const fExistente = req.filtros
+ const condicoesBusca = fBusca.condicoes.map(
+ (c: { id: string; coluna: string; operador: string; valor: unknown }) => ({
+ ...c,
+ id: c.id.replace(/[{}]/g, (m: string) => (m === "{" ? "{busca_" : "}")),
+ }),
+ )
+ const formulaBusca = fBusca.formula.replace(/\{(\w+)\}/g, "{busca_$1}")
+ req.filtros = criarFiltroDelta({
+ condicoes: [...fExistente.condicoes, ...condicoesBusca],
+ formula: `(${fExistente.formula}) E (${formulaBusca})`,
+ })
+ } else {
+ req.filtros = fBusca
+ }
+ delete req.texto_busca
+ } else if (Array.isArray(req.filtros)) {
+ const todos = req.filtros as ReturnType[]
+ if (todos.length === 1) {
+ req.filtros = todos[0]
+ } else if (todos.length > 1) {
+ const condicoesFinais: unknown[] = []
+ const partes: string[] = []
+ for (let i = 0; i < todos.length; i++) {
+ const f = todos[i]
+ const prefixo = `f${i}_`
+ const conds = f.condicoes.map(
+ (c: { id: string; coluna: string; operador: string; valor: unknown }) => ({
+ ...c,
+ id: c.id.replace(/\{(\w+)\}/, `{${prefixo}$1}`),
+ }),
+ )
+ const formula = f.formula.replace(/\{(\w+)\}/g, `{${prefixo}$1}`)
+ condicoesFinais.push(...conds)
+ partes.push(`(${formula})`)
+ }
+ req.filtros = criarFiltroDelta({
+ condicoes: condicoesFinais as ReturnType["condicoes"],
+ formula: partes.join(" E "),
+ })
+ } else {
+ delete req.filtros
+ }
+ }
+ const resposta = await ApiSimulacao("listar", req, (r, res) => {
+ console.groupCollapsed("🌐 [ApiSimulacao] listar")
+ console.info("Req:", r)
+ console.info("Res:", res)
+ console.groupEnd()
+ adicionarLog("listar", r, res)
+ })
+ if (resposta.eErro) throw new Error(resposta.mensagem)
+ return {
+ cod: codigosResposta.sucesso,
+ eCerto: true,
+ eErro: false,
+ mensagem: undefined,
+ valor: { quantidade: resposta.valor.total, valores: resposta.valor.dados },
+ }
+ } catch (error) {
+ return {
+ cod: codigosResposta.erroConhecido,
+ eCerto: false,
+ eErro: true,
+ mensagem: String(error),
+ valor: undefined,
+ }
+ }
+}
diff --git a/src/tipos/indicador.ts b/src/tipos/indicador.ts
index c9660a6..cef88df 100644
--- a/src/tipos/indicador.ts
+++ b/src/tipos/indicador.ts
@@ -12,16 +12,7 @@ export type IndicadorLocalizacao =
| "left center"
| "bottom left"
-export type IndicadorOffset =
- | "-20"
- | "-15"
- | "-10"
- | "-5"
- | "0"
- | "20"
- | "15"
- | "10"
- | "5"
+export type IndicadorOffset = "-20" | "-15" | "-10" | "-5" | "0" | "20" | "15" | "10" | "5"
export type IndicadorPresetRaio = "suave" | "pill"