reorganização de arquivos

This commit is contained in:
Luiz Silva 2026-01-29 09:21:31 -03:00
parent 317b0b3b3e
commit fa1f93aedc
23 changed files with 3 additions and 3 deletions

View file

@ -0,0 +1,372 @@
.eli-tabela {
width: 100%;
}
.eli-tabela__table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px;
overflow: visible;
}
.eli-tabela__tbody {
overflow: visible;
}
.eli-tabela__tbody .eli-tabela__tr--zebra .eli-tabela__td {
background: rgba(15, 23, 42, 0.02);
}
.eli-tabela__th,
.eli-tabela__td {
padding: 10px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
vertical-align: top;
}
.eli-tabela__th {
text-align: left;
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
}
.eli-tabela__th--ordenavel {
padding: 0;
}
.eli-tabela__th--ordenavel .eli-tabela__th-botao {
padding: 10px 12px;
}
.eli-tabela__th-botao {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
background: transparent;
border: none;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
transition: color 0.2s ease;
}
.eli-tabela__th-botao:hover,
.eli-tabela__th-botao:focus-visible {
color: rgba(15, 23, 42, 0.85);
}
.eli-tabela__th-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__th-botao--ativo {
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__th-texto {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.eli-tabela__th-icone {
flex-shrink: 0;
}
.eli-tabela__th-icone--oculto {
opacity: 0;
}
.eli-tabela__tr:last-child .eli-tabela__td {
border-bottom: none;
}
.eli-tabela__td--clicavel {
cursor: pointer;
}
.eli-tabela__td--clicavel:hover {
background: rgba(0, 0, 0, 0.03);
}
.eli-tabela--erro {
border: 1px solid rgba(220, 53, 69, 0.35);
border-radius: 12px;
padding: 12px;
}
.eli-tabela--carregando {
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__erro-titulo {
font-weight: 700;
margin-bottom: 4px;
}
.eli-tabela__erro-mensagem {
opacity: 0.9;
}
.eli-tabela--vazio {
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__th--acoes {
text-align: right;
white-space: nowrap;
}
.eli-tabela__td--acoes {
white-space: nowrap;
overflow: visible;
}
.eli-tabela__acoes-container {
display: flex;
justify-content: flex-end;
position: relative;
z-index: 1;
}
.eli-tabela__acoes-container--aberto {
z-index: 200;
}
.eli-tabela__cabecalho {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
/* Altura padrão para controles do cabeçalho (busca e botões)
* - Mantém alinhamento visual entre input de busca e ações
* - Pode ser sobrescrita via CSS no consumidor, se necessário
*/
--eli-tabela-cabecalho-controle-altura: 34px;
/* Borda dos controles do cabeçalho: menos "pílula", mais quadrado.
* Ajuste fino via CSS no consumidor, se necessário.
*/
--eli-tabela-cabecalho-controle-radius: 8px;
}
.eli-tabela__acoes-cabecalho {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
}
.eli-tabela__acoes-cabecalho-botao {
display: inline-flex;
align-items: center;
gap: 6px;
height: var(--eli-tabela-cabecalho-controle-altura);
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);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acoes-cabecalho-botao:hover,
.eli-tabela__acoes-cabecalho-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
}
.eli-tabela__acoes-cabecalho-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
.eli-tabela__acoes-cabecalho-icone {
display: inline-block;
}
.eli-tabela__acoes-cabecalho-rotulo {
line-height: 1;
}
.eli-tabela__acoes-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9999px;
border: none;
background: transparent;
color: rgba(15, 23, 42, 0.72);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acoes-toggle-icone {
display: block;
}
.eli-tabela__acoes-toggle:hover,
.eli-tabela__acoes-toggle:focus-visible {
background-color: rgba(15, 23, 42, 0.08);
color: rgba(15, 23, 42, 0.95);
}
.eli-tabela__acoes-toggle:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__acoes-toggle:disabled {
cursor: default;
color: rgba(148, 163, 184, 0.8);
background: transparent;
}
.eli-tabela__acoes-menu {
min-width: 180px;
padding: 6px 0;
margin: 0;
list-style: none;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
z-index: 1000;
}
.eli-tabela__acoes-item {
margin: 0;
}
.eli-tabela__acoes-item-botao {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.eli-tabela__acoes-item-botao:hover,
.eli-tabela__acoes-item-botao:focus-visible {
background-color: rgba(15, 23, 42, 0.06);
}
.eli-tabela__acoes-item-botao:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.eli-tabela__acoes-item-icone {
flex-shrink: 0;
}
.eli-tabela__acoes-item-texto {
flex: 1;
text-align: left;
}
/* =========================
* Expander (colunas invisíveis)
* ========================= */
.eli-tabela__th--expander,
.eli-tabela__td--expander {
width: 42px;
padding: 6px 8px;
text-align: center;
vertical-align: middle;
}
.eli-tabela__expander-botao {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 9999px;
border: none;
background: transparent;
color: rgba(15, 23, 42, 0.72);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__expander-botao:hover,
.eli-tabela__expander-botao:focus-visible {
background-color: rgba(15, 23, 42, 0.08);
color: rgba(15, 23, 42, 0.95);
}
.eli-tabela__expander-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__expander-botao--ativo {
background-color: rgba(15, 23, 42, 0.06);
color: rgba(15, 23, 42, 0.95);
}
.eli-tabela__td--detalhes {
padding: 12px;
}
.eli-tabela__tr--detalhes .eli-tabela__td {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.eli-tabela__detalhes {
display: grid;
gap: 10px;
padding-left: 4px;
}
.eli-tabela__detalhe {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
align-items: start;
}
.eli-tabela__detalhe-rotulo {
font-weight: 600;
color: rgba(15, 23, 42, 0.85);
}
.eli-tabela__detalhe-valor {
min-width: 0;
}
@media (max-width: 720px) {
.eli-tabela__detalhe {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,673 @@
<template>
<div class="eli-tabela">
<EliTabelaDebug :isDev="isDev" :menuAberto="menuAberto" :menuPopupPos="menuPopupPos" />
<EliTabelaEstados
v-if="carregando || Boolean(erro) || !linhas.length"
:carregando="carregando"
:erro="erro"
:mensagemVazio="tabela.mensagemVazio"
/>
<template v-else>
<EliTabelaCabecalho
v-if="exibirBusca || temAcoesCabecalho"
:exibirBusca="exibirBusca"
:valorBusca="valorBusca"
:acoesCabecalho="acoesCabecalho"
@buscar="atualizarBusca"
@colunas="abrirModalColunas"
/>
<EliTabelaModalColunas
:aberto="modalColunasAberto"
:rotulosColunas="rotulosColunas"
:configInicial="configColunas"
:colunas="tabela.colunas"
@fechar="fecharModalColunas"
@salvar="salvarModalColunas"
/>
<table class="eli-tabela__table">
<EliTabelaHead
:colunas="colunasEfetivas"
:temAcoes="temAcoes"
:temColunasInvisiveis="temColunasInvisiveis"
:colunaOrdenacao="colunaOrdenacao"
:direcaoOrdenacao="direcaoOrdenacao"
@alternar-ordenacao="alternarOrdenacao"
/>
<EliTabelaBody
:colunas="colunasEfetivas"
:colunasInvisiveis="colunasInvisiveisEfetivas"
:temColunasInvisiveis="temColunasInvisiveis"
:linhasExpandidas="linhasExpandidas"
:linhas="linhas"
:temAcoes="temAcoes"
:menuAberto="menuAberto"
:possuiAcoes="possuiAcoes"
:toggleMenu="toggleMenu"
:alternarLinhaExpandida="alternarLinhaExpandida"
/>
</table>
<EliTabelaMenuAcoes
ref="menuPopup"
:menuAberto="menuAberto"
:posicao="menuPopupPos"
:acoes="menuAberto === null ? [] : acoesDisponiveisPorLinha(menuAberto)"
:linha="menuAberto === null ? null : linhas[menuAberto]"
@executar="({ acao, linha }) => { menuAberto = null; acao.acao(linha as never); }"
/>
<EliTabelaPaginacao
v-if="totalPaginas > 1 && quantidade > 0"
:pagina="paginaAtual"
:totalPaginas="totalPaginas"
:maximoBotoes="tabela.maximo_botoes_paginacao"
@alterar="irParaPagina"
/>
</template>
</div>
</template>
<script lang="ts">
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Dependências do Vue (Composition API) */
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
PropType,
ref,
watch,
} from "vue";
/** Enum de códigos de resposta utilizado na consulta */
import { codigosResposta } from "p-respostas";
/** Componentes auxiliares */
import EliTabelaCabecalho from "./EliTabelaCabecalho.vue";
import EliTabelaEstados from "./EliTabelaEstados.vue";
import EliTabelaDebug from "./EliTabelaDebug.vue";
import EliTabelaHead from "./EliTabelaHead.vue";
import EliTabelaBody from "./EliTabelaBody.vue";
import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue";
import EliTabelaPaginacao from "./EliTabelaPaginacao.vue";
import EliTabelaModalColunas from "./EliTabelaModalColunas.vue";
import type { EliColuna } from "./types-eli-tabela";
/** Tipos da configuração/contrato da tabela */
import type { EliTabelaConsulta } from "./types-eli-tabela";
import {
carregarConfigColunas,
salvarConfigColunas,
type EliTabelaColunasConfig,
} from "./colunasStorage";
export default defineComponent({
name: "EliTabela",
inheritAttrs: false,
components: {
EliTabelaCabecalho,
EliTabelaEstados,
EliTabelaDebug,
EliTabelaHead,
EliTabelaBody,
EliTabelaMenuAcoes,
EliTabelaPaginacao,
EliTabelaModalColunas,
},
props: {
/** Configuração principal da tabela (colunas, consulta e ações) */
tabela: {
type: Object as PropType<EliTabelaConsulta<any>>,
required: true,
},
},
setup(props) {
/** 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");
/** Alias reativo da prop tabela */
const tabela = computed(() => props.tabela);
/** Exibição da busca e ações do cabeçalho */
const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca));
const acoesCabecalho = computed(() => props.tabela.acoesTabela ?? []);
const temAcoesCabecalho = computed(() => acoesCabecalho.value.length > 0);
/** 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(() => {
const colunas = props.tabela.colunas as Array<EliColuna<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;
const mapa = new Map<string, EliColuna<any>>();
for (const c of base) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c);
}
const ordenadas: Array<EliColuna<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 ?? []
: (props.tabela.colunas as Array<EliColuna<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.
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);
});
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 na quantidade */
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value;
if (!limite || limite <= 0) return 1;
const total = quantidade.value;
if (!total) return 1;
return Math.max(1, Math.ceil(total / limite));
});
/** Indica se existem ações por linha */
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).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 && 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;
acoesVisiveis.value = [];
menuAberto.value = null;
linhasExpandidas.value = {};
const limite = Math.max(1, registrosPorConsulta.value);
const offset = (paginaAtual.value - 1) * limite;
const parametrosConsulta: {
coluna_ordem?: never;
direcao_ordem?: "asc" | "desc";
offSet: number;
limit: number;
texto_busca?: string;
} = {
offSet: offset,
limit: limite,
};
if (valorBusca.value) {
parametrosConsulta.texto_busca = valorBusca.value;
}
if (colunaOrdenacao.value) {
parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never;
parametrosConsulta.direcao_ordem = direcaoOrdenacao.value;
}
try {
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 ?? [];
const total = res.valor?.quantidade ?? valores.length;
linhas.value = valores;
quantidade.value = total;
const totalPaginasRecalculado = Math.max(1, Math.ceil((total || 0) / limite));
if (paginaAtual.value > totalPaginasRecalculado) {
paginaAtual.value = totalPaginasRecalculado;
return;
}
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) {
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;
configColunas.value = carregarConfigColunas(props.tabela.nome);
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,
quantidade,
menuAberto,
valorBusca,
paginaAtual,
colunaOrdenacao,
direcaoOrdenacao,
totalPaginas,
// computed
exibirBusca,
acoesCabecalho,
temAcoesCabecalho,
temAcoes,
colunasEfetivas,
rotulosColunas,
modalColunasAberto,
configColunas,
temColunasInvisiveis,
colunasInvisiveisEfetivas,
linhasExpandidas,
abrirModalColunas,
fecharModalColunas,
salvarModalColunas,
alternarLinhaExpandida,
// actions
alternarOrdenacao,
atualizarBusca,
irParaPagina,
acoesDisponiveisPorLinha,
possuiAcoes,
toggleMenu,
// popup
menuPopup,
menuPopupPos,
};
},
});
</script>
<style src="./EliTabela.css"></style>

View file

@ -0,0 +1,140 @@
<template>
<tbody class="eli-tabela__tbody">
<template v-for="(linha, i) in linhas" :key="`grp-${i}`">
<tr
class="eli-tabela__tr"
:class="[i % 2 === 1 ? 'eli-tabela__tr--zebra' : undefined]"
>
<td v-if="temColunasInvisiveis" class="eli-tabela__td eli-tabela__td--expander" :key="`td-${i}-exp`">
<button
type="button"
class="eli-tabela__expander-botao"
:class="[linhasExpandidas?.[i] ? 'eli-tabela__expander-botao--ativo' : undefined]"
:aria-expanded="linhasExpandidas?.[i] ? 'true' : 'false'"
:aria-label="linhasExpandidas?.[i] ? 'Ocultar colunas ocultas' : 'Mostrar colunas ocultas'"
:title="linhasExpandidas?.[i] ? 'Ocultar detalhes' : 'Mostrar detalhes'"
@click.stop="alternarLinhaExpandida(i)"
>
<component
:is="linhasExpandidas?.[i] ? ChevronDown : ChevronRight"
class="eli-tabela__expander-icone"
:size="16"
:stroke-width="2"
aria-hidden="true"
/>
</button>
</td>
<td
v-for="(coluna, j) in colunas"
:key="`td-${i}-${j}`"
class="eli-tabela__td"
>
<EliTabelaCelula :celula="(coluna.celula(linha as never) as never)" />
</td>
<td v-if="temAcoes" class="eli-tabela__td eli-tabela__td--acoes" :key="`td-${i}-acoes`">
<div
class="eli-tabela__acoes-container"
:class="[menuAberto === i ? 'eli-tabela__acoes-container--aberto' : undefined]"
>
<button
class="eli-tabela__acoes-toggle"
type="button"
:id="`eli-tabela-acoes-toggle-${i}`"
:disabled="!possuiAcoes(i)"
:aria-haspopup="'menu'"
:aria-expanded="menuAberto === i ? 'true' : 'false'"
:aria-controls="possuiAcoes(i) ? `eli-tabela-acoes-menu-${i}` : undefined"
:aria-label="possuiAcoes(i) ? 'Ações da linha' : 'Nenhuma ação disponível'"
:title="possuiAcoes(i) ? 'Ações' : 'Nenhuma ação disponível'"
@click.stop="toggleMenu(i, $event)"
>
<MoreVertical class="eli-tabela__acoes-toggle-icone" :size="18" :stroke-width="2" />
</button>
</div>
</td>
</tr>
<tr
v-if="temColunasInvisiveis && Boolean(linhasExpandidas?.[i])"
class="eli-tabela__tr eli-tabela__tr--detalhes"
:class="[i % 2 === 1 ? 'eli-tabela__tr--zebra' : undefined]"
>
<td
class="eli-tabela__td eli-tabela__td--detalhes"
:colspan="(temColunasInvisiveis ? 1 : 0) + colunas.length + (temAcoes ? 1 : 0)"
>
<EliTabelaDetalhesLinha :linha="linha" :colunasInvisiveis="colunasInvisiveis" />
</td>
</tr>
</template>
</tbody>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { ChevronDown, ChevronRight, MoreVertical } from "lucide-vue-next";
import EliTabelaCelula from "./celulas/EliTabelaCelula.vue";
import EliTabelaDetalhesLinha from "./EliTabelaDetalhesLinha.vue";
import type { EliColuna } from "./types-eli-tabela";
export default defineComponent({
name: "EliTabelaBody",
components: {
EliTabelaCelula,
EliTabelaDetalhesLinha,
MoreVertical,
ChevronRight,
ChevronDown,
},
props: {
colunas: {
type: Array as PropType<Array<EliColuna<any>>>,
required: true,
},
colunasInvisiveis: {
type: Array as PropType<Array<EliColuna<any>>>,
required: true,
},
temColunasInvisiveis: {
type: Boolean,
required: true,
},
linhasExpandidas: {
type: Object as PropType<Record<number, boolean>>,
required: true,
},
linhas: {
type: Array as PropType<Array<unknown>>,
required: true,
},
temAcoes: {
type: Boolean,
required: true,
},
menuAberto: {
type: Number as PropType<number | null>,
required: true,
},
possuiAcoes: {
type: Function as PropType<(i: number) => boolean>,
required: true,
},
toggleMenu: {
type: Function as PropType<(indice: number, evento: MouseEvent) => void>,
required: true,
},
alternarLinhaExpandida: {
type: Function as PropType<(indice: number) => void>,
required: true,
},
},
setup() {
return {
ChevronRight,
ChevronDown,
};
},
});
</script>

View file

@ -0,0 +1,103 @@
<template>
<div class="eli-tabela__cabecalho">
<!-- Grupo de busca: botão de colunas (à esquerda) + input de busca -->
<div v-if="exibirBusca" class="eli-tabela__busca-grupo">
<button
v-if="exibirBotaoColunas"
type="button"
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas"
@click="emitColunas"
>
Colunas
</button>
<EliTabelaCaixaDeBusca :modelo="valorBusca" @buscar="emitBuscar" />
</div>
<!-- Ações do cabeçalho: ações globais da tabela -->
<div v-if="temAcoesCabecalho" class="eli-tabela__acoes-cabecalho">
<button
v-for="(botao, indice) in acoesCabecalho"
:key="`${botao.rotulo}-${indice}`"
type="button"
class="eli-tabela__acoes-cabecalho-botao"
:style="botao.cor ? { backgroundColor: botao.cor, color: '#fff' } : undefined"
@click="botao.acao"
>
<component
v-if="botao.icone"
:is="botao.icone"
class="eli-tabela__acoes-cabecalho-icone"
:size="16"
:stroke-width="2"
/>
<span class="eli-tabela__acoes-cabecalho-rotulo">{{ botao.rotulo }}</span>
</button>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, PropType } from "vue";
import EliTabelaCaixaDeBusca from "./EliTabelaCaixaDeBusca.vue";
export default defineComponent({
name: "EliTabelaCabecalho",
components: { EliTabelaCaixaDeBusca },
props: {
exibirBusca: {
type: Boolean,
required: true,
},
exibirBotaoColunas: {
type: Boolean,
required: false,
default: true,
},
valorBusca: {
type: String,
required: true,
},
acoesCabecalho: {
type: Array as PropType<
Array<{
icone?: any;
cor?: string;
rotulo: string;
acao: () => void;
}>
>,
required: true,
},
},
emits: {
buscar(valor: string) {
return typeof valor === "string";
},
colunas() {
return true;
},
},
setup(props, { emit }) {
const temAcoesCabecalho = computed(() => props.acoesCabecalho.length > 0);
function emitBuscar(texto: string) {
emit("buscar", texto);
}
function emitColunas() {
emit("colunas");
}
return { temAcoesCabecalho, emitBuscar, emitColunas };
},
});
</script>
<style scoped>
.eli-tabela__busca-grupo {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
</style>

View file

@ -0,0 +1,137 @@
<template>
<div class="eli-tabela__busca">
<div class="eli-tabela__busca-input-wrapper">
<input
id="eli-tabela-busca"
v-model="texto"
type="search"
class="eli-tabela__busca-input"
placeholder="Digite termos para filtrar"
@keyup.enter="emitirBusca"
/>
<button
type="button"
class="eli-tabela__busca-botao"
aria-label="Buscar"
title="Buscar"
@click="emitirBusca"
>
<Search class="eli-tabela__busca-botao-icone" :size="16" :stroke-width="2" aria-hidden="true" />
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from "vue";
import { Search } from "lucide-vue-next";
export default defineComponent({
name: "EliTabelaCaixaDeBusca",
components: { Search },
props: {
modelo: {
type: String,
required: false,
default: "",
},
},
emits: {
buscar(valor: string) {
return typeof valor === "string";
},
},
setup(props, { emit }) {
/**
* Estado local da entrada para que o usuário possa digitar livremente antes
* de disparar uma nova consulta.
*/
const texto = ref(props.modelo ?? "");
watch(
() => props.modelo,
(novo) => {
if (novo !== undefined && novo !== texto.value) {
texto.value = novo;
}
}
);
function emitirBusca() {
emit("buscar", texto.value.trim());
}
return { texto, emitirBusca };
},
});
</script>
<style scoped>
.eli-tabela__busca {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.eli-tabela__busca-input-wrapper {
display: inline-flex;
align-items: stretch;
border-radius: var(--eli-tabela-cabecalho-controle-radius, 8px);
border: 1px solid rgba(15, 23, 42, 0.15);
overflow: hidden;
background: #fff;
/* Herda do container do cabeçalho (.eli-tabela__cabecalho) quando presente. */
height: var(--eli-tabela-cabecalho-controle-altura, 34px);
}
.eli-tabela__busca-input {
/* Mantém o input com a mesma altura do botão e dos botões de ação do cabeçalho. */
height: 100%;
padding: 0 12px;
border: none;
outline: none;
font-size: 0.875rem;
color: rgba(15, 23, 42, 0.85);
}
.eli-tabela__busca-input::-webkit-search-cancel-button,
.eli-tabela__busca-input::-webkit-search-decoration {
-webkit-appearance: none;
}
.eli-tabela__busca-input::placeholder {
color: rgba(107, 114, 128, 0.85);
}
.eli-tabela__busca-botao {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: rgba(37, 99, 235, 0.12);
color: rgba(37, 99, 235, 0.95);
height: 100%;
padding: 0 12px;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__busca-botao-icone {
display: block;
}
.eli-tabela__busca-botao:hover,
.eli-tabela__busca-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
color: rgba(37, 99, 235, 1);
}
.eli-tabela__busca-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<!-- 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;"
>
<div><b>EliTabela debug</b></div>
<div>menuAberto: {{ menuAberto }}</div>
<div>menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
export default defineComponent({
name: "EliTabelaDebug",
props: {
isDev: {
type: Boolean,
required: true,
},
menuAberto: {
type: Number as PropType<number | null>,
required: true,
},
menuPopupPos: {
type: Object as PropType<{ top: number; left: number }>,
required: true,
},
},
});
</script>

View file

@ -0,0 +1,35 @@
<template>
<div class="eli-tabela__detalhes">
<div v-for="(coluna, idx) in colunasInvisiveis" :key="`det-${idx}-${coluna.rotulo}`" class="eli-tabela__detalhe">
<div class="eli-tabela__detalhe-rotulo">{{ coluna.rotulo }}</div>
<div class="eli-tabela__detalhe-valor">
<EliTabelaCelula :celula="(coluna.celula(linha as never) as never)" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import EliTabelaCelula from "./celulas/EliTabelaCelula.vue";
import type { EliColuna } from "./types-eli-tabela";
export default defineComponent({
name: "EliTabelaDetalhesLinha",
components: { EliTabelaCelula },
props: {
linha: {
type: null as unknown as PropType<unknown>,
required: true,
},
colunasInvisiveis: {
type: Array as PropType<Array<EliColuna<any>>>,
required: true,
},
},
});
</script>
<style scoped>
/* estilos base ficam no EliTabela.css (global do componente) */
</style>

View file

@ -0,0 +1,40 @@
<template>
<!-- Estado de carregamento: consulta em andamento -->
<div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true">
Carregando...
</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>
</div>
<!-- Estado vazio: consulta sem registros -->
<div v-else class="eli-tabela eli-tabela--vazio">
{{ mensagemVazio ?? "Nenhum registro encontrado." }}
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
export default defineComponent({
name: "EliTabelaEstados",
props: {
carregando: {
type: Boolean,
required: true,
},
erro: {
type: String as PropType<string | null>,
required: true,
},
mensagemVazio: {
type: String as PropType<string | undefined>,
required: false,
default: undefined,
},
},
});
</script>

View file

@ -0,0 +1,103 @@
<template>
<thead class="eli-tabela__thead">
<tr class="eli-tabela__tr eli-tabela__tr--header">
<th v-if="temColunasInvisiveis" class="eli-tabela__th eli-tabela__th--expander" scope="col"></th>
<th
v-for="(coluna, idx) in colunas"
:key="`th-${idx}`"
class="eli-tabela__th"
:class="[isOrdenavel(coluna) ? 'eli-tabela__th--ordenavel' : undefined]"
scope="col"
>
<button
v-if="isOrdenavel(coluna)"
type="button"
class="eli-tabela__th-botao"
:class="[
colunaOrdenacao === String(coluna.coluna_ordem) ? 'eli-tabela__th-botao--ativo' : undefined,
]"
@click="emitAlternarOrdenacao(String(coluna.coluna_ordem))"
>
<span class="eli-tabela__th-texto">{{ coluna.rotulo }}</span>
<component
v-if="colunaOrdenacao === String(coluna.coluna_ordem)"
:is="direcaoOrdenacao === 'asc' ? ArrowUp : ArrowDown"
class="eli-tabela__th-icone"
:size="16"
:stroke-width="2"
aria-hidden="true"
/>
<ArrowUp
v-else
class="eli-tabela__th-icone eli-tabela__th-icone--oculto"
:size="16"
:stroke-width="2"
aria-hidden="true"
/>
</button>
<span v-else class="eli-tabela__th-label">{{ coluna.rotulo }}</span>
</th>
<th v-if="temAcoes" class="eli-tabela__th eli-tabela__th--acoes" scope="col">
Ações
</th>
</tr>
</thead>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { ArrowDown, ArrowUp } from "lucide-vue-next";
import type { EliColuna } from "./types-eli-tabela";
export default defineComponent({
name: "EliTabelaHead",
components: { ArrowUp, ArrowDown },
props: {
colunas: {
type: Array as PropType<Array<EliColuna<any>>>,
required: true,
},
temAcoes: {
type: Boolean,
required: true,
},
temColunasInvisiveis: {
type: Boolean,
required: true,
},
colunaOrdenacao: {
type: String as PropType<string | null>,
required: true,
},
direcaoOrdenacao: {
type: String as PropType<"asc" | "desc">,
required: true,
},
},
emits: {
alternarOrdenacao(chave: string) {
return typeof chave === "string" && chave.length > 0;
},
},
setup(_props, { emit }) {
function isOrdenavel(coluna: any) {
return coluna?.coluna_ordem !== undefined && coluna?.coluna_ordem !== null;
}
function emitAlternarOrdenacao(chave: string) {
emit("alternarOrdenacao", chave);
}
return {
ArrowUp,
ArrowDown,
isOrdenavel,
emitAlternarOrdenacao,
};
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<Teleport to="body">
<ul
v-if="menuAberto !== null && possuiAcoes"
:id="`eli-tabela-acoes-menu-${menuAberto}`"
ref="menuEl"
class="eli-tabela__acoes-menu"
role="menu"
:aria-labelledby="`eli-tabela-acoes-toggle-${menuAberto}`"
:style="{
position: 'fixed',
top: `${posicao.top}px`,
left: `${posicao.left}px`,
zIndex: 999999,
}"
>
<li
v-for="item in acoes"
:key="`acao-${menuAberto}-${item.indice}`"
class="eli-tabela__acoes-item"
role="none"
>
<button
type="button"
class="eli-tabela__acoes-item-botao"
:style="{ color: item.acao.cor }"
role="menuitem"
:aria-label="item.acao.rotulo"
:title="item.acao.rotulo"
@click.stop="emitExecutar(item)"
>
<component
:is="item.acao.icone"
class="eli-tabela__acoes-item-icone"
:size="16"
:stroke-width="2"
/>
<span class="eli-tabela__acoes-item-texto">{{ item.acao.rotulo }}</span>
</button>
</li>
</ul>
</Teleport>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, ref } from "vue";
import type { EliTabelaAcao } from "./types-eli-tabela";
type ItemAcao<T> = {
acao: EliTabelaAcao<T>;
indice: number;
visivel: boolean;
};
export default defineComponent({
name: "EliTabelaMenuAcoes",
props: {
menuAberto: {
type: Number as PropType<number | null>,
required: true,
},
posicao: {
type: Object as PropType<{ top: number; left: number }>,
required: true,
},
acoes: {
type: Array as PropType<Array<ItemAcao<any>>>,
required: true,
},
linha: {
// Aceita qualquer tipo de linha (objeto, string, etc.) sem validação runtime.
type: null as unknown as PropType<unknown | null>,
required: true,
},
},
emits: {
executar(payload: { acao: EliTabelaAcao<any>; linha: unknown }) {
return payload !== null && typeof payload === "object";
},
},
setup(props, { emit, expose }) {
const menuEl = ref<HTMLElement | null>(null);
expose({ menuEl });
const possuiAcoes = computed(() => props.acoes.length > 0);
function emitExecutar(item: { acao: EliTabelaAcao<any> }) {
if (!props.linha) return;
emit("executar", { acao: item.acao, linha: props.linha });
}
return { menuEl, possuiAcoes, emitExecutar };
},
});
</script>

View file

@ -0,0 +1,408 @@
<template>
<div v-if="aberto" class="eli-tabela-modal-colunas__overlay" role="presentation" @click.self="emitFechar">
<div
class="eli-tabela-modal-colunas__modal"
role="dialog"
aria-modal="true"
aria-label="Configurar colunas"
>
<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">
×
</button>
</header>
<div class="eli-tabela-modal-colunas__conteudo">
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Visíveis</div>
<div
class="eli-tabela-modal-colunas__lista"
@dragover.prevent
@drop="(e) => onDropLista(e, 'visiveis', null)"
>
<div
v-for="(rotulo, idx) in visiveisLocal"
:key="`vis-${rotulo}`"
class="eli-tabela-modal-colunas__item"
draggable="true"
@dragstart="(e) => onDragStart(e, rotulo, 'visiveis', idx)"
@dragover.prevent
@drop="(e) => onDropItem(e, 'visiveis', idx)"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true"></span>
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
</div>
</div>
</div>
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Invisíveis</div>
<div
class="eli-tabela-modal-colunas__lista"
@dragover.prevent
@drop="(e) => onDropLista(e, 'invisiveis', null)"
>
<div
v-for="(rotulo, idx) in invisiveisLocal"
:key="`inv-${rotulo}`"
class="eli-tabela-modal-colunas__item"
draggable="true"
@dragstart="(e) => onDragStart(e, rotulo, 'invisiveis', idx)"
@dragover.prevent
@drop="(e) => onDropItem(e, 'invisiveis', idx)"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true"></span>
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
</div>
</div>
</div>
</div>
<footer class="eli-tabela-modal-colunas__footer">
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--sec" @click="emitFechar">
Cancelar
</button>
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--prim" @click="emitSalvar">
Salvar
</button>
</footer>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch } from "vue";
import type { EliTabelaColunasConfig } from "./colunasStorage";
import type { EliColuna } from "./types-eli-tabela";
type OrigemLista = "visiveis" | "invisiveis";
type DragPayload = {
rotulo: string;
origem: OrigemLista;
index: number;
};
const DRAG_MIME = "application/x-eli-tabela-coluna";
export default defineComponent({
name: "EliTabelaModalColunas",
props: {
aberto: {
type: Boolean,
required: true,
},
rotulosColunas: {
type: Array as PropType<string[]>,
required: true,
},
configInicial: {
type: Object as PropType<EliTabelaColunasConfig>,
required: true,
},
colunas: {
type: Array as PropType<Array<EliColuna<any>>>,
required: true,
},
},
emits: {
fechar() {
return true;
},
salvar(_config: EliTabelaColunasConfig) {
return true;
},
},
setup(props, { emit }) {
const visiveisLocal = ref<string[]>([]);
const invisiveisLocal = ref<string[]>([]);
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 }
);
function emitFechar() {
emit("fechar");
}
function emitSalvar() {
emit("salvar", {
visiveis: [...visiveisLocal.value],
invisiveis: [...invisiveisLocal.value],
});
}
function writeDragData(e: DragEvent, payload: DragPayload) {
try {
e.dataTransfer?.setData(DRAG_MIME, JSON.stringify(payload));
e.dataTransfer?.setData("text/plain", payload.rotulo);
e.dataTransfer!.effectAllowed = "move";
} catch {
// ignore
}
}
function readDragData(e: DragEvent): DragPayload | null {
try {
const raw = e.dataTransfer?.getData(DRAG_MIME);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed.rotulo !== "string" || (parsed.origem !== "visiveis" && parsed.origem !== "invisiveis")) {
return null;
}
return parsed as DragPayload;
} catch {
return null;
}
}
function removerDaOrigem(payload: DragPayload) {
const origemArr = payload.origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value;
const idx = origemArr.indexOf(payload.rotulo);
if (idx >= 0) origemArr.splice(idx, 1);
}
function inserirNoDestino(destino: OrigemLista, rotulo: string, index: number | null) {
const arr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value;
// remove duplicatas
const existing = arr.indexOf(rotulo);
if (existing >= 0) arr.splice(existing, 1);
if (index === null || index < 0 || index > arr.length) {
arr.push(rotulo);
} else {
arr.splice(index, 0, rotulo);
}
}
function onDragStart(e: DragEvent, rotulo: string, origem: OrigemLista, index: number) {
writeDragData(e, { rotulo, origem, index });
}
function onDropItem(e: DragEvent, destino: OrigemLista, index: number) {
const payload = readDragData(e);
if (!payload) return;
// remove da origem e insere no destino na posição do item
removerDaOrigem(payload);
inserirNoDestino(destino, payload.rotulo, index);
// garante que uma coluna não fique nos 2 lados
if (destino === "visiveis") {
const idxInv = invisiveisLocal.value.indexOf(payload.rotulo);
if (idxInv >= 0) invisiveisLocal.value.splice(idxInv, 1);
} else {
const idxVis = visiveisLocal.value.indexOf(payload.rotulo);
if (idxVis >= 0) visiveisLocal.value.splice(idxVis, 1);
}
}
function onDropLista(e: DragEvent, destino: OrigemLista, _index: number | null) {
const payload = readDragData(e);
if (!payload) return;
removerDaOrigem(payload);
inserirNoDestino(destino, payload.rotulo, null);
if (destino === "visiveis") {
const idxInv = invisiveisLocal.value.indexOf(payload.rotulo);
if (idxInv >= 0) invisiveisLocal.value.splice(idxInv, 1);
} else {
const idxVis = visiveisLocal.value.indexOf(payload.rotulo);
if (idxVis >= 0) visiveisLocal.value.splice(idxVis, 1);
}
}
return {
visiveisLocal,
invisiveisLocal,
emitFechar,
emitSalvar,
onDragStart,
onDropItem,
onDropLista,
};
},
});
</script>
<style scoped>
.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);
}
.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;
}
.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-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,244 @@
<template>
<nav
v-if="totalPaginasExibidas > 1"
class="eli-tabela__paginacao"
role="navigation"
aria-label="Paginação de resultados"
>
<button
type="button"
class="eli-tabela__pagina-botao"
:disabled="anteriorDesabilitado"
aria-label="Página anterior"
@click="irParaPagina(paginaAtual - 1)"
>
<<
</button>
<template v-for="(item, index) in botoes" :key="`${item.label}-${index}`">
<span
v-if="item.ehEllipsis"
class="eli-tabela__pagina-ellipsis"
aria-hidden="true"
>
{{ item.label }}
</span>
<button
v-else
type="button"
class="eli-tabela__pagina-botao"
:class="item.ativo ? 'eli-tabela__pagina-botao--ativo' : undefined"
:disabled="item.ativo"
:aria-current="item.ativo ? 'page' : undefined"
:aria-label="`Ir para página ${item.label}`"
@click="irParaPagina(item.pagina)"
>
{{ item.label }}
</button>
</template>
<button
type="button"
class="eli-tabela__pagina-botao"
:disabled="proximaDesabilitada"
aria-label="Próxima página"
@click="irParaPagina(paginaAtual + 1)"
>
>>
</button>
</nav>
</template>
<script lang="ts">
import { computed, defineComponent } from "vue";
export default defineComponent({
name: "EliTabelaPaginacao",
props: {
pagina: {
type: Number,
required: true,
},
totalPaginas: {
type: Number,
required: true,
},
maximoBotoes: {
type: Number,
required: false,
},
},
emits: {
alterar(pagina: number) {
return Number.isFinite(pagina);
},
},
setup(props, { emit }) {
/**
* Define o limite de botões visíveis. Mantemos um mínimo de 7 para garantir
* uma navegação confortável, mesmo quando o consumidor não informa o valor.
*/
const maximoBotoesVisiveis = computed(() => {
const valor = props.maximoBotoes;
if (typeof valor === "number" && valor >= 5) {
return Math.floor(valor);
}
return 7;
});
/**
* Constrói a lista de botões/reticências que serão exibidos na paginação.
* Mantemos sempre a primeira e a última página visíveis, posicionando as
* demais de forma dinâmica ao redor da página atual.
*/
const botoes = computed(() => {
const total = props.totalPaginas;
const atual = props.pagina;
const limite = maximoBotoesVisiveis.value;
const resultado: Array<{
label: string;
pagina?: number;
ativo?: boolean;
ehEllipsis?: boolean;
}> = [];
const adicionarPagina = (pagina: number) => {
resultado.push({
label: String(pagina),
pagina,
ativo: pagina === atual,
});
};
const adicionarReticencias = () => {
resultado.push({ label: "…", ehEllipsis: true });
};
if (total <= limite) {
for (let pagina = 1; pagina <= total; pagina += 1) {
adicionarPagina(pagina);
}
return resultado;
}
const visiveisCentrais = Math.max(3, limite - 2);
let inicio = Math.max(2, atual - Math.floor(visiveisCentrais / 2));
let fim = inicio + visiveisCentrais - 1;
if (fim >= total) {
fim = total - 1;
inicio = fim - visiveisCentrais + 1;
}
adicionarPagina(1);
if (inicio > 2) {
adicionarReticencias();
}
for (let pagina = inicio; pagina <= fim; pagina += 1) {
adicionarPagina(pagina);
}
if (fim < total - 1) {
adicionarReticencias();
}
adicionarPagina(total);
return resultado;
});
/**
* Emite a requisição de mudança de página garantindo que o valor esteja
* dentro dos limites válidos informados pelo componente pai.
*/
function irParaPagina(pagina: number | undefined) {
if (!pagina) {
return;
}
const alvo = Math.min(Math.max(1, pagina), props.totalPaginas);
if (alvo !== props.pagina) {
emit("alterar", alvo);
}
}
const anteriorDesabilitado = computed(() => props.pagina <= 1);
const proximaDesabilitada = computed(() => props.pagina >= props.totalPaginas);
const paginaAtual = computed(() => props.pagina);
const totalPaginasExibidas = computed(() => props.totalPaginas);
return {
botoes,
irParaPagina,
anteriorDesabilitado,
proximaDesabilitada,
paginaAtual,
totalPaginasExibidas,
};
},
});
</script>
<style scoped>
.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;
}
</style>

View file

@ -0,0 +1,39 @@
<template>
<!--
Os componentes de célula possuem props tipadas por `tipo`.
Aqui o TS do template não consegue inferir o narrowing do union do `dados`,
então normalizamos para `unknown` e deixamos a validação de runtime do Vue.
-->
<component :is="Componente" :dados="dadosParaComponente" />
</template>
<script lang="ts">
import type { Component } from "vue";
import { computed, defineComponent, PropType } from "vue";
import type { ComponenteCelula, TipoTabelaCelula, TiposTabelaCelulas } from "../types-eli-tabela";
import { registryTabelaCelulas } from "./registryTabelaCelulas";
export default defineComponent({
name: "EliTabelaCelula",
props: {
celula: {
// `ComponenteCelula` é uma tupla `readonly [tipo, dados]`.
type: Array as unknown as PropType<ComponenteCelula>,
required: true,
},
},
setup(props) {
const tipo = computed(() => props.celula[0] as 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 dadosParaComponente = computed(() => dados.value);
return { Componente, dadosParaComponente };
},
});
</script>

View file

@ -0,0 +1,59 @@
<template>
<button
v-if="dados?.acao"
type="button"
class="eli-tabela__celula-link"
@click.stop.prevent="dados.acao()"
>
{{ String(dados?.numero).replace('.', ',') }}
</button>
<span v-else>{{ String(dados?.numero).replace('.', ',') }}</span>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue"
import type { TiposTabelaCelulas } from "./tiposTabelaCelulas";
export default defineComponent({
name: "EliTabelaCelulaNumero",
components: {},
props: {
dados: {
type: Object as PropType<TiposTabelaCelulas["numero"]>,
},
},
data() {
return {
}
},
methods: {
},
setup({ dados }) {
return { dados }
},
})
</script>
<style scoped>
.eli-tabela__celula-link {
all: unset;
display: inline;
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-underline-offset: 2px;
}
.eli-tabela__celula-link:hover {
color: #1d4ed8;
text-decoration-color: rgba(29, 78, 216, 0.75);
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<button
v-if="dados?.acao"
type="button"
class="eli-tabela__celula-link"
@click.stop.prevent="dados.acao()"
>
{{ dados?.texto }}
</button>
<span v-else>{{ dados?.texto }}</span>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue"
import type { TiposTabelaCelulas } from "./tiposTabelaCelulas";
export default defineComponent({
name: "EliTabelaCelulaTextoSimples",
components: {},
props: {
dados: {
type: Object as PropType<TiposTabelaCelulas["textoSimples"]>,
},
},
data() {
return {
}
},
methods: {
},
setup({ dados }) {
return { dados }
},
})
</script>
<style scoped>
.eli-tabela__celula-link {
all: unset;
display: inline;
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-underline-offset: 2px;
}
.eli-tabela__celula-link:hover {
color: #1d4ed8;
text-decoration-color: rgba(29, 78, 216, 0.75);
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<!-- TODO: Validar de ação está cehgando aqui-->
<button
v-if="dados?.acao"
type="button"
class="eli-tabela__texto-truncado eli-tabela__celula-link"
:title="dados?.texto"
@click.stop.prevent="dados.acao()"
>
{{ dados?.texto }}
</button>
<span v-else class="eli-tabela__texto-truncado" :title="dados?.texto">{{ dados?.texto }}</span>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import type { TiposTabelaCelulas } from "./tiposTabelaCelulas";
export default defineComponent({
name: "EliTabelaCelulaTextoTruncado",
props: {
dados: {
type: Object as PropType<TiposTabelaCelulas["textoTruncado"]>,
},
},
setup({ dados }) {
return { dados };
},
});
</script>
<style scoped>
.eli-tabela__texto-truncado {
display: inline-block;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: top;
}
.eli-tabela__celula-link {
all: unset;
display: inline;
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-underline-offset: 2px;
}
.eli-tabela__celula-link:hover {
color: #1d4ed8;
text-decoration-color: rgba(29, 78, 216, 0.75);
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
border-radius: 4px;
}
</style>

View file

@ -0,0 +1,12 @@
import type { Component } from "vue";
import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue";
import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue";
import EliTabelaCelulaNumero from "./EliTabelaCelulaNumero.vue";
import type { TipoTabelaCelula } from "./tiposTabelaCelulas";
export const registryTabelaCelulas = {
textoSimples: EliTabelaCelulaTextoSimples,
textoTruncado: EliTabelaCelulaTextoTruncado,
numero: EliTabelaCelulaNumero,
} as const satisfies Record<TipoTabelaCelula, Component>;

View file

@ -0,0 +1,20 @@
/**
* Tipagem dos dados de entrada dos componentes de celulas
*/
export type TiposTabelaCelulas = {
textoSimples: {
texto: string;
acao?: () => void;
};
textoTruncado: {
texto: string;
acao?: () => void;
};
numero: {
numero: number;
acao?: () => void;
};
};
export type TipoTabelaCelula = keyof TiposTabelaCelulas;

View file

@ -0,0 +1,49 @@
export type EliTabelaColunasConfig = {
/** Rotulos das colunas visiveis (em ordem). */
visiveis: string[];
/** Rotulos das colunas invisiveis. */
invisiveis: string[];
};
const STORAGE_PREFIX = "eli:tabela";
export function storageKeyColunas(nomeTabela: string) {
return `${STORAGE_PREFIX}:${nomeTabela}:colunas`;
}
function normalizarConfig(valor: unknown): EliTabelaColunasConfig {
if (!valor || typeof valor !== "object") {
return { visiveis: [], invisiveis: [] };
}
const v = valor as any;
const visiveis = Array.isArray(v.visiveis) ? v.visiveis.filter((x: any) => typeof x === "string") : [];
const invisiveis = Array.isArray(v.invisiveis) ? v.invisiveis.filter((x: any) => typeof x === "string") : [];
return { visiveis, invisiveis };
}
export function carregarConfigColunas(nomeTabela: string): EliTabelaColunasConfig {
try {
const raw = window.localStorage.getItem(storageKeyColunas(nomeTabela));
if (!raw) return { visiveis: [], invisiveis: [] };
return normalizarConfig(JSON.parse(raw));
} catch {
return { visiveis: [], invisiveis: [] };
}
}
export function salvarConfigColunas(nomeTabela: string, config: EliTabelaColunasConfig) {
try {
window.localStorage.setItem(storageKeyColunas(nomeTabela), JSON.stringify(normalizarConfig(config)));
} catch {
// ignore
}
}
export function limparConfigColunas(nomeTabela: string) {
try {
window.localStorage.removeItem(storageKeyColunas(nomeTabela));
} catch {
// ignore
}
}

View file

@ -0,0 +1,7 @@
export { default as EliTabela } from "./EliTabela.vue";
export * from "./types-eli-tabela";
export * from "./celulas/tiposTabelaCelulas";
// Helper para construção de células tipadas.
export { celulaTabela } from "./types-eli-tabela";

View file

@ -0,0 +1,121 @@
import type { tipoResposta } from "p-respostas";
import type { LucideIcon } from "lucide-vue-next";
import type { TipoTabelaCelula, TiposTabelaCelulas } from "./celulas/tiposTabelaCelulas";
export type ComponenteCelulaBase<T extends TipoTabelaCelula> =
readonly [T, TiposTabelaCelulas[T]]
export type ComponenteCelula = {
[K in TipoTabelaCelula]: ComponenteCelulaBase<K>
}[TipoTabelaCelula]
export const celulaTabela = <T extends TipoTabelaCelula>(
tipo: T,
dados: TiposTabelaCelulas[T],
): ComponenteCelulaBase<T> => {
return [tipo, dados] as const
}
export type { TipoTabelaCelula, TiposTabelaCelulas };
export type EliColuna<T> = {
/** Texto exibido no cabeçalho da coluna. */
rotulo: string;
/** Função responsável por renderizar o conteúdo da célula. */
celula: (linha: T) => ComponenteCelula;
/** Ação opcional disparada ao clicar na célula. */
/**
* Campo de ordenação associado à coluna. Caso informado, a coluna passa a
* exibir controles de ordenação e utiliza o valor como chave para o backend.
*/
coluna_ordem?: keyof T;
/**
* indica que a coluna será visivel, se false incia em detalhe
* Caso tenha salvo a propriedade de visibilidade será adotado a propriedade salva
*/
visivel: boolean
};
export type EliConsultaPaginada<T> = {
/** Registros retornados na consulta. */
valores: T[];
/** Total de registros disponíveis no backend. */
quantidade: number;
};
export type EliTabelaAcao<T> = {
/** Ícone (Lucide) exibido para representar a ação. */
icone: LucideIcon;
/** Cor aplicada ao ícone e rótulo. */
cor: string;
/** Texto descritivo da ação. */
rotulo: string;
/** Função executada quando o usuário ativa a ação. */
acao: (linha: T) => void;
/**
* Define se a ação deve ser exibida para a linha. Pode ser um booleano fixo
* ou uma função (sincrona/assíncrona) que recebe a linha para decisão dinâmica.
*/
exibir?: boolean | ((linha: T) => Promise<boolean> | boolean);
};
/**
* Estrutura de dados para uma tabela alimentada por uma consulta.
*
* - `colunas`: definição de colunas e como renderizar cada célula
* - `consulta`: função que recupera os dados, com suporte a ordenação/paginação
* - `mostrarCaixaDeBusca`: habilita um campo de busca textual no cabeçalho
*/
export type EliTabelaConsulta<T> = {
/** nome da tabela, um identificador unico */
nome: string
/** Indica se a caixa de busca deve ser exibida acima da tabela. */
mostrarCaixaDeBusca?: boolean;
/** Lista de colunas da tabela. */
colunas: EliColuna<T>[];
/** Quantidade de registros solicitados por consulta (padrão `10`). */
registros_por_consulta?: number;
/**
* Função responsável por buscar os dados. Recebe parâmetros opcionais de
* ordenação (`coluna_ordem`/`direcao_ordem`) e paginação (`offSet`/`limit`).
*/
consulta: (parametrosConsulta?: {
coluna_ordem?: keyof T;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
/** Texto digitado na caixa de busca, quando habilitada. */
texto_busca?: string;
}) => Promise<tipoResposta<EliConsultaPaginada<T>>>;
/** Quantidade máxima de botões exibidos na paginação (padrão `7`). */
maximo_botoes_paginacao?: number;
/** Mensagem exibida quando a consulta retorna ok porém sem dados. */
mensagemVazio?: string;
/** Ações exibidas à direita de cada linha. */
acoesLinha?: EliTabelaAcao<T>[];
/**
* Configurações dos botões que serão inseridos a direita da caixa de busca.
* Seu uso mais comum será para criar novos registros, mas poderá ter outras utilidades.
*/
acoesTabela?: {
/** Ícone (Lucide) exibido no botão */
icone?: LucideIcon;
/** Cor aplicada ao botão. */
cor?: string;
/** Texto descritivo da ação. */
rotulo: string;
/** Função executada ao clicar no botão. */
acao: () => void;
}[];
};