reorganização de arquivos
This commit is contained in:
parent
317b0b3b3e
commit
fa1f93aedc
23 changed files with 3 additions and 3 deletions
372
src/componentes/EliTabela/EliTabela.css
Normal file
372
src/componentes/EliTabela/EliTabela.css
Normal 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;
|
||||
}
|
||||
}
|
||||
673
src/componentes/EliTabela/EliTabela.vue
Normal file
673
src/componentes/EliTabela/EliTabela.vue
Normal 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>
|
||||
140
src/componentes/EliTabela/EliTabelaBody.vue
Normal file
140
src/componentes/EliTabela/EliTabelaBody.vue
Normal 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>
|
||||
103
src/componentes/EliTabela/EliTabelaCabecalho.vue
Normal file
103
src/componentes/EliTabela/EliTabelaCabecalho.vue
Normal 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>
|
||||
137
src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue
Normal file
137
src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue
Normal 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>
|
||||
33
src/componentes/EliTabela/EliTabelaDebug.vue
Normal file
33
src/componentes/EliTabela/EliTabelaDebug.vue
Normal 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>
|
||||
35
src/componentes/EliTabela/EliTabelaDetalhesLinha.vue
Normal file
35
src/componentes/EliTabela/EliTabelaDetalhesLinha.vue
Normal 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>
|
||||
40
src/componentes/EliTabela/EliTabelaEstados.vue
Normal file
40
src/componentes/EliTabela/EliTabelaEstados.vue
Normal 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>
|
||||
103
src/componentes/EliTabela/EliTabelaHead.vue
Normal file
103
src/componentes/EliTabela/EliTabelaHead.vue
Normal 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>
|
||||
95
src/componentes/EliTabela/EliTabelaMenuAcoes.vue
Normal file
95
src/componentes/EliTabela/EliTabelaMenuAcoes.vue
Normal 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>
|
||||
408
src/componentes/EliTabela/EliTabelaModalColunas.vue
Normal file
408
src/componentes/EliTabela/EliTabelaModalColunas.vue
Normal 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>
|
||||
244
src/componentes/EliTabela/EliTabelaPaginacao.vue
Normal file
244
src/componentes/EliTabela/EliTabelaPaginacao.vue
Normal 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>
|
||||
39
src/componentes/EliTabela/celulas/EliTabelaCelula.vue
Normal file
39
src/componentes/EliTabela/celulas/EliTabelaCelula.vue
Normal 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>
|
||||
59
src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue
Normal file
59
src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
12
src/componentes/EliTabela/celulas/registryTabelaCelulas.ts
Normal file
12
src/componentes/EliTabela/celulas/registryTabelaCelulas.ts
Normal 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>;
|
||||
20
src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts
Normal file
20
src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts
Normal 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;
|
||||
49
src/componentes/EliTabela/colunasStorage.ts
Normal file
49
src/componentes/EliTabela/colunasStorage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
7
src/componentes/EliTabela/index.ts
Normal file
7
src/componentes/EliTabela/index.ts
Normal 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";
|
||||
121
src/componentes/EliTabela/types-eli-tabela.ts
Normal file
121
src/componentes/EliTabela/types-eli-tabela.ts
Normal 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 já 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;
|
||||
}[];
|
||||
};
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue