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

@ -1,673 +0,0 @@
<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>