Atualização floresta
This commit is contained in:
parent
0886fd5c3e
commit
2d4d8f8929
103 changed files with 19266 additions and 14160 deletions
|
|
@ -23,7 +23,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { dayjsbr } from "p-comuns"
|
||||
import dayjs from "dayjs"
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
import type { CampoDensidade, CampoVariante } from "../../tipos"
|
||||
import type { PadroesEntradas } from "./tiposEntradas"
|
||||
|
|
@ -122,13 +122,9 @@ export default defineComponent({
|
|||
}
|
||||
})
|
||||
|
||||
const modoEfetivo = computed<"data" | "dataHora">(
|
||||
() => opcoesEfetivas.value.modo ?? "dataHora",
|
||||
)
|
||||
const modoEfetivo = computed<"data" | "dataHora">(() => opcoesEfetivas.value.modo ?? "dataHora")
|
||||
|
||||
const desabilitadoEfetivo = computed<boolean>(() =>
|
||||
Boolean(props.desabilitado),
|
||||
)
|
||||
const desabilitadoEfetivo = computed<boolean>(() => Boolean(props.desabilitado))
|
||||
|
||||
const tipoInput = computed<"date" | "datetime-local">(() =>
|
||||
modoEfetivo.value === "data" ? "date" : "datetime-local",
|
||||
|
|
@ -136,23 +132,21 @@ export default defineComponent({
|
|||
|
||||
function isoParaInputDatetime(valorIso: string): string {
|
||||
if (modoEfetivo.value === "data") {
|
||||
return dayjsbr(valorIso).format("YYYY-MM-DD")
|
||||
return dayjs(valorIso).format("YYYY-MM-DD")
|
||||
}
|
||||
return dayjsbr(valorIso).format("YYYY-MM-DDTHH:mm")
|
||||
return dayjs(valorIso).format("YYYY-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
function inputDatetimeParaIsoLocal(valorInput: string): string {
|
||||
if (modoEfetivo.value === "data") {
|
||||
return dayjsbr(`${valorInput}T00:00`).format()
|
||||
return dayjs(`${valorInput}T00:00`).format()
|
||||
}
|
||||
return dayjsbr(valorInput).format()
|
||||
return dayjs(valorInput).format()
|
||||
}
|
||||
|
||||
const effectiveModelValue = computed<string | null>(() => {
|
||||
// Prioridade: value (novo) se vier definido; senão usa modelValue (antigo)
|
||||
return props.value !== undefined
|
||||
? (props.value ?? null)
|
||||
: props.modelValue
|
||||
return props.value !== undefined ? (props.value ?? null) : props.modelValue
|
||||
})
|
||||
|
||||
const valor = computed<string>({
|
||||
|
|
|
|||
14
src/componentes/EliEntrada/EliEntradaNumero.css
Normal file
14
src/componentes/EliEntrada/EliEntradaNumero.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.eli-entrada__prefixo,
|
||||
.eli-entrada__sufixo {
|
||||
opacity: 0.75;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eli-entrada__prefixo {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.eli-entrada__sufixo {
|
||||
margin-left: 6px;
|
||||
}
|
||||
112
src/componentes/EliEntrada/EliEntradaNumero.helpers.ts
Normal file
112
src/componentes/EliEntrada/EliEntradaNumero.helpers.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Helpers de formatação e parsing numérico usados pelo EliEntradaNumero.
|
||||
* Extraídos do componente Vue para manter o arquivo dentro de 250 linhas.
|
||||
*/
|
||||
|
||||
export function casasDecimaisFromPrecisao(precisao: number): number {
|
||||
if (!Number.isFinite(precisao) || precisao <= 0) return 0
|
||||
if (precisao >= 1) return 0
|
||||
const texto = precisao.toString()
|
||||
if (texto.includes("e-")) {
|
||||
const [, exp] = texto.split("e-")
|
||||
const n = Number(exp)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const idx = texto.indexOf(".")
|
||||
if (idx === -1) return 0
|
||||
const dec = texto.slice(idx + 1).replace(/0+$/, "")
|
||||
return dec.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Analisa a entrada de texto e converte em um valor numérico válido (float).
|
||||
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
|
||||
*
|
||||
* @param texto - O texto bruto digitado ou vindo do estado
|
||||
* @returns O número processado ou `null` caso inválido ou vazio
|
||||
*/
|
||||
export function parseNumero(texto: string): number | null {
|
||||
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
|
||||
if (!normalizado) return null
|
||||
const n = Number(normalizado)
|
||||
if (Number.isNaN(n)) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
|
||||
*
|
||||
* @param value - O valor numérico de fato emitido
|
||||
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
|
||||
* @returns String formatada amigável para exibição em pt-BR
|
||||
*/
|
||||
export function formatarNumero(value: number | null | undefined, casas: number | null) {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (casas === null) return String(value).replace(/\./g, ",")
|
||||
const fixed = Number(value).toFixed(Math.max(0, casas))
|
||||
return fixed.replace(/\./g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
|
||||
*
|
||||
* @param texto - String de entrada
|
||||
* @returns Apenas dígitos de 0 a 9
|
||||
*/
|
||||
export function somenteNumeros(texto: string) {
|
||||
return (texto ?? "").replace(/\D+/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
|
||||
*
|
||||
* @param texto - Texto misto de entrada
|
||||
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
|
||||
*/
|
||||
export function somenteNumerosESeparadorDecimal(texto: string) {
|
||||
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
|
||||
const matchSep = limpo.match(/[.,]/)
|
||||
if (!matchSep) return limpo
|
||||
|
||||
const sep = matchSep[0]
|
||||
const idx = limpo.indexOf(sep)
|
||||
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
|
||||
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
|
||||
const antes = antesRaw.length ? antesRaw : "0"
|
||||
return `${antes}${sep}${depois}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trunca a parte decimal de um texto numérico ao número máximo de casas permitidas.
|
||||
* Aplicado durante a digitação para evitar que o usuário ultrapasse a precisão configurada.
|
||||
*
|
||||
* @param texto - Texto numérico já higienizado (com separador único)
|
||||
* @param casas - Número máximo de casas decimais; `null` = sem limite
|
||||
* @returns Texto com a parte decimal truncada no limite especificado
|
||||
*/
|
||||
export function limitarCasasDecimais(texto: string, casas: number | null) {
|
||||
if (casas === null) return texto
|
||||
if (casas <= 0) return texto.replace(/[.,]/g, "")
|
||||
|
||||
const matchSep = texto.match(/[.,]/)
|
||||
if (!matchSep) return texto
|
||||
const sep = matchSep[0]
|
||||
const idx = texto.indexOf(sep)
|
||||
|
||||
const inteiro = texto.slice(0, idx)
|
||||
const frac = texto.slice(idx + 1)
|
||||
return `${inteiro}${sep}${frac.slice(0, casas)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
|
||||
*
|
||||
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
|
||||
* @returns A conversão parcial estrita ou `null` caso falhe
|
||||
*/
|
||||
export function valorParcialDecimal(texto: string): number | null {
|
||||
const m = texto.match(/^(\d+)[.,]$/)
|
||||
if (!m) return null
|
||||
const n = Number(m[1])
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
|
@ -27,126 +27,19 @@
|
|||
<script lang="ts">
|
||||
import { computed, defineComponent, type PropType, ref, watch } from "vue"
|
||||
import type { PadroesEntradas } from "./tiposEntradas"
|
||||
import "./EliEntradaNumero.css"
|
||||
import {
|
||||
casasDecimaisFromPrecisao,
|
||||
formatarNumero,
|
||||
limitarCasasDecimais,
|
||||
parseNumero,
|
||||
somenteNumeros,
|
||||
somenteNumerosESeparadorDecimal,
|
||||
valorParcialDecimal,
|
||||
} from "./EliEntradaNumero.helpers"
|
||||
|
||||
type EntradaNumero = PadroesEntradas["numero"]
|
||||
|
||||
function casasDecimaisFromPrecisao(precisao: number): number {
|
||||
if (!Number.isFinite(precisao) || precisao <= 0) return 0
|
||||
if (precisao >= 1) return 0
|
||||
|
||||
// Preferência por contar casas decimais na representação (ex.: 0.01 -> 2)
|
||||
const texto = precisao.toString()
|
||||
if (texto.includes("e-")) {
|
||||
const [, exp] = texto.split("e-")
|
||||
const n = Number(exp)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const idx = texto.indexOf(".")
|
||||
if (idx === -1) return 0
|
||||
const dec = texto.slice(idx + 1).replace(/0+$/, "")
|
||||
return dec.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Analisa a entrada de texto e converte em um valor numérico válido (float).
|
||||
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
|
||||
*
|
||||
* @param texto - O texto bruto digitado ou vindo do estado
|
||||
* @returns O número processado ou `null` caso inválido ou vazio
|
||||
*/
|
||||
function parseNumero(texto: string): number | null {
|
||||
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
|
||||
if (!normalizado) return null
|
||||
const n = Number(normalizado)
|
||||
if (Number.isNaN(n)) return null
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
|
||||
* Em caso de precisão nula (modo livre), preserva a semântica de exibição direta sem forçar o preenchimento de zeros (toFixed).
|
||||
*
|
||||
* @param value - O valor numérico de fato emitido
|
||||
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
|
||||
* @returns String formatada amigável para exibição em pt-BR
|
||||
*/
|
||||
function formatarNumero(
|
||||
value: number | null | undefined,
|
||||
casas: number | null,
|
||||
) {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (casas === null) return String(value).replace(/\./g, ",")
|
||||
|
||||
// Garantimos que o texto visual corresponda ao value (fixed).
|
||||
const fixed = Number(value).toFixed(Math.max(0, casas))
|
||||
// Exibe com vírgula (pt-BR)
|
||||
return fixed.replace(/\./g, ",")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
|
||||
*
|
||||
* @param texto - String de entrada
|
||||
* @returns Apenas dígitos de 0 a 9
|
||||
*/
|
||||
function somenteNumeros(texto: string) {
|
||||
return (texto ?? "").replace(/\D+/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
|
||||
* Colapsa múltiplos separadores e normaliza se o separador vier no início (",4" => "0,4").
|
||||
*
|
||||
* @param texto - Texto misto de entrada
|
||||
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
|
||||
*/
|
||||
function somenteNumerosESeparadorDecimal(texto: string) {
|
||||
// Mantém apenas números e UM separador decimal ("," ou "."), sem sinais.
|
||||
// Implementação baseada em regex + colapso de separadores.
|
||||
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
|
||||
const matchSep = limpo.match(/[.,]/)
|
||||
if (!matchSep) return limpo
|
||||
|
||||
const sep = matchSep[0]
|
||||
const idx = limpo.indexOf(sep)
|
||||
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
|
||||
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
|
||||
|
||||
// Se o usuário começa pelo separador (",4"), normalizamos para "0,4".
|
||||
const antes = antesRaw.length ? antesRaw : "0"
|
||||
return `${antes}${sep}${depois}`
|
||||
}
|
||||
|
||||
function limitarCasasDecimais(texto: string, casas: number | null) {
|
||||
if (casas === null) return texto
|
||||
if (casas <= 0) return texto.replace(/[.,]/g, "")
|
||||
|
||||
const matchSep = texto.match(/[.,]/)
|
||||
if (!matchSep) return texto
|
||||
const sep = matchSep[0]
|
||||
const idx = texto.indexOf(sep)
|
||||
|
||||
const inteiro = texto.slice(0, idx)
|
||||
const frac = texto.slice(idx + 1)
|
||||
return `${inteiro}${sep}${frac.slice(0, casas)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
|
||||
* Identifica se a string tem este formato pendente e retorna temporariamente sua base inteira
|
||||
* sem permitir que o valor se torne nulo.
|
||||
*
|
||||
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
|
||||
* @returns A conversão parcial estrita ou `null` caso falhe
|
||||
*/
|
||||
function valorParcialDecimal(texto: string): number | null {
|
||||
// Regra (B): se o usuário digitou "1," ou "1." emite 1.
|
||||
const m = texto.match(/^(\d+)[.,]$/)
|
||||
if (!m) return null
|
||||
const n = Number(m[1])
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliEntradaNumero",
|
||||
inheritAttrs: false,
|
||||
|
|
@ -229,12 +122,8 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
// Modo livre (sem precisao) ou inteiro: aceita números e 1 separador (no caso livre)
|
||||
const base = isInteiro.value
|
||||
? somenteNumeros(texto)
|
||||
: somenteNumerosESeparadorDecimal(texto)
|
||||
const textoFiltrado = isInteiro.value
|
||||
? base
|
||||
: limitarCasasDecimais(base, casasDecimais.value)
|
||||
const base = isInteiro.value ? somenteNumeros(texto) : somenteNumerosESeparadorDecimal(texto)
|
||||
const textoFiltrado = isInteiro.value ? base : limitarCasasDecimais(base, casasDecimais.value)
|
||||
|
||||
// Emissão:
|
||||
// - vazio => null
|
||||
|
|
@ -242,9 +131,7 @@ export default defineComponent({
|
|||
// - caso geral => parse normal
|
||||
let out: EntradaNumero["value"] = null
|
||||
if (textoFiltrado) {
|
||||
const parcial = isInteiro.value
|
||||
? null
|
||||
: valorParcialDecimal(textoFiltrado)
|
||||
const parcial = isInteiro.value ? null : valorParcialDecimal(textoFiltrado)
|
||||
const n = parcial ?? parseNumero(textoFiltrado)
|
||||
out = (n === null ? null : n) as EntradaNumero["value"]
|
||||
}
|
||||
|
|
@ -269,19 +156,3 @@ export default defineComponent({
|
|||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.eli-entrada__prefixo,
|
||||
.eli-entrada__sufixo {
|
||||
opacity: 0.75;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eli-entrada__prefixo {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.eli-entrada__sufixo {
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,14 +23,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
onMounted,
|
||||
type PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue"
|
||||
import { computed, defineComponent, onMounted, type PropType, ref, watch } from "vue"
|
||||
import { VSelect } from "vuetify/components"
|
||||
import type { PadroesEntradas } from "./tiposEntradas"
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ export default defineComponent({
|
|||
|
||||
const inputMode = computed<string | undefined>(() => {
|
||||
if (formato.value === "telefone") return "tel"
|
||||
if (formato.value === "cpfCnpj" || formato.value === "cep")
|
||||
return "numeric"
|
||||
if (formato.value === "cpfCnpj" || formato.value === "cep") return "numeric"
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,16 @@ import EliEntradaTexto from "./EliEntradaTexto.vue"
|
|||
|
||||
import type { TipoEntrada } from "./tiposEntradas"
|
||||
|
||||
/**
|
||||
* Mapa (dispatch table) que associa cada `TipoEntrada` ao seu componente Vue correspondente.
|
||||
* Usado pelo sistema de filtro avançado para renderizar dinamicamente o campo correto
|
||||
* a partir da configuração declarada em `tipoEliTabelaConsulta.filtroAvancado[].entrada`.
|
||||
*
|
||||
* Adicionar um novo tipo de entrada requer:
|
||||
* 1. Criar o componente Vue
|
||||
* 2. Adicionar a chave em `TipoEntrada` (tiposEntradas.ts)
|
||||
* 3. Registrar aqui no mapa
|
||||
*/
|
||||
export const registryTabelaCelulas = {
|
||||
texto: EliEntradaTexto,
|
||||
numero: EliEntradaNumero,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ function somenteNumeros(v: string): string {
|
|||
return v.replace(/\D+/g, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata um CPF ou CNPJ aplicando a máscara brasileira padrão durante a digitação.
|
||||
* Detecta automaticamente o tipo pelo comprimento (≤11 dígitos = CPF, >11 = CNPJ).
|
||||
* Não valida o dígito verificador — apenas aplica a formatação visual.
|
||||
*
|
||||
* @param v - Valor bruto (pode conter letras, pontos, traços etc.)
|
||||
* @returns String formatada no padrão `000.000.000-00` (CPF) ou `00.000.000/0000-00` (CNPJ)
|
||||
*/
|
||||
export function formatarCpfCnpj(v: string): string {
|
||||
const d = somenteNumeros(v)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.eli-tabela__table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.eli-tabela__td--estados {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.eli-tabela__estados-inline {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.eli-tabela__table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
|
|
@ -29,21 +43,30 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
.eli-tabela__tbody .eli-tabela__tr .eli-tabela__td {
|
||||
background: var(--cor-eli-superficie, #ffffff);
|
||||
}
|
||||
|
||||
.eli-tabela__tbody .eli-tabela__tr--zebra .eli-tabela__td {
|
||||
background: rgba(15, 23, 42, 0.02);
|
||||
background: var(--cor-eli-primaria-zebra, #F6F9E8);
|
||||
}
|
||||
|
||||
.eli-tabela__th,
|
||||
.eli-tabela__td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid var(--cor-eli-borda);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.eli-tabela__th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cor-eli-neutro-escuro, #3E4436);
|
||||
background: var(--cor-eli-primaria-suave, #BFC99C);
|
||||
border-bottom: 2px solid var(--cor-eli-primaria-bg, #EEF3D0);
|
||||
}
|
||||
|
||||
.eli-tabela__th--ordenavel {
|
||||
|
|
@ -71,16 +94,16 @@
|
|||
|
||||
.eli-tabela__th-botao:hover,
|
||||
.eli-tabela__th-botao:focus-visible {
|
||||
color: rgba(15, 23, 42, 0.85);
|
||||
color: var(--cor-eli-primaria-texto, #2E3F00);
|
||||
}
|
||||
|
||||
.eli-tabela__th-botao:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria, #688C00);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.eli-tabela__th-botao--ativo {
|
||||
color: rgba(15, 23, 42, 0.85);
|
||||
color: var(--cor-eli-primaria-texto, #2E3F00);
|
||||
}
|
||||
|
||||
.eli-tabela__th-texto {
|
||||
|
|
@ -110,14 +133,19 @@
|
|||
}
|
||||
|
||||
|
||||
|
||||
.eli-tabela--erro {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(220, 53, 69, 0.35);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.eli-tabela--carregando {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
|
|
@ -134,12 +162,33 @@
|
|||
}
|
||||
|
||||
.eli-tabela--vazio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.eli-tabela__estado-icone {
|
||||
flex-shrink: 0;
|
||||
color: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.eli-tabela__estado-icone--erro {
|
||||
color: var(--cor-eli-erro);
|
||||
}
|
||||
|
||||
.eli-tabela__estado-icone--spin {
|
||||
animation: eli-tabela-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes eli-tabela-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.eli-tabela__th--acoes {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
|
@ -222,8 +271,8 @@
|
|||
padding: 0 14px;
|
||||
border-radius: var(--eli-tabela-cabecalho-controle-radius);
|
||||
border: none;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: rgba(37, 99, 235, 0.95);
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
|
|
@ -233,11 +282,11 @@
|
|||
|
||||
.eli-tabela__acoes-cabecalho-botao:hover,
|
||||
.eli-tabela__acoes-cabecalho-botao:focus-visible {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
background: var(--cor-eli-primaria-clara);
|
||||
}
|
||||
|
||||
.eli-tabela__acoes-cabecalho-botao:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -274,7 +323,7 @@
|
|||
}
|
||||
|
||||
.eli-tabela__acoes-toggle:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +338,7 @@
|
|||
padding: 6px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: #ffffff;
|
||||
background: var(--cor-eli-superficie);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
|
||||
|
|
@ -365,7 +414,7 @@
|
|||
}
|
||||
|
||||
.eli-tabela__expander-botao:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -445,8 +494,8 @@
|
|||
|
||||
.eli-tabela__checkbox:checked,
|
||||
.eli-tabela__checkbox:indeterminate {
|
||||
background-color: #1976d2;
|
||||
border-color: #1976d2;
|
||||
background-color: var(--cor-eli-primaria);
|
||||
border-color: var(--cor-eli-primaria);
|
||||
}
|
||||
|
||||
.eli-tabela__checkbox:checked::after {
|
||||
|
|
@ -473,6 +522,6 @@
|
|||
}
|
||||
|
||||
.eli-tabela__checkbox:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.3);
|
||||
box-shadow: 0 0 0 2px var(--cor-eli-primaria-clara);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,57 +31,60 @@
|
|||
|
||||
<EliTabelaModalFiltroAvancado
|
||||
:aberto="modalFiltroAberto"
|
||||
:filtrosBase="tabela.filtroAvancado ?? []"
|
||||
:filtrosBase="(tabela.filtroAvancado ?? []) as FiltroBaseModal[]"
|
||||
:modelo="filtrosUi"
|
||||
:modeloFormula="filtrosFormula"
|
||||
@fechar="fecharModalFiltro"
|
||||
@limpar="limparFiltrosAvancados"
|
||||
@salvar="salvarFiltrosAvancados"
|
||||
/>
|
||||
|
||||
<table class="eli-tabela__table">
|
||||
<EliTabelaHead
|
||||
:colunas="colunasEfetivas"
|
||||
:temAcoes="temAcoes"
|
||||
:temColunasInvisiveis="temColunasInvisiveis"
|
||||
:colunaOrdenacao="colunaOrdenacao"
|
||||
:direcaoOrdenacao="direcaoOrdenacao"
|
||||
:mostrarSelecao="mostrarSelecao"
|
||||
:todosSelecionados="todosSelecionados"
|
||||
:algunsSelecionados="algunsSelecionados"
|
||||
@alternar-ordenacao="alternarOrdenacao"
|
||||
@alternar-selecao-todos="alternarSelecaoTodos"
|
||||
/>
|
||||
<div class="eli-tabela__table-wrapper">
|
||||
<table class="eli-tabela__table">
|
||||
<EliTabelaHead
|
||||
:colunas="colunasEfetivas"
|
||||
:temAcoes="temAcoes"
|
||||
:temColunasInvisiveis="temColunasInvisiveis"
|
||||
:colunaOrdenacao="colunaOrdenacao"
|
||||
:direcaoOrdenacao="direcaoOrdenacao"
|
||||
:mostrarSelecao="mostrarSelecao"
|
||||
:todosSelecionados="todosSelecionados"
|
||||
:algunsSelecionados="algunsSelecionados"
|
||||
@alternar-ordenacao="alternarOrdenacao"
|
||||
@alternar-selecao-todos="alternarSelecaoTodos"
|
||||
/>
|
||||
|
||||
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
|
||||
<tr>
|
||||
<td :colspan="totalColunas" style="padding: 0;">
|
||||
<EliTabelaEstados
|
||||
:carregando="carregando"
|
||||
:erro="erro"
|
||||
:mensagemVazio="tabela.mensagemVazio"
|
||||
style="border: none; border-radius: 0;"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
|
||||
<tr>
|
||||
<td :colspan="totalColunas" class="eli-tabela__td--estados">
|
||||
<EliTabelaEstados
|
||||
:carregando="carregando"
|
||||
:erro="erro"
|
||||
:mensagemVazio="tabela.mensagemVazio"
|
||||
class="eli-tabela__estados-inline"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<EliTabelaBody
|
||||
v-else
|
||||
:colunas="colunasEfetivas"
|
||||
:colunasInvisiveis="colunasInvisiveisEfetivas"
|
||||
:temColunasInvisiveis="temColunasInvisiveis"
|
||||
:linhasExpandidas="linhasExpandidas"
|
||||
:linhas="linhasPaginadas"
|
||||
:temAcoes="temAcoes"
|
||||
:menuAberto="menuAberto"
|
||||
:possuiAcoes="possuiAcoes"
|
||||
:toggleMenu="toggleMenu"
|
||||
:alternarLinhaExpandida="alternarLinhaExpandida"
|
||||
:mostrarSelecao="mostrarSelecao"
|
||||
:isSelecionado="isSelecionado"
|
||||
:alternarSelecao="alternarSelecao"
|
||||
/>
|
||||
</table>
|
||||
<EliTabelaBody
|
||||
v-else
|
||||
:colunas="colunasEfetivas"
|
||||
:colunasInvisiveis="colunasInvisiveisEfetivas"
|
||||
:temColunasInvisiveis="temColunasInvisiveis"
|
||||
:linhasExpandidas="linhasExpandidas"
|
||||
:linhas="linhasPaginadas"
|
||||
:temAcoes="temAcoes"
|
||||
:menuAberto="menuAberto"
|
||||
:possuiAcoes="possuiAcoes"
|
||||
:toggleMenu="toggleMenu"
|
||||
:alternarLinhaExpandida="alternarLinhaExpandida"
|
||||
:mostrarSelecao="mostrarSelecao"
|
||||
:isSelecionado="isSelecionado"
|
||||
:alternarSelecao="alternarSelecao"
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<EliTabelaMenuAcoes
|
||||
ref="menuPopup"
|
||||
|
|
@ -108,28 +111,16 @@
|
|||
/**
|
||||
* EliTabela
|
||||
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
|
||||
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
|
||||
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
|
||||
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
|
||||
* - useEliTabelaSelecao — seleção múltipla de linhas
|
||||
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
|
||||
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
|
||||
*/
|
||||
|
||||
/** Enum de códigos de resposta utilizado na consulta */
|
||||
import { codigosResposta } from "p-respostas"
|
||||
/** Dependências do Vue (Composition API) */
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
type PropType,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue"
|
||||
// operadores usados no tipo de configuração; o operador aplicado vem travado no filtroAvancado.
|
||||
import {
|
||||
carregarConfigColunas,
|
||||
type EliTabelaColunasConfig,
|
||||
salvarConfigColunas,
|
||||
} from "./colunasStorage"
|
||||
import { defineComponent, type PropType } from "vue"
|
||||
import "./EliTabela.css"
|
||||
import EliTabelaBody from "./EliTabelaBody.vue"
|
||||
/** Componentes auxiliares */
|
||||
import EliTabelaCabecalho from "./EliTabelaCabecalho.vue"
|
||||
import EliTabelaDebug from "./EliTabelaDebug.vue"
|
||||
import EliTabelaEstados from "./EliTabelaEstados.vue"
|
||||
|
|
@ -138,18 +129,12 @@ import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue"
|
|||
import EliTabelaModalColunas from "./EliTabelaModalColunas.vue"
|
||||
import EliTabelaModalFiltroAvancado from "./EliTabelaModalFiltroAvancado.vue"
|
||||
import EliTabelaPaginacao from "./EliTabelaPaginacao.vue"
|
||||
import {
|
||||
carregarFiltroAvancado,
|
||||
limparFiltroAvancado,
|
||||
salvarFiltroAvancado,
|
||||
} from "./filtroAvancadoStorage"
|
||||
/** Tipos da configuração/contrato da tabela */
|
||||
import type {
|
||||
parametrosConsulta,
|
||||
tipoEliColuna,
|
||||
tipoEliTabelaConsulta,
|
||||
tipoFiltro,
|
||||
} from "./types-eli-tabela"
|
||||
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
|
||||
import { useEliTabela } from "./useEliTabela"
|
||||
import type { FiltroBase } from "./useFiltroAvancado"
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: tipo usado no template SFC no cast :filtrosBase
|
||||
type FiltroBaseModal = FiltroBase<string>
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabela",
|
||||
|
|
@ -170,821 +155,22 @@ export default defineComponent({
|
|||
tabela: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic table
|
||||
type: Object as PropType<tipoEliTabelaConsulta<any>>,
|
||||
required: true,
|
||||
required: true as const,
|
||||
},
|
||||
/** Array de itens selecionados (v-model:selecionados) */
|
||||
selecionados: {
|
||||
type: Array as PropType<unknown[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/** Ocultar painel de debug lateral (EliTabelaDebug) */
|
||||
esconderDebug: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:selecionados"],
|
||||
setup(props, { emit }) {
|
||||
/** Flag para habilitar elementos de debug */
|
||||
const isDev = import.meta.env.DEV
|
||||
/** Estados de carregamento/erro e dados retornados */
|
||||
const carregando = ref(false)
|
||||
const erro = ref<string | null>(null)
|
||||
const linhas = ref<unknown[]>([])
|
||||
const quantidade = ref<number>(0)
|
||||
|
||||
/** Controle de visibilidade das ações por linha */
|
||||
const acoesVisiveis = ref<boolean[][]>([])
|
||||
/** Estado do menu de ações (aberto, elemento e posição) */
|
||||
const menuAberto = ref<number | null>(null)
|
||||
// O componente EliTabelaMenuAcoes expõe `menuEl` (ref do elemento <ul>)
|
||||
const menuPopup = ref<{ menuEl: { value: HTMLElement | null } } | null>(
|
||||
null,
|
||||
)
|
||||
const menuPopupPos = ref({ top: 0, left: 0 })
|
||||
|
||||
/** Filtros e ordenação */
|
||||
const valorBusca = ref<string>("")
|
||||
const paginaAtual = ref(1)
|
||||
const colunaOrdenacao = ref<string | null>(null)
|
||||
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
|
||||
|
||||
/** Filtro avançado (config + estado modal) */
|
||||
const modalFiltroAberto = ref(false)
|
||||
type LinhaFiltroUI = {
|
||||
chave: string
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
|
||||
valor: any
|
||||
}
|
||||
const filtrosUi = ref<Array<LinhaFiltroUI>>(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic load
|
||||
carregarFiltroAvancado<any>(props.tabela.nome) as any,
|
||||
)
|
||||
|
||||
function abrirModalFiltro() {
|
||||
modalFiltroAberto.value = true
|
||||
}
|
||||
|
||||
function fecharModalFiltro() {
|
||||
modalFiltroAberto.value = false
|
||||
}
|
||||
|
||||
function limparFiltrosAvancados() {
|
||||
filtrosUi.value = []
|
||||
limparFiltroAvancado(props.tabela.nome)
|
||||
modalFiltroAberto.value = false
|
||||
// Se o usuário estiver usando filtro avançado, a busca deixa de ter efeito.
|
||||
// Mantemos a regra combinatória (busca tem prioridade), então limpamos a busca.
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
|
||||
function salvarFiltrosAvancados(novo: any[]) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
|
||||
filtrosUi.value = (novo ?? []) as any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic storage
|
||||
salvarFiltroAvancado(props.tabela.nome, (novo ?? []) as any)
|
||||
modalFiltroAberto.value = false
|
||||
// Ao aplicar filtros, limpamos a busca para garantir que os filtros sejam efetivos.
|
||||
// (busca tem prioridade sobre filtros)
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic
|
||||
const filtrosAvancadosAtivos = computed<tipoFiltro<any>[]>(() => {
|
||||
const base = props.tabela.filtroAvancado ?? []
|
||||
|
||||
return (
|
||||
(filtrosUi.value ?? [])
|
||||
.filter((f) => f && f.chave !== undefined)
|
||||
.map((f) => {
|
||||
const b = base.find((x) => String(x.chave) === String(f.chave))
|
||||
if (!b) return null
|
||||
|
||||
// O filtro já vem pronto da função definida na configuração
|
||||
return b.filtro(f.valor)
|
||||
})
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic
|
||||
.filter(Boolean) as tipoFiltro<any>[]
|
||||
)
|
||||
})
|
||||
|
||||
/** Alias reativo da prop tabela */
|
||||
const tabela = computed(() => props.tabela)
|
||||
|
||||
/** Seleção Múltipla */
|
||||
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
|
||||
const chaveSelecao = computed(
|
||||
() => props.tabela.chave_selecao as string | undefined,
|
||||
)
|
||||
|
||||
function obterChaveLinha(linha: unknown): unknown {
|
||||
if (chaveSelecao.value && linha && typeof linha === "object") {
|
||||
return (linha as Record<string, unknown>)[chaveSelecao.value]
|
||||
}
|
||||
return linha
|
||||
}
|
||||
|
||||
function isSelecionado(linha: unknown): boolean {
|
||||
const chave = obterChaveLinha(linha)
|
||||
if (chaveSelecao.value) {
|
||||
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
|
||||
}
|
||||
return props.selecionados.includes(linha)
|
||||
}
|
||||
|
||||
function alternarSelecao(linha: unknown) {
|
||||
const atual = [...props.selecionados]
|
||||
const chave = obterChaveLinha(linha)
|
||||
|
||||
let index = -1
|
||||
if (chaveSelecao.value) {
|
||||
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
|
||||
} else {
|
||||
index = atual.indexOf(linha)
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
atual.push(linha)
|
||||
} else {
|
||||
atual.splice(index, 1)
|
||||
}
|
||||
emit("update:selecionados", atual)
|
||||
}
|
||||
|
||||
const todosSelecionados = computed(() => {
|
||||
if (linhasPaginadas.value.length === 0) return false
|
||||
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
|
||||
})
|
||||
|
||||
const algunsSelecionados = computed(() => {
|
||||
if (linhasPaginadas.value.length === 0) return false
|
||||
return (
|
||||
linhasPaginadas.value.some((linha) => isSelecionado(linha)) &&
|
||||
!todosSelecionados.value
|
||||
)
|
||||
})
|
||||
|
||||
function alternarSelecaoTodos(forcarMarcar?: boolean) {
|
||||
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
|
||||
let atual = [...props.selecionados]
|
||||
|
||||
if (marcarTodos) {
|
||||
// Adiciona os que faltam na página atual
|
||||
for (const linha of linhasPaginadas.value) {
|
||||
if (!isSelecionado(linha)) {
|
||||
atual.push(linha)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove os da página atual
|
||||
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
|
||||
if (chaveSelecao.value) {
|
||||
atual = atual.filter(
|
||||
(s) => !chavesPagina.includes(obterChaveLinha(s)),
|
||||
)
|
||||
} else {
|
||||
atual = atual.filter((s) => !chavesPagina.includes(s))
|
||||
}
|
||||
}
|
||||
emit("update:selecionados", atual)
|
||||
}
|
||||
|
||||
/** Exibição da busca e ações do cabeçalho */
|
||||
const exibirBusca = computed(() =>
|
||||
Boolean(props.tabela.mostrarCaixaDeBusca),
|
||||
)
|
||||
const acoesSuperiores = computed(() =>
|
||||
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
|
||||
)
|
||||
const acoesInferiores = computed(() =>
|
||||
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
|
||||
)
|
||||
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
|
||||
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
|
||||
|
||||
const parametrosConsultaAtuais = computed(() => {
|
||||
const limite = Math.max(1, registrosPorConsulta.value)
|
||||
const offset = (paginaAtual.value - 1) * limite
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
|
||||
const params: parametrosConsulta<any> = {
|
||||
offSet: offset,
|
||||
limit: limite,
|
||||
}
|
||||
|
||||
const busca = (valorBusca.value ?? "").trim()
|
||||
if (busca) {
|
||||
params.texto_busca = busca
|
||||
} else {
|
||||
const filtros = filtrosAvancadosAtivos.value
|
||||
if (filtros.length) params.filtros = filtros
|
||||
}
|
||||
|
||||
if (colunaOrdenacao.value) {
|
||||
params.coluna_ordem = colunaOrdenacao.value
|
||||
params.direcao_ordem = direcaoOrdenacao.value
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
atualizarConsulta: async () => {
|
||||
await carregar()
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
|
||||
editarLista: async (novaLista: any[]) => {
|
||||
linhas.value = novaLista
|
||||
return novaLista
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/** Colunas: visibilidade/ordem com persistência */
|
||||
const modalColunasAberto = ref(false)
|
||||
const configColunas = ref<EliTabelaColunasConfig>(
|
||||
carregarConfigColunas(props.tabela.nome),
|
||||
)
|
||||
|
||||
/** Linhas expandidas (para exibir colunas invisíveis) */
|
||||
const linhasExpandidas = ref<Record<number, boolean>>({})
|
||||
|
||||
const rotulosColunas = computed(() =>
|
||||
props.tabela.colunas.map((c) => c.rotulo),
|
||||
)
|
||||
|
||||
const colunasInvisiveisEfetivas = computed(() => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
const colunas = props.tabela.colunas as Array<tipoEliColuna<any>>
|
||||
|
||||
const configTemDados =
|
||||
(configColunas.value.visiveis?.length ?? 0) > 0 ||
|
||||
(configColunas.value.invisiveis?.length ?? 0) > 0
|
||||
|
||||
const invisiveisBaseRotulos = configTemDados
|
||||
? (configColunas.value.invisiveis ?? [])
|
||||
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
|
||||
|
||||
const invisiveisSet = new Set(invisiveisBaseRotulos)
|
||||
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
|
||||
|
||||
// ordenação: usa a lista (salva ou derivada do default) e adiciona novas ao final
|
||||
const ordemSalva = invisiveisBaseRotulos
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
|
||||
const mapa = new Map<string, tipoEliColuna<any>>()
|
||||
for (const c of base) {
|
||||
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
const ordenadas: Array<tipoEliColuna<any>> = []
|
||||
for (const r of ordemSalva) {
|
||||
const c = mapa.get(r)
|
||||
if (c) ordenadas.push(c)
|
||||
}
|
||||
for (const c of base) {
|
||||
if (!ordenadas.includes(c)) ordenadas.push(c)
|
||||
}
|
||||
|
||||
return ordenadas
|
||||
})
|
||||
|
||||
const temColunasInvisiveis = computed(
|
||||
() => colunasInvisiveisEfetivas.value.length > 0,
|
||||
)
|
||||
|
||||
const colunasEfetivas = computed(() => {
|
||||
const colunas = props.tabela.colunas
|
||||
const todosRotulos = rotulosColunas.value
|
||||
|
||||
const configTemDados =
|
||||
(configColunas.value.visiveis?.length ?? 0) > 0 ||
|
||||
(configColunas.value.invisiveis?.length ?? 0) > 0
|
||||
|
||||
const invisiveisBaseRotulos = configTemDados
|
||||
? (configColunas.value.invisiveis ?? [])
|
||||
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
(props.tabela.colunas as Array<tipoEliColuna<any>>)
|
||||
.filter((c) => c.visivel === false)
|
||||
.map((c) => c.rotulo)
|
||||
|
||||
const invisiveisSet = new Set(invisiveisBaseRotulos)
|
||||
|
||||
// base visiveis: remove invisiveis
|
||||
const visiveisBaseRotulos = todosRotulos.filter(
|
||||
(r) => !invisiveisSet.has(r),
|
||||
)
|
||||
const visiveisSet = new Set(visiveisBaseRotulos)
|
||||
|
||||
// aplica ordem salva; novas (sem definicao) entram no fim, respeitando ordem original
|
||||
const ordemSalva = configTemDados
|
||||
? (configColunas.value.visiveis ?? [])
|
||||
: []
|
||||
const ordemFinal: string[] = []
|
||||
|
||||
for (const r of ordemSalva) {
|
||||
if (visiveisSet.has(r)) ordemFinal.push(r)
|
||||
}
|
||||
for (const r of visiveisBaseRotulos) {
|
||||
if (!ordemFinal.includes(r)) ordemFinal.push(r)
|
||||
}
|
||||
|
||||
// mapeia rótulo -> coluna, preservando duplicatas (se existirem) pelo primeiro match.
|
||||
// OBS: pressupoe rotulo unico; se repetir, comportamento fica indefinido.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
|
||||
const mapa = new Map<string, any>()
|
||||
for (const c of colunas) {
|
||||
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
|
||||
}
|
||||
|
||||
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
|
||||
})
|
||||
|
||||
const totalColunas = computed(() => {
|
||||
return (
|
||||
colunasEfetivas.value.length +
|
||||
(temAcoes.value ? 1 : 0) +
|
||||
(temColunasInvisiveis.value ? 1 : 0)
|
||||
)
|
||||
})
|
||||
|
||||
function abrirModalColunas() {
|
||||
modalColunasAberto.value = true
|
||||
}
|
||||
|
||||
function fecharModalColunas() {
|
||||
modalColunasAberto.value = false
|
||||
}
|
||||
|
||||
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
|
||||
configColunas.value = cfg
|
||||
salvarConfigColunas(props.tabela.nome, cfg)
|
||||
modalColunasAberto.value = false
|
||||
|
||||
// ao mudar colunas, fecha detalhes expandidos
|
||||
linhasExpandidas.value = {}
|
||||
}
|
||||
|
||||
function alternarLinhaExpandida(indice: number) {
|
||||
const atual = Boolean(linhasExpandidas.value[indice])
|
||||
linhasExpandidas.value = {
|
||||
...linhasExpandidas.value,
|
||||
[indice]: !atual,
|
||||
}
|
||||
}
|
||||
|
||||
/** Registros por consulta (normaliza para inteiro positivo) */
|
||||
const registrosPorConsulta = computed(() => {
|
||||
const valor = props.tabela.registros_por_consulta
|
||||
if (typeof valor === "number" && valor > 0) {
|
||||
return Math.floor(valor)
|
||||
}
|
||||
return 10
|
||||
})
|
||||
|
||||
/** Total de páginas calculado com base no total retornado pela API */
|
||||
const totalPaginas = computed(() => {
|
||||
const limite = registrosPorConsulta.value
|
||||
if (!limite || limite <= 0) return 1
|
||||
const total = quantidade.value ?? 0
|
||||
if (!total) return 1
|
||||
return Math.max(1, Math.ceil(total / limite))
|
||||
})
|
||||
|
||||
/** As linhas já vêm paginadas do backend */
|
||||
const linhasPaginadas = computed(() => linhas.value ?? [])
|
||||
|
||||
/** Quantidade exibida é a quantidade total retornada pela consulta */
|
||||
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
|
||||
|
||||
/** Indica se existem ações por linha */
|
||||
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
|
||||
|
||||
const exibirFiltroAvancado = computed(
|
||||
() => (props.tabela.filtroAvancado ?? []).length > 0,
|
||||
)
|
||||
|
||||
/** Sequencial para evitar race conditions entre consultas */
|
||||
let carregamentoSequencial = 0
|
||||
|
||||
/** Calcula a posição do menu de ações na viewport */
|
||||
function atualizarPosicaoMenu(anchor: HTMLElement) {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const gap = 8
|
||||
|
||||
// Alinha no canto inferior direito do botão.
|
||||
// Se estourar a tela para baixo, abre para cima.
|
||||
const alturaMenu = menuPopup.value?.menuEl?.value?.offsetHeight ?? 0
|
||||
const larguraMenu = menuPopup.value?.menuEl?.value?.offsetWidth ?? 180
|
||||
|
||||
let top = rect.bottom + gap
|
||||
const left = rect.right - larguraMenu
|
||||
|
||||
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
|
||||
top = rect.top - gap - alturaMenu
|
||||
}
|
||||
|
||||
menuPopupPos.value = {
|
||||
top: Math.max(gap, Math.round(top)),
|
||||
left: Math.max(gap, Math.round(left)),
|
||||
}
|
||||
}
|
||||
|
||||
/** Fecha o menu quando ocorre clique fora */
|
||||
function handleClickFora(evento: MouseEvent) {
|
||||
if (menuAberto.value === null) return
|
||||
|
||||
const alvo = evento.target as Node
|
||||
if (menuPopup.value?.menuEl?.value?.contains(alvo)) return
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] click fora => fechar menu", {
|
||||
menuAberto: menuAberto.value,
|
||||
})
|
||||
}
|
||||
|
||||
menuAberto.value = null
|
||||
}
|
||||
|
||||
/** Alterna ordenação e recarrega os dados */
|
||||
function alternarOrdenacao(chave?: string) {
|
||||
if (!chave) return
|
||||
|
||||
if (colunaOrdenacao.value === chave) {
|
||||
direcaoOrdenacao.value =
|
||||
direcaoOrdenacao.value === "asc" ? "desc" : "asc"
|
||||
void carregar()
|
||||
return
|
||||
}
|
||||
|
||||
colunaOrdenacao.value = chave
|
||||
direcaoOrdenacao.value = "asc"
|
||||
if (paginaAtual.value !== 1) {
|
||||
paginaAtual.value = 1
|
||||
} else {
|
||||
void carregar()
|
||||
}
|
||||
}
|
||||
|
||||
/** Atualiza a busca e reinicia paginação, se necessário */
|
||||
function atualizarBusca(texto: string) {
|
||||
if (valorBusca.value === texto) return
|
||||
|
||||
valorBusca.value = texto
|
||||
if (paginaAtual.value !== 1) {
|
||||
paginaAtual.value = 1
|
||||
} else {
|
||||
void carregar()
|
||||
}
|
||||
}
|
||||
|
||||
/** Navega para a página solicitada com limites */
|
||||
function irParaPagina(pagina: number) {
|
||||
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
|
||||
if (alvo !== paginaAtual.value) {
|
||||
paginaAtual.value = alvo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista ações visíveis por linha, respeitando regras sync/async de `exibir`.
|
||||
*/
|
||||
function acoesDisponiveisPorLinha(i: number) {
|
||||
const acoesLinha = props.tabela.acoesLinha ?? []
|
||||
const visibilidade = acoesVisiveis.value[i] ?? []
|
||||
|
||||
return acoesLinha
|
||||
.map((acao, indice) => {
|
||||
const fallbackVisivel =
|
||||
acao.exibir === undefined
|
||||
? true
|
||||
: typeof acao.exibir === "boolean"
|
||||
? acao.exibir
|
||||
: false
|
||||
|
||||
return {
|
||||
acao,
|
||||
indice,
|
||||
visivel: visibilidade[indice] ?? fallbackVisivel,
|
||||
}
|
||||
})
|
||||
.filter((item) => item.visivel)
|
||||
}
|
||||
|
||||
/** Informa se a linha possui ações disponíveis */
|
||||
function possuiAcoes(i: number) {
|
||||
return acoesDisponiveisPorLinha(i).length > 0
|
||||
}
|
||||
|
||||
/** Abre/fecha o menu de ações da linha e posiciona o popup */
|
||||
function toggleMenu(i: number, evento?: MouseEvent) {
|
||||
if (!possuiAcoes(i)) return
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] toggleMenu (antes)", {
|
||||
i,
|
||||
atual: menuAberto.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (menuAberto.value === i) {
|
||||
menuAberto.value = null
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] toggleMenu => fechou", { i })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
menuAberto.value = i
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] toggleMenu => abriu", { i })
|
||||
}
|
||||
|
||||
// posiciona assim que abrir
|
||||
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
|
||||
if (anchor) {
|
||||
// primeiro posicionamento (antes do menu medir)
|
||||
atualizarPosicaoMenu(anchor)
|
||||
// reposiciona no próximo frame para pegar altura real
|
||||
requestAnimationFrame(() => atualizarPosicaoMenu(anchor))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa consulta, aplica filtros e resolve visibilidade de ações.
|
||||
* Usa o contador sequencial para ignorar respostas antigas.
|
||||
*/
|
||||
async function carregar() {
|
||||
const idCarregamento = ++carregamentoSequencial
|
||||
carregando.value = true
|
||||
erro.value = null
|
||||
|
||||
try {
|
||||
acoesVisiveis.value = []
|
||||
menuAberto.value = null
|
||||
linhasExpandidas.value = {}
|
||||
|
||||
const limite = Math.max(1, registrosPorConsulta.value)
|
||||
const offset = (paginaAtual.value - 1) * limite
|
||||
|
||||
const parametrosConsulta: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic
|
||||
filtros?: tipoFiltro<any>[]
|
||||
coluna_ordem?: never
|
||||
direcao_ordem?: "asc" | "desc"
|
||||
offSet: number
|
||||
limit: number
|
||||
texto_busca?: string
|
||||
} = {
|
||||
offSet: offset,
|
||||
limit: limite,
|
||||
}
|
||||
|
||||
// Regra combinatória definida: busca tem prioridade.
|
||||
const busca = (valorBusca.value ?? "").trim()
|
||||
if (busca) {
|
||||
parametrosConsulta.texto_busca = busca
|
||||
} else {
|
||||
const filtros = filtrosAvancadosAtivos.value
|
||||
if (filtros.length) parametrosConsulta.filtros = filtros
|
||||
}
|
||||
|
||||
if (colunaOrdenacao.value) {
|
||||
parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never
|
||||
parametrosConsulta.direcao_ordem = direcaoOrdenacao.value
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] consulta(parametros)", parametrosConsulta)
|
||||
}
|
||||
|
||||
const tabelaConfig = props.tabela
|
||||
const res = await tabelaConfig.consulta(parametrosConsulta)
|
||||
|
||||
if (idCarregamento !== carregamentoSequencial) return
|
||||
|
||||
if (res.cod !== codigosResposta.sucesso) {
|
||||
linhas.value = []
|
||||
quantidade.value = 0
|
||||
erro.value = res.mensagem
|
||||
return
|
||||
}
|
||||
|
||||
const valores = res.valor?.valores ?? []
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic response
|
||||
const total = (res.valor as any)?.quantidade ?? valores.length
|
||||
|
||||
linhas.value = valores
|
||||
quantidade.value = Number(total) || 0
|
||||
|
||||
const totalPaginasRecalculado = Math.max(
|
||||
1,
|
||||
Math.ceil((quantidade.value || 0) / limite),
|
||||
)
|
||||
if (paginaAtual.value > totalPaginasRecalculado)
|
||||
paginaAtual.value = totalPaginasRecalculado
|
||||
|
||||
const acoesLinhaConfiguradas = tabelaConfig.acoesLinha ?? []
|
||||
if (!acoesLinhaConfiguradas.length) {
|
||||
acoesVisiveis.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const preResultado = valores.map(() =>
|
||||
acoesLinhaConfiguradas.map((acao) => {
|
||||
if (acao.exibir === undefined) return true
|
||||
if (typeof acao.exibir === "boolean") return acao.exibir
|
||||
return false
|
||||
}),
|
||||
)
|
||||
|
||||
acoesVisiveis.value = preResultado
|
||||
|
||||
const visibilidade = await Promise.all(
|
||||
valores.map(async (linha) =>
|
||||
Promise.all(
|
||||
acoesLinhaConfiguradas.map(async (acao) => {
|
||||
if (acao.exibir === undefined) return true
|
||||
if (typeof acao.exibir === "boolean") return acao.exibir
|
||||
|
||||
try {
|
||||
const resultado = acao.exibir(linha as never)
|
||||
return Boolean(await Promise.resolve(resultado))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if (idCarregamento === carregamentoSequencial) {
|
||||
acoesVisiveis.value = visibilidade
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[EliTabela] Erro capturado em carregar():", e)
|
||||
if (idCarregamento !== carregamentoSequencial) return
|
||||
|
||||
linhas.value = []
|
||||
quantidade.value = 0
|
||||
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
|
||||
} finally {
|
||||
if (idCarregamento === carregamentoSequencial) {
|
||||
carregando.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ciclo de vida: registra listener global e carrega dados iniciais */
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", handleClickFora)
|
||||
void carregar()
|
||||
})
|
||||
|
||||
/** Ciclo de vida: remove listener global */
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", handleClickFora)
|
||||
})
|
||||
|
||||
/** Watch: ao desabilitar busca, limpa termo e recarrega */
|
||||
watch(
|
||||
() => props.tabela.mostrarCaixaDeBusca,
|
||||
(mostrar) => {
|
||||
if (!mostrar && valorBusca.value) {
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) {
|
||||
paginaAtual.value = 1
|
||||
} else {
|
||||
void carregar()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Watch: mudança de página dispara nova consulta */
|
||||
watch(paginaAtual, (nova, antiga) => {
|
||||
if (nova !== antiga) void carregar()
|
||||
})
|
||||
|
||||
/** Watch: troca de configuração reseta estados e recarrega */
|
||||
watch(
|
||||
() => props.tabela,
|
||||
() => {
|
||||
menuAberto.value = null
|
||||
colunaOrdenacao.value = null
|
||||
direcaoOrdenacao.value = "asc"
|
||||
valorBusca.value = ""
|
||||
modalColunasAberto.value = false
|
||||
modalFiltroAberto.value = false
|
||||
configColunas.value = carregarConfigColunas(props.tabela.nome)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic reload
|
||||
filtrosUi.value = carregarFiltroAvancado<any>(props.tabela.nome) as any
|
||||
linhasExpandidas.value = {}
|
||||
if (paginaAtual.value !== 1) {
|
||||
paginaAtual.value = 1
|
||||
} else {
|
||||
void carregar()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Watch: alteração do limite de registros reinicia paginação */
|
||||
watch(
|
||||
() => props.tabela.registros_por_consulta,
|
||||
() => {
|
||||
if (paginaAtual.value !== 1) {
|
||||
paginaAtual.value = 1
|
||||
} else {
|
||||
void carregar()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** Watch: mudança nas linhas fecha o menu aberto */
|
||||
watch(linhas, () => {
|
||||
menuAberto.value = null
|
||||
linhasExpandidas.value = {}
|
||||
})
|
||||
|
||||
/** Exposição para o template (state, computed, helpers e actions) */
|
||||
return {
|
||||
// state
|
||||
isDev,
|
||||
tabela,
|
||||
carregando,
|
||||
erro,
|
||||
linhas,
|
||||
linhasPaginadas,
|
||||
filtrosAvancadosAtivos,
|
||||
quantidadeFiltrada,
|
||||
quantidade,
|
||||
menuAberto,
|
||||
valorBusca,
|
||||
paginaAtual,
|
||||
colunaOrdenacao,
|
||||
direcaoOrdenacao,
|
||||
totalPaginas,
|
||||
registrosPorConsulta,
|
||||
|
||||
// computed
|
||||
exibirBusca,
|
||||
exibirFiltroAvancado,
|
||||
acoesSuperiores,
|
||||
temAcoesSuperiores,
|
||||
acoesInferiores,
|
||||
temAcoesInferiores,
|
||||
parametrosConsultaAtuais,
|
||||
temAcoes,
|
||||
totalColunas,
|
||||
colunasEfetivas,
|
||||
rotulosColunas,
|
||||
modalColunasAberto,
|
||||
configColunas,
|
||||
|
||||
temColunasInvisiveis,
|
||||
colunasInvisiveisEfetivas,
|
||||
linhasExpandidas,
|
||||
|
||||
// selecao
|
||||
mostrarSelecao,
|
||||
isSelecionado,
|
||||
alternarSelecao,
|
||||
todosSelecionados,
|
||||
algunsSelecionados,
|
||||
alternarSelecaoTodos,
|
||||
|
||||
abrirModalColunas,
|
||||
abrirModalFiltro,
|
||||
fecharModalColunas,
|
||||
salvarModalColunas,
|
||||
|
||||
modalFiltroAberto,
|
||||
filtrosUi,
|
||||
salvarFiltrosAvancados,
|
||||
limparFiltrosAvancados,
|
||||
fecharModalFiltro,
|
||||
|
||||
alternarLinhaExpandida,
|
||||
|
||||
// actions
|
||||
alternarOrdenacao,
|
||||
atualizarBusca,
|
||||
irParaPagina,
|
||||
acoesDisponiveisPorLinha,
|
||||
possuiAcoes,
|
||||
toggleMenu,
|
||||
|
||||
// popup
|
||||
menuPopup,
|
||||
menuPopupPos,
|
||||
}
|
||||
return useEliTabela(props, emit)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style src="./EliTabela.css"></style>
|
||||
|
|
|
|||
|
|
@ -102,12 +102,12 @@ export default defineComponent({
|
|||
props: {
|
||||
colunas: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
type: Array as PropType<Array<tipoEliColuna<any>>>,
|
||||
type: Array as PropType<tipoEliColuna<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
colunasInvisiveis: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
type: Array as PropType<Array<tipoEliColuna<any>>>,
|
||||
type: Array as PropType<tipoEliColuna<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
temColunasInvisiveis: {
|
||||
|
|
@ -119,7 +119,7 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
linhas: {
|
||||
type: Array as PropType<Array<unknown>>,
|
||||
type: Array as PropType<unknown[]>,
|
||||
required: true,
|
||||
},
|
||||
temAcoes: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas"
|
||||
@click="emitColunas"
|
||||
>
|
||||
<Columns3 :size="15" :stroke-width="2" />
|
||||
Colunas
|
||||
</button>
|
||||
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--filtro"
|
||||
@click="emitFiltroAvancado"
|
||||
>
|
||||
<Filter :size="15" :stroke-width="2" />
|
||||
Filtro
|
||||
</button>
|
||||
<EliTabelaCaixaDeBusca :modelo="valorBusca" @buscar="emitBuscar" />
|
||||
|
|
@ -46,12 +48,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Columns3, Filter } from "@lucide/vue"
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
import EliTabelaCaixaDeBusca from "./EliTabelaCaixaDeBusca.vue"
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabelaCabecalho",
|
||||
components: { EliTabelaCaixaDeBusca },
|
||||
components: { Columns3, EliTabelaCaixaDeBusca, Filter },
|
||||
props: {
|
||||
exibirBusca: {
|
||||
type: Boolean,
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export default defineComponent({
|
|||
border-radius: var(--eli-tabela-cabecalho-controle-radius, 8px);
|
||||
border: 1px solid rgba(15, 23, 42, 0.15);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--cor-eli-superficie);
|
||||
|
||||
/* Herda do container do cabeçalho (.eli-tabela__cabecalho) quando presente. */
|
||||
height: var(--eli-tabela-cabecalho-controle-altura, 34px);
|
||||
|
|
@ -104,7 +104,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.eli-tabela__busca-input::placeholder {
|
||||
color: rgba(107, 114, 128, 0.85);
|
||||
color: var(--cor-eli-neutro);
|
||||
}
|
||||
|
||||
.eli-tabela__busca-botao {
|
||||
|
|
@ -112,8 +112,8 @@ export default defineComponent({
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: rgba(37, 99, 235, 0.95);
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
|
@ -126,12 +126,12 @@ export default defineComponent({
|
|||
|
||||
.eli-tabela__busca-botao:hover,
|
||||
.eli-tabela__busca-botao:focus-visible {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
color: rgba(37, 99, 235, 1);
|
||||
background: var(--cor-eli-primaria-clara);
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
}
|
||||
|
||||
.eli-tabela__busca-botao:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.35);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,21 @@
|
|||
<!-- Debug visual (somente DEV): expõe estados internos úteis para testes e diagnóstico -->
|
||||
<div
|
||||
v-if="isDev"
|
||||
style="position: fixed; left: 8px; bottom: 8px; z-index: 999999; background: rgba(185,28,28,0.9); color: #fff; padding: 6px 10px; border-radius: 8px; font-size: 12px; max-width: 500px;"
|
||||
style="position: fixed; right: 0; top: 0; bottom: 0; width: 360px; z-index: 999999; background: rgba(20, 20, 20, 0.95); color: #32CD32; padding: 20px 16px; font-family: 'Courier New', Courier, monospace; font-size: 12px; overflow-y: auto; border-left: 1px solid #333; display: flex; flex-direction: column; gap: 6px;"
|
||||
>
|
||||
<div><b>EliTabela debug</b></div>
|
||||
<div>menuAberto: {{ menuAberto }}</div>
|
||||
<div>menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}</div>
|
||||
<slot />
|
||||
<div style="font-weight: bold; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #333; padding-bottom: 8px;">
|
||||
EliTabela // Log Terminal
|
||||
</div>
|
||||
<div style="color: #bbb;">menuAberto: <span style="color: #fff;">{{ menuAberto }}</span></div>
|
||||
<div style="color: #bbb;">menuPos: top=<span style="color: #fff;">{{ menuPopupPos.top }}</span>, left=<span style="color: #fff;">{{ menuPopupPos.left }}</span></div>
|
||||
<div style="margin-top: 8px; white-space: pre-wrap; word-break: break-all;">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue"
|
||||
import { defineComponent, onMounted, onUnmounted, type PropType, watch } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabelaDebug",
|
||||
|
|
@ -30,5 +34,39 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const applyStyle = () => {
|
||||
let styleTag = document.getElementById("eli-tabela-debug-style")
|
||||
if (props.isDev) {
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement("style")
|
||||
styleTag.id = "eli-tabela-debug-style"
|
||||
styleTag.textContent = `
|
||||
html, body {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
#app, .v-application__wrap, .v-application {
|
||||
padding-right: 360px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.v-container, .stack {
|
||||
overflow-x: auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleTag)
|
||||
}
|
||||
} else {
|
||||
if (styleTag) styleTag.remove()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.isDev, applyStyle)
|
||||
onMounted(applyStyle)
|
||||
onUnmounted(() => {
|
||||
const styleTag = document.getElementById("eli-tabela-debug-style")
|
||||
if (styleTag) styleTag.remove()
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default defineComponent({
|
|||
},
|
||||
colunasInvisiveis: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
type: Array as PropType<Array<tipoEliColuna<any>>>,
|
||||
type: Array as PropType<tipoEliColuna<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,26 +1,33 @@
|
|||
<template>
|
||||
<!-- Estado de carregamento: consulta em andamento -->
|
||||
<div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true">
|
||||
Carregando...
|
||||
<LoaderCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--spin" :size="22" :stroke-width="2" />
|
||||
<span>Carregando…</span>
|
||||
</div>
|
||||
|
||||
<!-- Estado de erro: mostra mensagem retornada pela consulta -->
|
||||
<div v-else-if="erro" class="eli-tabela eli-tabela--erro" role="alert">
|
||||
<div class="eli-tabela__erro-titulo">Erro</div>
|
||||
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
|
||||
<AlertCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--erro" :size="18" :stroke-width="2" />
|
||||
<div>
|
||||
<div class="eli-tabela__erro-titulo">Erro</div>
|
||||
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estado vazio: consulta sem registros -->
|
||||
<div v-else class="eli-tabela eli-tabela--vazio">
|
||||
{{ mensagemVazio ?? "Nenhum registro encontrado." }}
|
||||
<Inbox class="eli-tabela__estado-icone" :size="22" :stroke-width="1.5" />
|
||||
<span>{{ mensagemVazio ?? "Nenhum registro encontrado." }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Inbox, LoaderCircle } from "@lucide/vue"
|
||||
import { defineComponent, type PropType } from "vue"
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabelaEstados",
|
||||
components: { AlertCircle, Inbox, LoaderCircle },
|
||||
props: {
|
||||
carregando: {
|
||||
type: Boolean,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export default defineComponent({
|
|||
props: {
|
||||
colunas: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
type: Array as PropType<Array<tipoEliColuna<any>>>,
|
||||
type: Array as PropType<tipoEliColuna<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
temAcoes: {
|
||||
|
|
@ -109,9 +109,8 @@ export default defineComponent({
|
|||
"alternar-selecao-todos": () => true,
|
||||
},
|
||||
setup(_props, { emit }) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
function isOrdenavel(coluna: any) {
|
||||
return coluna?.coluna_ordem !== undefined && coluna?.coluna_ordem !== null
|
||||
function isOrdenavel(coluna: tipoEliColuna<unknown>): boolean {
|
||||
return coluna.coluna_ordem !== undefined && coluna.coluna_ordem !== null
|
||||
}
|
||||
|
||||
function emitAlternarOrdenacao(chave: string) {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default defineComponent({
|
|||
},
|
||||
acoes: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
|
||||
type: Array as PropType<Array<ItemAcao<any>>>,
|
||||
type: Array as PropType<ItemAcao<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
linha: {
|
||||
|
|
|
|||
342
src/componentes/EliTabela/EliTabelaModalColunas.css
Normal file
342
src/componentes/EliTabela/EliTabelaModalColunas.css
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
.eli-tabela-modal-colunas__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__modal {
|
||||
width: min(820px, 100%);
|
||||
background: var(--cor-eli-superficie);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--cor-eli-borda);
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.eli-tabela-modal-colunas__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--cor-eli-borda);
|
||||
background: var(--cor-eli-primaria-header, #ADCC44);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__header-icone {
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__titulo {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--cor-eli-primaria-texto);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__fechar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__fechar:hover,
|
||||
.eli-tabela-modal-colunas__fechar:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__fechar:focus-visible {
|
||||
outline: 2px solid var(--cor-eli-primaria);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Instrução ── */
|
||||
.eli-tabela-modal-colunas__instrucao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: var(--cor-eli-fundo);
|
||||
border-bottom: 1px solid var(--cor-eli-borda);
|
||||
font-size: 0.78rem;
|
||||
color: var(--cor-eli-neutro);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__instrucao-icone {
|
||||
flex-shrink: 0;
|
||||
color: var(--cor-eli-borda-forte);
|
||||
}
|
||||
|
||||
/* ── Conteúdo ── */
|
||||
.eli-tabela-modal-colunas__conteudo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
|
||||
border-left: 1px solid var(--cor-eli-borda);
|
||||
}
|
||||
|
||||
/* ── Cabeçalho das colunas ── */
|
||||
.eli-tabela-modal-colunas__coluna-cabecalho {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna-cabecalho--visiveis {
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna-cabecalho--invisiveis {
|
||||
color: var(--cor-eli-neutro-escuro);
|
||||
background: var(--cor-eli-fundo);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna-contador {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── Lista ── */
|
||||
.eli-tabela-modal-colunas__lista {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
border: 1.5px dashed var(--cor-eli-borda);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__lista--visiveis {
|
||||
background: rgba(104, 140, 0, 0.02);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__lista--invisiveis {
|
||||
background: var(--cor-eli-fundo);
|
||||
}
|
||||
|
||||
/* ── Item ── */
|
||||
.eli-tabela-modal-colunas__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cor-eli-borda);
|
||||
background: var(--cor-eli-superficie);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item:hover {
|
||||
border-color: var(--cor-eli-borda-forte);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item--invisivel {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item--arrastando {
|
||||
opacity: 0.15;
|
||||
border-style: dashed;
|
||||
border-color: var(--cor-eli-primaria-media);
|
||||
}
|
||||
|
||||
/* ── Partes do item ── */
|
||||
.eli-tabela-modal-colunas__item-handle {
|
||||
color: var(--cor-eli-borda-forte);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item:hover .eli-tabela-modal-colunas__item-handle {
|
||||
color: var(--cor-eli-neutro);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-texto {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--cor-eli-neutro-escuro);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-badge-icone {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-badge-icone--visivel {
|
||||
color: var(--cor-eli-primaria-acao);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-badge-icone--invisivel {
|
||||
color: var(--cor-eli-borda-forte);
|
||||
}
|
||||
|
||||
/* ── Estado vazio ── */
|
||||
.eli-tabela-modal-colunas__vazio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--cor-eli-neutro);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__vazio-icone {
|
||||
color: var(--cor-eli-borda-forte);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__vazio-icone--ok {
|
||||
color: var(--cor-eli-primaria-acao);
|
||||
}
|
||||
|
||||
/* ── Animações TransitionGroup ── */
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.eli-tabela-modal-colunas__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--cor-eli-borda);
|
||||
background: var(--cor-eli-fundo);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cor-eli-borda);
|
||||
background: var(--cor-eli-superficie);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--sec {
|
||||
color: var(--cor-eli-neutro-escuro);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--sec:hover,
|
||||
.eli-tabela-modal-colunas__botao--sec:focus-visible {
|
||||
background: var(--cor-eli-fundo);
|
||||
border-color: var(--cor-eli-borda-forte);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--sec:focus-visible {
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--prim {
|
||||
border: none;
|
||||
background: var(--cor-eli-primaria);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--prim:hover,
|
||||
.eli-tabela-modal-colunas__botao--prim:focus-visible {
|
||||
background: var(--cor-eli-primaria-escura);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--prim:focus-visible {
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Responsivo ── */
|
||||
@media (max-width: 720px) {
|
||||
.eli-tabela-modal-colunas__conteudo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--cor-eli-borda);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,78 +5,124 @@
|
|||
class="eli-tabela-modal-colunas__modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Configurar colunas"
|
||||
aria-labelledby="eli-modal-colunas-titulo"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="eli-tabela-modal-colunas__header">
|
||||
<h3 class="eli-tabela-modal-colunas__titulo">Colunas</h3>
|
||||
<button type="button" class="eli-tabela-modal-colunas__fechar" aria-label="Fechar" @click="emitFechar">
|
||||
×
|
||||
<div class="eli-tabela-modal-colunas__header-info">
|
||||
<Columns2 :size="18" :stroke-width="2" class="eli-tabela-modal-colunas__header-icone" />
|
||||
<h3 id="eli-modal-colunas-titulo" class="eli-tabela-modal-colunas__titulo">Configurar colunas</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="eli-tabela-modal-colunas__fechar"
|
||||
aria-label="Fechar modal de colunas"
|
||||
@click="emitFechar"
|
||||
>
|
||||
<X :size="16" :stroke-width="2.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Instrução contextual -->
|
||||
<div class="eli-tabela-modal-colunas__instrucao">
|
||||
<GripVertical :size="14" :stroke-width="2" class="eli-tabela-modal-colunas__instrucao-icone" />
|
||||
Arraste para reordenar ou mover entre as listas
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo: duas colunas -->
|
||||
<div class="eli-tabela-modal-colunas__conteudo">
|
||||
|
||||
<!-- Visíveis -->
|
||||
<div class="eli-tabela-modal-colunas__coluna">
|
||||
<div class="eli-tabela-modal-colunas__coluna-titulo">Visíveis</div>
|
||||
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--visiveis">
|
||||
<Eye :size="14" :stroke-width="2" />
|
||||
<span>Visíveis</span>
|
||||
<span class="eli-tabela-modal-colunas__coluna-contador">{{ visiveisLocal.length }}</span>
|
||||
</div>
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="eli-tabela-modal-colunas__lista"
|
||||
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--visiveis"
|
||||
@dragover.prevent="onDragOverLista('visiveis')"
|
||||
@drop="onDragEnd"
|
||||
>
|
||||
<div
|
||||
v-for="rotulo in visiveisLocal"
|
||||
:key="`vis-${rotulo}`"
|
||||
class="eli-tabela-modal-colunas__item"
|
||||
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--visivel"
|
||||
:class="{
|
||||
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
|
||||
}"
|
||||
draggable="true"
|
||||
:title="`Arraste para mover '${rotulo}'`"
|
||||
@dragstart="onDragStart($event, rotulo)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover.stop.prevent="onDragOverItem(rotulo, 'visiveis')"
|
||||
@drop.stop.prevent="onDragEnd"
|
||||
>
|
||||
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
|
||||
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
|
||||
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
|
||||
<Eye :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--visivel" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Estado vazio -->
|
||||
<div v-if="visiveisLocal.length === 0" key="vazio-visiveis" class="eli-tabela-modal-colunas__vazio">
|
||||
<EyeOff :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone" />
|
||||
<span>Nenhuma coluna visível.<br>Arraste itens da lista ao lado.</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Invisíveis -->
|
||||
<div class="eli-tabela-modal-colunas__coluna">
|
||||
<div class="eli-tabela-modal-colunas__coluna-titulo">Invisíveis</div>
|
||||
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--invisiveis">
|
||||
<EyeOff :size="14" :stroke-width="2" />
|
||||
<span>Ocultas</span>
|
||||
<span class="eli-tabela-modal-colunas__coluna-contador">{{ invisiveisLocal.length }}</span>
|
||||
</div>
|
||||
<TransitionGroup
|
||||
name="list"
|
||||
tag="div"
|
||||
class="eli-tabela-modal-colunas__lista"
|
||||
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--invisiveis"
|
||||
@dragover.prevent="onDragOverLista('invisiveis')"
|
||||
@drop="onDragEnd"
|
||||
>
|
||||
<div
|
||||
v-for="rotulo in invisiveisLocal"
|
||||
:key="`inv-${rotulo}`"
|
||||
class="eli-tabela-modal-colunas__item"
|
||||
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--invisivel"
|
||||
:class="{
|
||||
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
|
||||
}"
|
||||
draggable="true"
|
||||
:title="`Arraste para mover '${rotulo}'`"
|
||||
@dragstart="onDragStart($event, rotulo)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover.stop.prevent="onDragOverItem(rotulo, 'invisiveis')"
|
||||
@drop.stop.prevent="onDragEnd"
|
||||
>
|
||||
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
|
||||
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
|
||||
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
|
||||
<EyeOff :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--invisivel" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<!-- Estado vazio -->
|
||||
<div v-if="invisiveisLocal.length === 0" key="vazio-invisiveis" class="eli-tabela-modal-colunas__vazio">
|
||||
<Eye :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone eli-tabela-modal-colunas__vazio-icone--ok" />
|
||||
<span>Todas as colunas estão visíveis.</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="eli-tabela-modal-colunas__footer">
|
||||
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--sec" @click="emitFechar">
|
||||
<X :size="14" :stroke-width="2.5" />
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--prim" @click="emitSalvar">
|
||||
<Check :size="14" :stroke-width="2.5" />
|
||||
Salvar
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -86,30 +132,23 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType, ref, watch } from "vue"
|
||||
import { Check, Columns2, Eye, EyeOff, GripVertical, X } from "@lucide/vue"
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
import "./EliTabelaModalColunas.css"
|
||||
import type { EliTabelaColunasConfig } from "./colunasStorage"
|
||||
import type { tipoEliColuna } from "./types-eli-tabela"
|
||||
|
||||
type OrigemLista = "visiveis" | "invisiveis"
|
||||
import { useColunasOrdenacao } from "./useColunasOrdenacao"
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabelaModalColunas",
|
||||
components: { Columns2, Eye, EyeOff, GripVertical, X, Check },
|
||||
props: {
|
||||
aberto: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
rotulosColunas: {
|
||||
type: Array as PropType<string[]>,
|
||||
required: true,
|
||||
},
|
||||
configInicial: {
|
||||
type: Object as PropType<EliTabelaColunasConfig>,
|
||||
required: true,
|
||||
},
|
||||
aberto: { type: Boolean, required: true },
|
||||
rotulosColunas: { type: Array as PropType<string[]>, required: true },
|
||||
configInicial: { type: Object as PropType<EliTabelaColunasConfig>, required: true },
|
||||
colunas: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
|
||||
type: Array as PropType<Array<tipoEliColuna<any>>>,
|
||||
type: Array as PropType<tipoEliColuna<any>[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -122,58 +161,12 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const visiveisLocal = ref<string[]>([])
|
||||
const invisiveisLocal = ref<string[]>([])
|
||||
|
||||
const itemSendoArrastado = ref<string | null>(null)
|
||||
|
||||
function sincronizarEstado() {
|
||||
const todos = props.rotulosColunas
|
||||
const configTemDados =
|
||||
(props.configInicial.visiveis?.length ?? 0) > 0 ||
|
||||
(props.configInicial.invisiveis?.length ?? 0) > 0
|
||||
|
||||
const invisiveisPadraoSet = new Set(
|
||||
props.colunas.filter((c) => c.visivel === false).map((c) => c.rotulo),
|
||||
)
|
||||
|
||||
const invisiveisSet = configTemDados
|
||||
? new Set(props.configInicial.invisiveis ?? [])
|
||||
: invisiveisPadraoSet
|
||||
|
||||
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
|
||||
|
||||
// ordenação: aplica ordem salva (visiveis) e adiciona novas ao final
|
||||
const ordemSalva = props.configInicial.visiveis ?? []
|
||||
const setVis = new Set(baseVisiveis)
|
||||
const ordenadas: string[] = []
|
||||
|
||||
for (const r of ordemSalva) {
|
||||
if (setVis.has(r)) ordenadas.push(r)
|
||||
}
|
||||
for (const r of baseVisiveis) {
|
||||
if (!ordenadas.includes(r)) ordenadas.push(r)
|
||||
}
|
||||
|
||||
visiveisLocal.value = ordenadas
|
||||
|
||||
// invisíveis: somente as que existem na tabela
|
||||
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.aberto,
|
||||
props.rotulosColunas,
|
||||
props.configInicial,
|
||||
props.colunas,
|
||||
] as const,
|
||||
() => {
|
||||
if (props.aberto) sincronizarEstado()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
const ordenacao = useColunasOrdenacao({
|
||||
aberto: computed(() => props.aberto),
|
||||
rotulosColunas: computed(() => props.rotulosColunas),
|
||||
configInicial: computed(() => props.configInicial),
|
||||
colunas: computed(() => props.colunas),
|
||||
})
|
||||
|
||||
function emitFechar() {
|
||||
emit("fechar")
|
||||
|
|
@ -181,242 +174,17 @@ export default defineComponent({
|
|||
|
||||
function emitSalvar() {
|
||||
emit("salvar", {
|
||||
visiveis: [...visiveisLocal.value],
|
||||
invisiveis: [...invisiveisLocal.value],
|
||||
visiveis: [...ordenacao.visiveisLocal.value],
|
||||
invisiveis: [...ordenacao.invisiveisLocal.value],
|
||||
})
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent, rotulo: string) {
|
||||
itemSendoArrastado.value = rotulo
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData("text/plain", rotulo)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
itemSendoArrastado.value = null
|
||||
}
|
||||
|
||||
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
|
||||
const draggedRotulo = itemSendoArrastado.value
|
||||
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
|
||||
|
||||
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origem =
|
||||
origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
|
||||
if (!origem) return
|
||||
|
||||
const origemArr =
|
||||
origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
const destinoArr =
|
||||
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
|
||||
const dragIdx = origemArr.indexOf(draggedRotulo)
|
||||
const alvoIdx = destinoArr.indexOf(alvoRotulo)
|
||||
|
||||
origemArr.splice(dragIdx, 1)
|
||||
destinoArr.splice(alvoIdx, 0, draggedRotulo)
|
||||
}
|
||||
|
||||
function onDragOverLista(destino: OrigemLista) {
|
||||
const draggedRotulo = itemSendoArrastado.value
|
||||
if (!draggedRotulo) return
|
||||
|
||||
const destinoArr =
|
||||
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
|
||||
|
||||
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
|
||||
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
|
||||
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
|
||||
|
||||
destinoArr.push(draggedRotulo)
|
||||
}
|
||||
|
||||
return {
|
||||
visiveisLocal,
|
||||
invisiveisLocal,
|
||||
itemSendoArrastado,
|
||||
...ordenacao,
|
||||
emitFechar,
|
||||
emitSalvar,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOverItem,
|
||||
onDragOverLista,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.eli-tabela-modal-colunas__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__modal {
|
||||
width: min(860px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__titulo {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__fechar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__fechar:hover,
|
||||
.eli-tabela-modal-colunas__fechar:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__conteudo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__coluna-titulo {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__lista {
|
||||
min-height: 260px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(15, 23, 42, 0.01);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
background: #fff;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: box-shadow 0.15s, opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item--arrastando {
|
||||
opacity: 0.1;
|
||||
background: #f8fafc;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* Animações de reposicionamento e listas do Vue TransitionGroup */
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.list-leave-active {
|
||||
/* position absolute para suavizar a animação de saída de outras listas */
|
||||
position: absolute;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-handle {
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__item-texto {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--sec:hover,
|
||||
.eli-tabela-modal-colunas__botao--sec:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--prim {
|
||||
border: none;
|
||||
background: rgba(37, 99, 235, 0.95);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-colunas__botao--prim:hover,
|
||||
.eli-tabela-modal-colunas__botao--prim:focus-visible {
|
||||
background: rgba(37, 99, 235, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.eli-tabela-modal-colunas__conteudo {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
443
src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css
Normal file
443
src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
.eli-tabela-modal-filtro__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__modal {
|
||||
width: min(980px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────── */
|
||||
.eli-tabela-modal-filtro__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__header-titulo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__titulo {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__header-icone {
|
||||
color: var(--cor-eli-primaria);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__contador {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--cor-eli-primaria);
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__fechar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__fechar:hover,
|
||||
.eli-tabela-modal-filtro__fechar:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
/* ── Conteúdo ────────────────────────────────── */
|
||||
.eli-tabela-modal-filtro__conteudo {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__vazio {
|
||||
opacity: 0.65;
|
||||
font-size: 0.85rem;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
/* ── Estado vazio orientado ──────────────────── */
|
||||
.eli-tabela-modal-filtro__estado-vazio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 28px 16px;
|
||||
color: rgba(15, 23, 42, 0.35);
|
||||
border: 1px dashed rgba(15, 23, 42, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.015);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__estado-vazio span {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Lista de filtros (2 colunas) ────────────── */
|
||||
.eli-tabela-modal-filtro__lista {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__linha {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(15, 23, 42, 0.015);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__linha:hover {
|
||||
border-color: var(--cor-eli-primaria-clara);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__linha-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__rotulo-filtro {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: rgba(15, 23, 42, 0.65);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__id-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
color: var(--cor-eli-primaria);
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__entrada {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__remover {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: rgba(15, 23, 42, 0.35);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__remover:hover,
|
||||
.eli-tabela-modal-filtro__remover:focus-visible {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: rgba(220, 38, 38, 0.9);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ── Rodapé: select + fórmula lado a lado ────── */
|
||||
.eli-tabela-modal-filtro__rodape-filtros {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.07);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__acoes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__select {
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(15, 23, 42, 0.85);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--cor-eli-primaria);
|
||||
}
|
||||
|
||||
/* ── Fórmula de combinação ───────────────────── */
|
||||
.eli-tabela-modal-filtro__formula-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: rgba(15, 23, 42, 0.55);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.eli-tabela-modal-filtro__formula-ajuda {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: rgba(15, 23, 42, 0.35);
|
||||
cursor: help;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-ajuda:hover,
|
||||
.eli-tabela-modal-filtro__formula-ajuda:focus-visible {
|
||||
color: var(--cor-eli-primaria);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-ajuda:hover .eli-tabela-modal-filtro__formula-tooltip,
|
||||
.eli-tabela-modal-filtro__formula-ajuda:focus-visible .eli-tabela-modal-filtro__formula-tooltip {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 280px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
z-index: 100;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 6px;
|
||||
border: 6px solid transparent;
|
||||
border-top-color: #0f172a;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--cor-eli-fundo);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip p {
|
||||
margin: 6px 0 4px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip ul {
|
||||
margin: 4px 0 8px 14px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip li {
|
||||
margin-bottom: 3px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
color: #7dd3fc;
|
||||
font-size: 0.78rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-input {
|
||||
width: 220px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
padding: 0 10px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.85rem;
|
||||
background: var(--cor-eli-fundo);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--cor-eli-primaria);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__formula-tooltip-nota {
|
||||
margin-top: 8px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────── */
|
||||
.eli-tabela-modal-filtro__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Botões ──────────────────────────────────── */
|
||||
.eli-tabela-modal-filtro__botao {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--sec:hover:not(:disabled),
|
||||
.eli-tabela-modal-filtro__botao--sec:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--prim {
|
||||
border: none;
|
||||
background: var(--cor-eli-primaria);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--prim:hover:not(:disabled),
|
||||
.eli-tabela-modal-filtro__botao--prim:focus-visible {
|
||||
background: var(--cor-eli-primaria-escura);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--perigo {
|
||||
color: rgba(220, 38, 38, 0.85);
|
||||
border-color: rgba(220, 38, 38, 0.18);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--perigo:hover:not(:disabled),
|
||||
.eli-tabela-modal-filtro__botao--perigo:focus-visible {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
color: rgba(220, 38, 38, 1);
|
||||
}
|
||||
|
||||
/* ── Responsivo ──────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.eli-tabela-modal-filtro__lista {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.eli-tabela-modal-filtro__rodape-filtros {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.eli-tabela-modal-filtro__acoes {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Transição de abertura/fechamento ────────── */
|
||||
.eli-tabela-modal-filtro-enter-active,
|
||||
.eli-tabela-modal-filtro-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro-enter-active .eli-tabela-modal-filtro__modal,
|
||||
.eli-tabela-modal-filtro-leave-active .eli-tabela-modal-filtro__modal {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro-enter-from,
|
||||
.eli-tabela-modal-filtro-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro-enter-from .eli-tabela-modal-filtro__modal,
|
||||
.eli-tabela-modal-filtro-leave-to .eli-tabela-modal-filtro__modal {
|
||||
transform: scale(0.96);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
@ -1,403 +1,182 @@
|
|||
<template>
|
||||
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
|
||||
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
|
||||
<header class="eli-tabela-modal-filtro__header">
|
||||
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
|
||||
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<Transition name="eli-tabela-modal-filtro">
|
||||
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
|
||||
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
|
||||
<header class="eli-tabela-modal-filtro__header">
|
||||
<div class="eli-tabela-modal-filtro__header-titulo">
|
||||
<Filter class="eli-tabela-modal-filtro__header-icone" :size="18" :stroke-width="2" />
|
||||
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
|
||||
<span v-if="linhas.length" class="eli-tabela-modal-filtro__contador">
|
||||
{{ linhas.length }} {{ linhas.length === 1 ? 'filtro' : 'filtros' }}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
|
||||
<X :size="18" :stroke-width="2" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="eli-tabela-modal-filtro__conteudo">
|
||||
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
|
||||
Nenhum filtro configurado na tabela.
|
||||
</div>
|
||||
<div class="eli-tabela-modal-filtro__conteudo">
|
||||
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
|
||||
Nenhum filtro configurado na tabela.
|
||||
</div>
|
||||
|
||||
<div v-else class="eli-tabela-modal-filtro__lista">
|
||||
<div v-for="(linha, idx) in linhas" :key="String(linha.chave)" class="eli-tabela-modal-filtro__linha">
|
||||
<div class="eli-tabela-modal-filtro__entrada">
|
||||
<component
|
||||
:is="componenteEntrada(linha.entrada)"
|
||||
:value="linha.valor"
|
||||
@update:value="(v: any) => (linha.valor = v)"
|
||||
@input="(v: any) => (linha.valor = v)"
|
||||
:opcoes="opcoesEntrada(linha.entrada)"
|
||||
density="compact"
|
||||
/>
|
||||
<div v-else>
|
||||
<!-- Lista de filtros ativos -->
|
||||
<div v-if="linhas.length" class="eli-tabela-modal-filtro__lista">
|
||||
<div
|
||||
v-for="(linha, idx) in linhas"
|
||||
:key="String(linha.chave)"
|
||||
class="eli-tabela-modal-filtro__linha"
|
||||
>
|
||||
<div class="eli-tabela-modal-filtro__linha-header">
|
||||
<span
|
||||
class="eli-tabela-modal-filtro__id-badge"
|
||||
:title="`Identificador na fórmula: {c${idx + 1}}`"
|
||||
>
|
||||
c{{ idx + 1 }}
|
||||
</span>
|
||||
<span class="eli-tabela-modal-filtro__rotulo-filtro">{{ rotuloDoFiltroAtivo(linha.chave) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eli-tabela-modal-filtro__remover"
|
||||
title="Remover filtro"
|
||||
aria-label="Remover filtro"
|
||||
@click="remover(idx)"
|
||||
>
|
||||
<Trash2 :size="14" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="eli-tabela-modal-filtro__entrada">
|
||||
<component
|
||||
:is="componenteEntrada(linha.entrada)"
|
||||
v-bind="({ value: linha.valor, opcoes: opcoesEntrada(linha.entrada), density: 'compact' } as Record<string, unknown>)"
|
||||
@update:value="(v: unknown) => (linha.valor = v)"
|
||||
@input="(v: unknown) => (linha.valor = v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="eli-tabela-modal-filtro__remover"
|
||||
title="Remover"
|
||||
aria-label="Remover"
|
||||
@click="remover(idx)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<!-- Estado vazio orientado -->
|
||||
<div v-else class="eli-tabela-modal-filtro__estado-vazio">
|
||||
<Filter :size="32" :stroke-width="1.5" />
|
||||
<span>Selecione um filtro abaixo para começar</span>
|
||||
</div>
|
||||
|
||||
<!-- Rodapé: adicionar filtro + fórmula lado a lado -->
|
||||
<div class="eli-tabela-modal-filtro__rodape-filtros">
|
||||
<div class="eli-tabela-modal-filtro__acoes">
|
||||
<select
|
||||
v-model="chaveParaAdicionar"
|
||||
class="eli-tabela-modal-filtro__select"
|
||||
:disabled="!opcoesParaAdicionar.length"
|
||||
>
|
||||
<option disabled value="">Selecione um filtro…</option>
|
||||
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
|
||||
{{ rotuloDoFiltro(o) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim"
|
||||
@click="adicionar"
|
||||
:disabled="!chaveParaAdicionar"
|
||||
>
|
||||
<Plus :size="15" :stroke-width="2.5" />
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fórmula: só aparece com 2+ filtros (com 1 filtro, fórmula não faz sentido) -->
|
||||
<div v-if="linhas.length >= 2" class="eli-tabela-modal-filtro__formula-container">
|
||||
<label class="eli-tabela-modal-filtro__formula-label">Fórmula</label>
|
||||
<input
|
||||
v-model="formulaCustomizada"
|
||||
type="text"
|
||||
class="eli-tabela-modal-filtro__formula-input"
|
||||
:placeholder="'Ex: ' + formulaGerada"
|
||||
/>
|
||||
<span class="eli-tabela-modal-filtro__formula-ajuda" tabindex="0" aria-label="Ajuda sobre fórmulas">
|
||||
<CircleHelp :size="15" :stroke-width="2" />
|
||||
<div class="eli-tabela-modal-filtro__formula-tooltip" role="tooltip">
|
||||
<strong>Como usar a fórmula</strong>
|
||||
<p>Use os identificadores <code>{c1}</code>, <code>{c2}</code>, <code>{c3}</code>… correspondentes a cada filtro acima:</p>
|
||||
<ul>
|
||||
<li><code>E</code> — ambos devem ser verdadeiros</li>
|
||||
<li><code>OU</code> — pelo menos um deve ser verdadeiro</li>
|
||||
<li><code>NAO</code> — negação de uma condição</li>
|
||||
<li><code>( )</code> — agrupa condições</li>
|
||||
</ul>
|
||||
<p><strong>Exemplo:</strong></p>
|
||||
<code>{c1} OU ({c2} E NAO {c3})</code>
|
||||
<p class="eli-tabela-modal-filtro__formula-tooltip-nota">Deixe em branco para combinar todos com E.</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="eli-tabela-modal-filtro__acoes">
|
||||
<select
|
||||
v-model="chaveParaAdicionar"
|
||||
class="eli-tabela-modal-filtro__select"
|
||||
:disabled="!opcoesParaAdicionar.length"
|
||||
>
|
||||
<option disabled value="">Selecione um filtro…</option>
|
||||
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
|
||||
{{ rotuloDoFiltro(o) }}
|
||||
</option>
|
||||
</select>
|
||||
<footer class="eli-tabela-modal-filtro__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="eli-tabela-modal-filtro__botao"
|
||||
@click="adicionar"
|
||||
:disabled="!chaveParaAdicionar"
|
||||
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--perigo"
|
||||
:disabled="!linhas.length"
|
||||
@click="emitLimpar"
|
||||
>
|
||||
Adicionar
|
||||
Limpar
|
||||
</button>
|
||||
</div>
|
||||
<div class="eli-tabela-modal-filtro__footer-spacer"></div>
|
||||
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
|
||||
Aplicar
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<footer class="eli-tabela-modal-filtro__footer">
|
||||
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitLimpar">
|
||||
Limpar
|
||||
</button>
|
||||
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
|
||||
Aplicar
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, type PropType, ref, watch } from "vue"
|
||||
import {
|
||||
EliEntradaDataHora,
|
||||
EliEntradaNumero,
|
||||
EliEntradaTexto,
|
||||
} from "../EliEntrada"
|
||||
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
|
||||
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
|
||||
|
||||
type FiltroBase<T> = NonNullable<
|
||||
tipoEliTabelaConsulta<T>["filtroAvancado"]
|
||||
>[number]
|
||||
|
||||
type LinhaFiltro = {
|
||||
chave: string
|
||||
entrada: ComponenteEntrada
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
|
||||
valor: any
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
|
||||
function rotuloDoFiltro(f: FiltroBase<any>) {
|
||||
return f.rotulo
|
||||
}
|
||||
import { CircleHelp, Filter, Plus, Trash2, X } from "@lucide/vue"
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
import type { FiltroBase } from "./useFiltroAvancado"
|
||||
// biome-ignore lint/correctness/noUnusedImports: rotuloDoFiltro é usado no template Vue (invisível ao linter estático)
|
||||
import { rotuloDoFiltro, useFiltroAvancado } from "./useFiltroAvancado"
|
||||
import "./EliTabelaModalFiltroAvancado.css"
|
||||
|
||||
export default defineComponent({
|
||||
name: "EliTabelaModalFiltroAvancado",
|
||||
components: { CircleHelp, Filter, Plus, Trash2, X },
|
||||
props: {
|
||||
aberto: { type: Boolean, required: true },
|
||||
filtrosBase: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic component
|
||||
type: Array as PropType<Array<FiltroBase<any>>>,
|
||||
required: true,
|
||||
},
|
||||
filtrosBase: { type: Array as PropType<FiltroBase<string>[]>, required: true },
|
||||
modelo: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic model
|
||||
type: Array as PropType<Array<any>>,
|
||||
/** Linhas persistidas do pai — normalizadas internamente via normalizarModelo() */
|
||||
type: Array as PropType<Array<{ chave: string; valor: unknown }>>,
|
||||
required: true,
|
||||
},
|
||||
modeloFormula: { type: String, default: "" },
|
||||
},
|
||||
emits: {
|
||||
fechar: () => true,
|
||||
limpar: () => true,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic lines
|
||||
salvar: (_linhas: any[]) => true,
|
||||
/** Dado serializável emitido pelo botão Aplicar */
|
||||
salvar: (_linhas: Array<{ chave: string; valor: unknown }>, _formula?: string) => true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const linhas = ref<Array<LinhaFiltro>>([])
|
||||
|
||||
const chaveParaAdicionar = ref<string>("")
|
||||
|
||||
const chavesDisponiveis = computed(() =>
|
||||
(props.filtrosBase ?? []).map((b) => String(b.chave)),
|
||||
)
|
||||
|
||||
const opcoesParaAdicionar = computed(() => {
|
||||
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
|
||||
return (props.filtrosBase ?? []).filter(
|
||||
(b) => !usadas.has(String(b.chave)),
|
||||
)
|
||||
return useFiltroAvancado({
|
||||
aberto: computed(() => props.aberto),
|
||||
filtrosBase: computed(() => props.filtrosBase),
|
||||
modelo: computed(() => props.modelo),
|
||||
modeloFormula: computed(() => props.modeloFormula),
|
||||
emit,
|
||||
})
|
||||
|
||||
function componenteEntrada(entrada: ComponenteEntrada) {
|
||||
const tipo = entrada?.[0]
|
||||
if (tipo === "numero") return EliEntradaNumero
|
||||
if (tipo === "dataHora") return EliEntradaDataHora
|
||||
return EliEntradaTexto
|
||||
}
|
||||
|
||||
function opcoesEntrada(entrada: ComponenteEntrada) {
|
||||
// o rótulo vem do próprio componente (entrada[1].rotulo)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic options
|
||||
return (entrada?.[1] as any) ?? { rotulo: "" }
|
||||
}
|
||||
|
||||
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
|
||||
const tipo = entrada?.[0]
|
||||
if (tipo === "numero") return null
|
||||
return ""
|
||||
}
|
||||
|
||||
function normalizarModelo() {
|
||||
const base = props.filtrosBase ?? []
|
||||
const modelo = Array.isArray(props.modelo) ? props.modelo : []
|
||||
linhas.value = modelo
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic model item
|
||||
.map((m: any) => {
|
||||
const baseItem =
|
||||
base.find((b) => String(b.chave) === String(m.chave)) ?? base[0]
|
||||
|
||||
if (!baseItem) return null
|
||||
|
||||
const entrada = (baseItem.entrada ?? m.entrada) as ComponenteEntrada
|
||||
const chave = String(baseItem.chave ?? m.chave)
|
||||
const val = m.valor ?? valorInicialPorEntrada(entrada)
|
||||
|
||||
return {
|
||||
chave: chave,
|
||||
entrada,
|
||||
valor: val,
|
||||
} as LinhaFiltro
|
||||
})
|
||||
.filter(Boolean) as LinhaFiltro[]
|
||||
|
||||
// se algum filtro mudou a chave para valor inválido, ajusta
|
||||
linhas.value = linhas.value.filter((l) =>
|
||||
chavesDisponiveis.value.includes(String(l.chave)),
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.aberto, props.filtrosBase, props.modelo] as const,
|
||||
() => {
|
||||
if (props.aberto) normalizarModelo()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
function adicionar() {
|
||||
if (!chaveParaAdicionar.value) return
|
||||
const b0 = (props.filtrosBase ?? []).find(
|
||||
(b) => String(b.chave) === String(chaveParaAdicionar.value),
|
||||
)
|
||||
if (!b0) return
|
||||
// evita repetição
|
||||
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
|
||||
|
||||
linhas.value.push({
|
||||
chave: String(b0.chave),
|
||||
entrada: b0.entrada,
|
||||
valor: valorInicialPorEntrada(b0.entrada),
|
||||
})
|
||||
|
||||
chaveParaAdicionar.value = ""
|
||||
}
|
||||
|
||||
function remover(idx: number) {
|
||||
linhas.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function emitFechar() {
|
||||
emit("fechar")
|
||||
}
|
||||
|
||||
function emitLimpar() {
|
||||
emit("limpar")
|
||||
}
|
||||
|
||||
function emitSalvar() {
|
||||
emit(
|
||||
"salvar",
|
||||
linhas.value.map((l) => ({
|
||||
chave: l.chave,
|
||||
valor: l.valor,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
linhas,
|
||||
opcoesParaAdicionar,
|
||||
chaveParaAdicionar,
|
||||
componenteEntrada,
|
||||
opcoesEntrada,
|
||||
adicionar,
|
||||
remover,
|
||||
emitFechar,
|
||||
emitSalvar,
|
||||
emitLimpar,
|
||||
rotuloDoFiltro,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.eli-tabela-modal-filtro__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__modal {
|
||||
width: min(980px, 100%);
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__titulo {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__fechar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__fechar:hover,
|
||||
.eli-tabela-modal-filtro__fechar:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__conteudo {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__vazio {
|
||||
opacity: 0.75;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__lista {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__linha {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 34px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__select {
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
padding: 0 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__entrada {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__remover {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__remover:hover,
|
||||
.eli-tabela-modal-filtro__remover:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__acoes {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--sec:hover,
|
||||
.eli-tabela-modal-filtro__botao--sec:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--prim {
|
||||
border: none;
|
||||
background: rgba(37, 99, 235, 0.95);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eli-tabela-modal-filtro__botao--prim:hover,
|
||||
.eli-tabela-modal-filtro__botao--prim:focus-visible {
|
||||
background: rgba(37, 99, 235, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.eli-tabela-modal-filtro__linha {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
87
src/componentes/EliTabela/EliTabelaPaginacao.css
Normal file
87
src/componentes/EliTabela/EliTabelaPaginacao.css
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
.eli-tabela__paginacao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: var(--cor-eli-superficie);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(15, 23, 42, 0.82);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:hover,
|
||||
.eli-tabela__pagina-botao:focus-visible {
|
||||
background-color: var(--cor-eli-primaria-bg);
|
||||
border-color: var(--cor-eli-primaria-clara);
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:focus-visible {
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
color: rgba(71, 85, 105, 0.75);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao--ativo {
|
||||
background: var(--cor-eli-primaria-bg);
|
||||
border-color: var(--cor-eli-primaria);
|
||||
color: var(--cor-eli-primaria-escura);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
color: var(--cor-eli-neutro);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--cor-eli-primaria);
|
||||
background: var(--cor-eli-superficie);
|
||||
color: var(--cor-eli-primaria);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior:hover,
|
||||
.eli-tabela__acao-inferior:focus-visible {
|
||||
background-color: var(--cor-eli-primaria-bg);
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior:focus-visible {
|
||||
outline: 2px solid var(--cor-eli-primaria);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
|
||||
import "./EliTabelaPaginacao.css"
|
||||
export default defineComponent({
|
||||
name: "EliTabelaPaginacao",
|
||||
props: {
|
||||
|
|
@ -209,9 +209,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
const anteriorDesabilitado = computed(() => props.pagina <= 1)
|
||||
const proximaDesabilitada = computed(
|
||||
() => props.pagina >= props.totalPaginas,
|
||||
)
|
||||
const proximaDesabilitada = computed(() => props.pagina >= props.totalPaginas)
|
||||
const paginaAtual = computed(() => props.pagina)
|
||||
const totalPaginasExibidas = computed(() => props.totalPaginas)
|
||||
|
||||
|
|
@ -227,92 +225,3 @@ export default defineComponent({
|
|||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.eli-tabela__paginacao {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.12);
|
||||
background: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(15, 23, 42, 0.82);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:hover,
|
||||
.eli-tabela__pagina-botao:focus-visible {
|
||||
background-color: rgba(37, 99, 235, 0.08);
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
color: rgba(37, 99, 235, 0.95);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-color: rgba(148, 163, 184, 0.18);
|
||||
color: rgba(71, 85, 105, 0.75);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-botao--ativo {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
color: rgba(37, 99, 235, 0.95);
|
||||
}
|
||||
|
||||
.eli-tabela__pagina-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
color: rgba(107, 114, 128, 0.85);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #16a34a; /* default green */
|
||||
background: #ffffff;
|
||||
color: #16a34a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior:hover,
|
||||
.eli-tabela__acao-inferior:focus-visible {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.eli-tabela__acao-inferior:focus-visible {
|
||||
outline: 2px solid #16a34a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -29,15 +29,11 @@ export default defineComponent({
|
|||
},
|
||||
setup(props) {
|
||||
const tipo = computed(() => props.celula[0] as tipoTabelaCelula)
|
||||
const dados = computed(
|
||||
() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula],
|
||||
)
|
||||
const dados = computed(() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula])
|
||||
|
||||
// Observação: mantemos o registry tipado, mas o TS do template não consegue
|
||||
// fazer narrowing do componente com base em `tipo`, então tipamos como `Component`.
|
||||
const Componente = computed(
|
||||
() => registryTabelaCelulas[tipo.value] as unknown as Component,
|
||||
)
|
||||
const Componente = computed(() => registryTabelaCelulas[tipo.value] as unknown as Component)
|
||||
|
||||
const dadosParaComponente = computed(() => dados.value)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { dayjsbr } from "p-comuns"
|
||||
import dayjs from "dayjs"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import "dayjs/locale/pt-br"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale("pt-br")
|
||||
|
||||
import { computed, defineComponent, type PropType } from "vue"
|
||||
import type { tiposTabelaCelulas } from "./tiposTabelaCelulas"
|
||||
|
||||
|
|
@ -24,27 +30,27 @@ export default defineComponent({
|
|||
required: false,
|
||||
},
|
||||
},
|
||||
setup({ dados }) {
|
||||
setup(props) {
|
||||
const textoData = computed(() => {
|
||||
const valorIso = dados?.valor
|
||||
const valorIso = props.dados?.valor
|
||||
if (!valorIso) return ""
|
||||
|
||||
const formato = dados?.formato ?? "data"
|
||||
const formato = props.dados?.formato ?? "data"
|
||||
|
||||
if (formato === "relativo") {
|
||||
return dayjsbr(valorIso).fromNow()
|
||||
return dayjs(valorIso).fromNow()
|
||||
}
|
||||
|
||||
if (formato === "data_hora") {
|
||||
// Padrão pt-BR simples (sem depender de locale do dayjsbr)
|
||||
return dayjsbr(valorIso).format("DD/MM/YYYY HH:mm")
|
||||
return dayjs(valorIso).format("DD/MM/YYYY HH:mm")
|
||||
}
|
||||
|
||||
// formato === "data"
|
||||
return dayjsbr(valorIso).format("DD/MM/YYYY")
|
||||
return dayjs(valorIso).format("DD/MM/YYYY")
|
||||
})
|
||||
|
||||
return { dados, textoData }
|
||||
return { textoData }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -56,7 +62,7 @@ export default defineComponent({
|
|||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(37, 99, 235, 0.55);
|
||||
text-decoration-color: var(--cor-eli-primaria-clara);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -66,7 +72,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.eli-tabela__celula-link:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,12 +35,12 @@ export default defineComponent({
|
|||
type: Object as PropType<tiposTabelaCelulas["numero"]>,
|
||||
},
|
||||
},
|
||||
setup({ dados }) {
|
||||
setup(props) {
|
||||
const textoNumero = computed(() => {
|
||||
// Mantemos o comportamento anterior (trocar "." por ","), mas agora suportamos prefixo/sufixo.
|
||||
const numero = String(dados?.numero).replace(".", ",")
|
||||
const prefixo = dados?.prefixo?.trim()
|
||||
const sufixo = dados?.sufixo?.trim()
|
||||
const numero = String(props.dados?.numero).replace(".", ",")
|
||||
const prefixo = props.dados?.prefixo?.trim()
|
||||
const sufixo = props.dados?.sufixo?.trim()
|
||||
|
||||
const inicio = prefixo ? `${prefixo} ` : ""
|
||||
const fim = sufixo ? ` ${sufixo}` : ""
|
||||
|
|
@ -48,7 +48,7 @@ export default defineComponent({
|
|||
return `${inicio}${numero}${fim}`
|
||||
})
|
||||
|
||||
return { dados, textoNumero }
|
||||
return { textoNumero }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -60,7 +60,7 @@ export default defineComponent({
|
|||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(37, 99, 235, 0.55);
|
||||
text-decoration-color: var(--cor-eli-primaria-clara);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.eli-tabela__celula-link:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ export default defineComponent({
|
|||
required: false,
|
||||
},
|
||||
},
|
||||
setup({ dados }) {
|
||||
return { dados }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,6 @@ export default defineComponent({
|
|||
return {}
|
||||
},
|
||||
methods: {},
|
||||
setup({ dados }) {
|
||||
return { dados }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -39,7 +36,7 @@ export default defineComponent({
|
|||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(37, 99, 235, 0.55);
|
||||
text-decoration-color: var(--cor-eli-primaria-clara);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +46,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.eli-tabela__celula-link:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,9 +23,6 @@ export default defineComponent({
|
|||
type: Object as PropType<tiposTabelaCelulas["textoTruncado"]>,
|
||||
},
|
||||
},
|
||||
setup({ dados }) {
|
||||
return { dados }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -45,7 +42,7 @@ export default defineComponent({
|
|||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(37, 99, 235, 0.55);
|
||||
text-decoration-color: var(--cor-eli-primaria-clara);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +52,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.eli-tabela__celula-link:focus-visible {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.45);
|
||||
outline: 2px solid var(--cor-eli-primaria-clara);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,16 @@ import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue"
|
|||
import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue"
|
||||
import type { tipoTabelaCelula } from "./tiposTabelaCelulas"
|
||||
|
||||
/**
|
||||
* Mapa (dispatch table) que associa cada `tipoTabelaCelula` ao seu componente Vue de renderização.
|
||||
* Usado por `EliTabelaCelula.vue` para resolver dinamicamente qual componente renderizar
|
||||
* a partir do tipo retornado por `tipoEliColuna.celula(linha)`.
|
||||
*
|
||||
* Adicionar um novo tipo de célula requer:
|
||||
* 1. Criar o componente `EliTabelaCelula<Tipo>.vue`
|
||||
* 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts)
|
||||
* 3. Registrar aqui no mapa
|
||||
*/
|
||||
export const registryTabelaCelulas = {
|
||||
textoSimples: EliTabelaCelulaTextoSimples,
|
||||
textoTruncado: EliTabelaCelulaTextoTruncado,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
export type EliTabelaColunasConfig = {
|
||||
/** Rotulos das colunas visiveis (em ordem). */
|
||||
/** Rótulos das colunas visíveis (em ordem). */
|
||||
visiveis: string[]
|
||||
/** Rotulos das colunas invisiveis. */
|
||||
/** Rótulos das colunas invisíveis. */
|
||||
invisiveis: string[]
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = "eli:tabela"
|
||||
|
||||
/**
|
||||
* Retorna a chave de `localStorage` utilizada para persistir a configuração de colunas de uma tabela.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
|
||||
* @returns Chave no formato `eli:tabela:<nomeTabela>:colunas`
|
||||
*/
|
||||
export function storageKeyColunas(nomeTabela: string) {
|
||||
return `${STORAGE_PREFIX}:${nomeTabela}:colunas`
|
||||
}
|
||||
|
|
@ -26,9 +32,14 @@ function normalizarConfig(valor: unknown): EliTabelaColunasConfig {
|
|||
return { visiveis, invisiveis }
|
||||
}
|
||||
|
||||
export function carregarConfigColunas(
|
||||
nomeTabela: string,
|
||||
): EliTabelaColunasConfig {
|
||||
/**
|
||||
* Carrega do `localStorage` a configuração de visibilidade e ordem das colunas de uma tabela.
|
||||
* Em caso de dado corrompido ou ausente, retorna configuração vazia (sem colunas fixadas).
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela
|
||||
* @returns Configuração salva ou `{ visiveis: [], invisiveis: [] }` como fallback seguro
|
||||
*/
|
||||
export function carregarConfigColunas(nomeTabela: string): EliTabelaColunasConfig {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKeyColunas(nomeTabela))
|
||||
if (!raw) return { visiveis: [], invisiveis: [] }
|
||||
|
|
@ -38,24 +49,34 @@ export function carregarConfigColunas(
|
|||
}
|
||||
}
|
||||
|
||||
export function salvarConfigColunas(
|
||||
nomeTabela: string,
|
||||
config: EliTabelaColunasConfig,
|
||||
) {
|
||||
/**
|
||||
* Persiste no `localStorage` a configuração de visibilidade e ordem das colunas.
|
||||
* Falhas de armazenamento (modo privado, storage cheio) são silenciadas.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela
|
||||
* @param config - Configuração com listas de colunas visíveis e invisíveis
|
||||
*/
|
||||
export function salvarConfigColunas(nomeTabela: string, config: EliTabelaColunasConfig) {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKeyColunas(nomeTabela),
|
||||
JSON.stringify(normalizarConfig(config)),
|
||||
)
|
||||
} catch {
|
||||
// ignore
|
||||
// Silencia erros de storage (modo privado, quota excedida)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove do `localStorage` a configuração de colunas salva para uma tabela.
|
||||
* Usado para restaurar o estado padrão de visibilidade definido nas props.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela
|
||||
*/
|
||||
export function limparConfigColunas(nomeTabela: string) {
|
||||
try {
|
||||
window.localStorage.removeItem(storageKeyColunas(nomeTabela))
|
||||
} catch {
|
||||
// ignore
|
||||
// Silencia erros de storage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,77 @@
|
|||
export type EliTabelaFiltroAvancadoSalvo<T> = Array<{
|
||||
coluna: keyof T
|
||||
|
||||
valor: T[keyof T]
|
||||
}>
|
||||
export type EliTabelaFiltroAvancadoSalvo<T> = {
|
||||
linhas: Array<{
|
||||
chave: keyof T
|
||||
/** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */
|
||||
valor: unknown
|
||||
}>
|
||||
formula?: string
|
||||
}
|
||||
|
||||
function key(nomeTabela: string) {
|
||||
return `eli_tabela:${nomeTabela}:filtro_avancado`
|
||||
}
|
||||
|
||||
export function carregarFiltroAvancado<T>(
|
||||
nomeTabela: string,
|
||||
): EliTabelaFiltroAvancadoSalvo<T> {
|
||||
/**
|
||||
* Carrega do `localStorage` as linhas de filtro avançado salvas para uma tabela.
|
||||
* Trata dois formatos legados: array direto de filtros (v1) e objeto `{ linhas, formula }` (v2).
|
||||
* Em caso de dado ausente, corrompido ou formato desconhecido, retorna estado vazio.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
|
||||
* @returns Estado de filtros salvo, ou `{ linhas: [], formula: '' }` como fallback seguro
|
||||
*/
|
||||
export function carregarFiltroAvancado<T>(nomeTabela: string): EliTabelaFiltroAvancadoSalvo<T> {
|
||||
const vazio: EliTabelaFiltroAvancadoSalvo<T> = { linhas: [], formula: "" }
|
||||
try {
|
||||
const raw = localStorage.getItem(key(nomeTabela))
|
||||
if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
|
||||
if (!raw) return vazio
|
||||
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed)
|
||||
? (parsed as EliTabelaFiltroAvancadoSalvo<T>)
|
||||
: ([] as unknown as EliTabelaFiltroAvancadoSalvo<T>)
|
||||
if (Array.isArray(parsed)) {
|
||||
return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo<T>
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return {
|
||||
linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [],
|
||||
formula: typeof parsed.formula === "string" ? parsed.formula : "",
|
||||
} as EliTabelaFiltroAvancadoSalvo<T>
|
||||
}
|
||||
|
||||
return vazio
|
||||
} catch {
|
||||
return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
|
||||
return vazio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste no `localStorage` o estado do filtro avançado de uma tabela.
|
||||
* Salva tanto as linhas de filtro quanto a fórmula booleana customizada.
|
||||
* Falhas de armazenamento são silenciadas para não interromper o fluxo do usuário.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela
|
||||
* @param filtros - Estado completo dos filtros a persistir
|
||||
*/
|
||||
export function salvarFiltroAvancado<T>(
|
||||
nomeTabela: string,
|
||||
filtros: EliTabelaFiltroAvancadoSalvo<T>,
|
||||
) {
|
||||
try {
|
||||
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? []))
|
||||
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? { linhas: [] }))
|
||||
} catch {
|
||||
// ignore
|
||||
// Silencia erros de storage (modo privado, quota excedida)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove do `localStorage` os filtros avançados salvos para uma tabela.
|
||||
* Chamado ao executar "Limpar Filtros" no modal de filtro avançado.
|
||||
*
|
||||
* @param nomeTabela - Identificador único da tabela
|
||||
*/
|
||||
export function limparFiltroAvancado(nomeTabela: string) {
|
||||
try {
|
||||
localStorage.removeItem(key(nomeTabela))
|
||||
} catch {
|
||||
// ignore
|
||||
// Silencia erros de storage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
import type { LucideIcon } from "@lucide/vue"
|
||||
import type { tipoFiltro26 } from "p-comuns"
|
||||
|
||||
export type tipoFiltro<T> = tipoFiltro26<T>
|
||||
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
|
||||
|
||||
import type { tipoResposta } from "p-respostas"
|
||||
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
|
||||
import type {
|
||||
tiposTabelaCelulas,
|
||||
tipoTabelaCelula,
|
||||
} from "./celulas/tiposTabelaCelulas"
|
||||
import type { tiposTabelaCelulas, tipoTabelaCelula } from "./celulas/tiposTabelaCelulas"
|
||||
|
||||
export type tipoComponenteCelulaBase<T extends tipoTabelaCelula> = readonly [
|
||||
T,
|
||||
|
|
@ -19,6 +14,20 @@ export type tipoComponenteCelula = {
|
|||
[K in tipoTabelaCelula]: tipoComponenteCelulaBase<K>
|
||||
}[tipoTabelaCelula]
|
||||
|
||||
/**
|
||||
* Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela.
|
||||
* Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`,
|
||||
* garantindo que não seja possível passar dados incorretos para um tipo de célula.
|
||||
*
|
||||
* Uso esperado dentro de `tipoEliColuna.celula`:
|
||||
* ```ts
|
||||
* celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome })
|
||||
* ```
|
||||
*
|
||||
* @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`)
|
||||
* @param dados - Dados tipados para o tipo de célula escolhido
|
||||
* @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue`
|
||||
*/
|
||||
export const celulaTabela = <T extends tipoTabelaCelula>(
|
||||
tipo: T,
|
||||
dados: tiposTabelaCelulas[T],
|
||||
|
|
@ -72,7 +81,7 @@ export type tipoEliTabelaAcao<T> = {
|
|||
}
|
||||
|
||||
export type parametrosConsulta<T> = {
|
||||
filtros?: tipoFiltro26<T>[]
|
||||
filtros?: tipoFiltroDeltaColunaTipada<Extract<keyof T, string>>[]
|
||||
coluna_ordem?: keyof T
|
||||
direcao_ordem?: "asc" | "desc"
|
||||
offSet?: number
|
||||
|
|
@ -152,7 +161,7 @@ export type tipoEliTabelaConsulta<T> = {
|
|||
/** Rótulo exibido no select de adicionar filtro */
|
||||
rotulo: string
|
||||
/** vai gerar filtro e mescla com os filtros já existentes */
|
||||
filtro: (valor: unknown) => tipoFiltro26<T>
|
||||
filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada<Extract<keyof T, string>> | undefined
|
||||
/** componente de entrada que vai receber o valor para o filtro */
|
||||
entrada: ComponenteEntrada
|
||||
}[]
|
||||
|
|
|
|||
139
src/componentes/EliTabela/useColunasOrdenacao.ts
Normal file
139
src/componentes/EliTabela/useColunasOrdenacao.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* useColunasOrdenacao
|
||||
* Composable que encapsula a lógica de drag-and-drop e sincronização de estado
|
||||
* do modal de configuração de colunas da EliTabela.
|
||||
*
|
||||
* Responsável por:
|
||||
* - Sincronizar colunas visíveis/invisíveis com a configuração persistida
|
||||
* - Aplicar ordem salva e adicionar novas colunas ao final
|
||||
* - Gerenciar eventos de arrastar-e-soltar entre as duas listas
|
||||
*
|
||||
* Extraído de `EliTabelaModalColunas.vue` para manter o componente abaixo de 250 linhas.
|
||||
*/
|
||||
import { type Ref, ref, watch } from "vue"
|
||||
import type { EliTabelaColunasConfig } from "./colunasStorage"
|
||||
import type { tipoEliColuna } from "./types-eli-tabela"
|
||||
|
||||
type OrigemLista = "visiveis" | "invisiveis"
|
||||
|
||||
interface UseColunasOrdenacaoOptions {
|
||||
aberto: Ref<boolean>
|
||||
rotulosColunas: Ref<string[]>
|
||||
configInicial: Ref<EliTabelaColunasConfig>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai
|
||||
colunas: Ref<tipoEliColuna<any>[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas.
|
||||
* Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam.
|
||||
*
|
||||
* @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização)
|
||||
* @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela
|
||||
* @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis)
|
||||
* @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão)
|
||||
* @returns Estado reativo e handlers de drag-and-drop prontos para o template
|
||||
*/
|
||||
export function useColunasOrdenacao({
|
||||
aberto,
|
||||
rotulosColunas,
|
||||
configInicial,
|
||||
colunas,
|
||||
}: UseColunasOrdenacaoOptions) {
|
||||
const visiveisLocal = ref<string[]>([])
|
||||
const invisiveisLocal = ref<string[]>([])
|
||||
const itemSendoArrastado = ref<string | null>(null)
|
||||
|
||||
function sincronizarEstado() {
|
||||
const todos = rotulosColunas.value
|
||||
const configTemDados =
|
||||
(configInicial.value.visiveis?.length ?? 0) > 0 ||
|
||||
(configInicial.value.invisiveis?.length ?? 0) > 0
|
||||
|
||||
const invisiveisPadraoSet = new Set(
|
||||
colunas.value.filter((c) => c.visivel === false).map((c) => c.rotulo),
|
||||
)
|
||||
const invisiveisSet = configTemDados
|
||||
? new Set(configInicial.value.invisiveis ?? [])
|
||||
: invisiveisPadraoSet
|
||||
|
||||
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
|
||||
const ordemSalva = configInicial.value.visiveis ?? []
|
||||
const setVis = new Set(baseVisiveis)
|
||||
const ordenadas: string[] = []
|
||||
|
||||
for (const r of ordemSalva) {
|
||||
if (setVis.has(r)) ordenadas.push(r)
|
||||
}
|
||||
for (const r of baseVisiveis) {
|
||||
if (!ordenadas.includes(r)) ordenadas.push(r)
|
||||
}
|
||||
|
||||
visiveisLocal.value = ordenadas
|
||||
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [aberto.value, rotulosColunas.value, configInicial.value, colunas.value] as const,
|
||||
() => {
|
||||
if (aberto.value) sincronizarEstado()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
function onDragStart(e: DragEvent, rotulo: string) {
|
||||
itemSendoArrastado.value = rotulo
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData("text/plain", rotulo)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
itemSendoArrastado.value = null
|
||||
}
|
||||
|
||||
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
|
||||
const draggedRotulo = itemSendoArrastado.value
|
||||
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
|
||||
|
||||
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origem = origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
|
||||
if (!origem) return
|
||||
|
||||
const origemArr = origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
|
||||
const dragIdx = origemArr.indexOf(draggedRotulo)
|
||||
const alvoIdx = destinoArr.indexOf(alvoRotulo)
|
||||
|
||||
origemArr.splice(dragIdx, 1)
|
||||
destinoArr.splice(alvoIdx, 0, draggedRotulo)
|
||||
}
|
||||
|
||||
function onDragOverLista(destino: OrigemLista) {
|
||||
const draggedRotulo = itemSendoArrastado.value
|
||||
if (!draggedRotulo) return
|
||||
|
||||
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
|
||||
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
|
||||
|
||||
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
|
||||
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
|
||||
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
|
||||
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
|
||||
|
||||
destinoArr.push(draggedRotulo)
|
||||
}
|
||||
|
||||
return {
|
||||
visiveisLocal,
|
||||
invisiveisLocal,
|
||||
itemSendoArrastado,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOverItem,
|
||||
onDragOverLista,
|
||||
}
|
||||
}
|
||||
105
src/componentes/EliTabela/useEliTabela.ts
Normal file
105
src/componentes/EliTabela/useEliTabela.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* useEliTabela
|
||||
* Orquestrador principal da EliTabela — compõe todos os sub-composables
|
||||
* e retorna o estado unificado para o template do componente.
|
||||
*
|
||||
* Composables compostos:
|
||||
* - `useEliTabelaConsulta` — carregamento, paginação, ordenação, busca
|
||||
* - `useEliTabelaFiltrosAvancados` — filtros avançados, modal e persistência
|
||||
* - `useEliTabelaSelecao` — seleção múltipla de linhas
|
||||
* - `useEliTabelaColunas` — visibilidade, ordem e modal de colunas
|
||||
* - `useEliTabelaMenu` — menu de ações por linha e posicionamento de popup
|
||||
*
|
||||
* @see EliTabela.vue — componente que consome este composable
|
||||
*/
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import { carregarConfigColunas } from "./colunasStorage"
|
||||
import { carregarFiltroAvancado } from "./filtroAvancadoStorage"
|
||||
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
|
||||
import { useEliTabelaColunas } from "./useEliTabelaColunas"
|
||||
import { useEliTabelaConsulta } from "./useEliTabelaConsulta"
|
||||
import { useEliTabelaFiltrosAvancados } from "./useEliTabelaFiltrosAvancados"
|
||||
import { useEliTabelaMenu } from "./useEliTabelaMenu"
|
||||
import { useEliTabelaSelecao } from "./useEliTabelaSelecao"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic table — genérico passado por prop
|
||||
type TabelaProps = tipoEliTabelaConsulta<any>
|
||||
|
||||
export function useEliTabela(
|
||||
props: { tabela: TabelaProps; selecionados: unknown[]; esconderDebug: boolean },
|
||||
emit: (event: "update:selecionados", value: unknown[]) => void,
|
||||
) {
|
||||
// Estado compartilhado entre sub-composables (passado por ref para reatividade)
|
||||
const paginaAtual = ref(1)
|
||||
const valorBusca = ref<string>("")
|
||||
|
||||
// Filtros avançados (precisa de carregar que ainda não existe; passa proxy que usará o da consulta)
|
||||
// Usamos um ref de função para quebrar a dependência circular: filtros precisam de carregar,
|
||||
// e consulta precisa de filtrosAvancadosAtivos.
|
||||
let _carregarProxy: () => Promise<void> = async () => {
|
||||
/* será atribuído após consulta ser criada */
|
||||
}
|
||||
const carregarProxy = () => _carregarProxy()
|
||||
|
||||
const filtros = useEliTabelaFiltrosAvancados(props, paginaAtual, valorBusca, carregarProxy)
|
||||
|
||||
// Colunas: temAcoes vem da consulta — usamos um ref intermediário
|
||||
const _temAcoesRef = ref(false)
|
||||
const colunas = useEliTabelaColunas(props, _temAcoesRef)
|
||||
|
||||
const consulta = useEliTabelaConsulta(
|
||||
props,
|
||||
filtros.filtrosAvancadosAtivos,
|
||||
paginaAtual,
|
||||
valorBusca,
|
||||
colunas.linhasExpandidas,
|
||||
)
|
||||
|
||||
// Conecta o proxy ao carregar real
|
||||
_carregarProxy = consulta.carregar
|
||||
// Sincroniza temAcoes para o composable de colunas
|
||||
// biome-ignore lint/suspicious/noExplicitAny: ref sync interno
|
||||
;(_temAcoesRef as any).value = consulta.temAcoes.value
|
||||
watch(consulta.temAcoes, (v) => {
|
||||
_temAcoesRef.value = v
|
||||
})
|
||||
|
||||
const selecao = useEliTabelaSelecao(props, emit, consulta.linhasPaginadas)
|
||||
const menu = useEliTabelaMenu(props, consulta.acoesVisiveis)
|
||||
|
||||
// Watch: troca de configuração de tabela reseta todos os estados e recarrega
|
||||
watch(
|
||||
() => props.tabela,
|
||||
() => {
|
||||
menu.menuAberto.value = null
|
||||
consulta.colunaOrdenacao.value = null
|
||||
consulta.direcaoOrdenacao.value = "asc"
|
||||
valorBusca.value = ""
|
||||
colunas.modalColunasAberto.value = false
|
||||
filtros.modalFiltroAberto.value = false
|
||||
colunas.configColunas.value = carregarConfigColunas(props.tabela.nome)
|
||||
const filtrosRecarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
|
||||
filtros.filtrosUi.value = filtrosRecarregados.linhas
|
||||
colunas.linhasExpandidas.value = {}
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void consulta.carregar()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => void consulta.carregar())
|
||||
|
||||
return {
|
||||
...consulta,
|
||||
...filtros,
|
||||
...selecao,
|
||||
...colunas,
|
||||
menuAberto: menu.menuAberto,
|
||||
menuPopup: menu.menuPopup,
|
||||
menuPopupPos: menu.menuPopupPos,
|
||||
possuiAcoes: menu.possuiAcoes,
|
||||
toggleMenu: menu.toggleMenu,
|
||||
acoesDisponiveisPorLinha: menu.acoesDisponiveisPorLinha,
|
||||
paginaAtual,
|
||||
valorBusca,
|
||||
}
|
||||
}
|
||||
129
src/componentes/EliTabela/useEliTabelaColunas.ts
Normal file
129
src/componentes/EliTabela/useEliTabelaColunas.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* useEliTabelaColunas
|
||||
* Gerencia visibilidade, ordenação e persistência da configuração de colunas da EliTabela.
|
||||
* Controla também o modal de colunas e o estado de linhas expandidas (detalhe de colunas invisíveis).
|
||||
*
|
||||
* @param props - Props do componente (tabela.colunas, tabela.nome)
|
||||
* @param temAcoes - Computed que indica se existem ações por linha (para totalColunas)
|
||||
*/
|
||||
import { computed, type Ref, ref } from "vue"
|
||||
import {
|
||||
carregarConfigColunas,
|
||||
type EliTabelaColunasConfig,
|
||||
salvarConfigColunas,
|
||||
} from "./colunasStorage"
|
||||
import type { tipoEliColuna } from "./types-eli-tabela"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: colunas dinâmicas — genérico passado por prop
|
||||
type PropsColunas = { tabela: { colunas: tipoEliColuna<any>[]; nome: string } }
|
||||
|
||||
export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref<boolean>) {
|
||||
const modalColunasAberto = ref(false)
|
||||
const configColunas = ref<EliTabelaColunasConfig>(carregarConfigColunas(props.tabela.nome))
|
||||
const linhasExpandidas = ref<Record<number, boolean>>({})
|
||||
|
||||
const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo))
|
||||
|
||||
const colunasInvisiveisEfetivas = computed(() => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
const colunas = props.tabela.colunas as tipoEliColuna<any>[]
|
||||
const configTemDados =
|
||||
(configColunas.value.visiveis?.length ?? 0) > 0 ||
|
||||
(configColunas.value.invisiveis?.length ?? 0) > 0
|
||||
const invisiveisBaseRotulos = configTemDados
|
||||
? (configColunas.value.invisiveis ?? [])
|
||||
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
|
||||
const invisiveisSet = new Set(invisiveisBaseRotulos)
|
||||
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
|
||||
const mapa = new Map<string, tipoEliColuna<any>>()
|
||||
for (const c of base) {
|
||||
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
const ordenadas: tipoEliColuna<any>[] = []
|
||||
for (const r of invisiveisBaseRotulos) {
|
||||
const c = mapa.get(r)
|
||||
if (c) ordenadas.push(c)
|
||||
}
|
||||
for (const c of base) {
|
||||
if (!ordenadas.includes(c)) ordenadas.push(c)
|
||||
}
|
||||
return ordenadas
|
||||
})
|
||||
|
||||
const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0)
|
||||
|
||||
const colunasEfetivas = computed(() => {
|
||||
const colunas = props.tabela.colunas
|
||||
const todosRotulos = rotulosColunas.value
|
||||
const configTemDados =
|
||||
(configColunas.value.visiveis?.length ?? 0) > 0 ||
|
||||
(configColunas.value.invisiveis?.length ?? 0) > 0
|
||||
const invisiveisBaseRotulos = configTemDados
|
||||
? (configColunas.value.invisiveis ?? [])
|
||||
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
|
||||
(props.tabela.colunas as tipoEliColuna<any>[])
|
||||
.filter((c) => c.visivel === false)
|
||||
.map((c) => c.rotulo)
|
||||
const invisiveisSet = new Set(invisiveisBaseRotulos)
|
||||
const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r))
|
||||
const visiveisSet = new Set(visiveisBaseRotulos)
|
||||
const ordemSalva = configTemDados ? (configColunas.value.visiveis ?? []) : []
|
||||
const ordemFinal: string[] = []
|
||||
for (const r of ordemSalva) {
|
||||
if (visiveisSet.has(r)) ordemFinal.push(r)
|
||||
}
|
||||
for (const r of visiveisBaseRotulos) {
|
||||
if (!ordemFinal.includes(r)) ordemFinal.push(r)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
|
||||
const mapa = new Map<string, any>()
|
||||
for (const c of colunas) {
|
||||
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
|
||||
}
|
||||
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
|
||||
})
|
||||
|
||||
const totalColunas = computed(
|
||||
() =>
|
||||
colunasEfetivas.value.length +
|
||||
(temAcoes.value ? 1 : 0) +
|
||||
(temColunasInvisiveis.value ? 1 : 0),
|
||||
)
|
||||
|
||||
function abrirModalColunas() {
|
||||
modalColunasAberto.value = true
|
||||
}
|
||||
|
||||
function fecharModalColunas() {
|
||||
modalColunasAberto.value = false
|
||||
}
|
||||
|
||||
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
|
||||
configColunas.value = cfg
|
||||
salvarConfigColunas(props.tabela.nome, cfg)
|
||||
modalColunasAberto.value = false
|
||||
linhasExpandidas.value = {}
|
||||
}
|
||||
|
||||
function alternarLinhaExpandida(indice: number) {
|
||||
const atual = Boolean(linhasExpandidas.value[indice])
|
||||
linhasExpandidas.value = { ...linhasExpandidas.value, [indice]: !atual }
|
||||
}
|
||||
|
||||
return {
|
||||
modalColunasAberto,
|
||||
configColunas,
|
||||
linhasExpandidas,
|
||||
rotulosColunas,
|
||||
colunasEfetivas,
|
||||
colunasInvisiveisEfetivas,
|
||||
temColunasInvisiveis,
|
||||
totalColunas,
|
||||
abrirModalColunas,
|
||||
fecharModalColunas,
|
||||
salvarModalColunas,
|
||||
alternarLinhaExpandida,
|
||||
}
|
||||
}
|
||||
247
src/componentes/EliTabela/useEliTabelaConsulta.ts
Normal file
247
src/componentes/EliTabela/useEliTabelaConsulta.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* useEliTabelaConsulta
|
||||
* Gerencia o ciclo de consultas da EliTabela: carregamento, paginação, ordenação e busca.
|
||||
* Usa um contador sequencial para descartar respostas de consultas antigas (race condition).
|
||||
*
|
||||
* @param props - Props do componente (tabela, esconderDebug)
|
||||
* @param filtrosAvancadosAtivos - Computed com filtros ativos para envio à API
|
||||
* @param paginaAtual - Ref da página atual (compartilhada com filtros e busca)
|
||||
* @param valorBusca - Ref da busca textual (compartilhada com filtros avançados)
|
||||
* @param linhasExpandidas - Ref das linhas expandidas (resetada ao carregar)
|
||||
*/
|
||||
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
|
||||
import { codigosResposta } from "p-respostas"
|
||||
import { computed, type Ref, ref, watch } from "vue"
|
||||
import type { parametrosConsulta, tipoEliTabelaConsulta } from "./types-eli-tabela"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: genérico passado por prop
|
||||
type PropsConsulta = { tabela: tipoEliTabelaConsulta<any>; esconderDebug: boolean }
|
||||
|
||||
export function useEliTabelaConsulta(
|
||||
props: PropsConsulta,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: genérico
|
||||
filtrosAvancadosAtivos: Ref<tipoFiltroDeltaColunaTipada<any>[]>,
|
||||
paginaAtual: Ref<number>,
|
||||
valorBusca: Ref<string>,
|
||||
linhasExpandidas: Ref<Record<number, boolean>>,
|
||||
) {
|
||||
const isDev = import.meta.env.DEV && !props.esconderDebug
|
||||
const carregando = ref(false)
|
||||
const erro = ref<string | null>(null)
|
||||
const linhas = ref<unknown[]>([])
|
||||
const quantidade = ref<number>(0)
|
||||
const acoesVisiveis = ref<boolean[][]>([])
|
||||
const colunaOrdenacao = ref<string | null>(null)
|
||||
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
|
||||
|
||||
const tabela = computed(() => props.tabela)
|
||||
const registrosPorConsulta = computed(() => {
|
||||
const valor = props.tabela.registros_por_consulta
|
||||
return typeof valor === "number" && valor > 0 ? Math.floor(valor) : 10
|
||||
})
|
||||
const totalPaginas = computed(() => {
|
||||
const limite = registrosPorConsulta.value
|
||||
if (!limite || limite <= 0) return 1
|
||||
const total = quantidade.value ?? 0
|
||||
return total ? Math.max(1, Math.ceil(total / limite)) : 1
|
||||
})
|
||||
const linhasPaginadas = computed(() => linhas.value ?? [])
|
||||
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
|
||||
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
|
||||
const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca))
|
||||
const acoesSuperiores = computed(() =>
|
||||
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
|
||||
)
|
||||
const acoesInferiores = computed(() =>
|
||||
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
|
||||
)
|
||||
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
|
||||
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
|
||||
const parametrosConsultaAtuais = computed(() => {
|
||||
const limite = Math.max(1, registrosPorConsulta.value)
|
||||
const offset = (paginaAtual.value - 1) * limite
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
|
||||
const params: parametrosConsulta<any> = { offSet: offset, limit: limite }
|
||||
const busca = (valorBusca.value ?? "").trim()
|
||||
if (busca) {
|
||||
params.texto_busca = busca
|
||||
} else {
|
||||
const filtros = filtrosAvancadosAtivos.value
|
||||
if (filtros.length) params.filtros = filtros
|
||||
}
|
||||
if (colunaOrdenacao.value) {
|
||||
params.coluna_ordem = colunaOrdenacao.value
|
||||
params.direcao_ordem = direcaoOrdenacao.value
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
atualizarConsulta: async () => {
|
||||
await carregar()
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
|
||||
editarLista: async (novaLista: any[]) => {
|
||||
linhas.value = novaLista
|
||||
return novaLista
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let carregamentoSequencial = 0
|
||||
|
||||
async function carregar() {
|
||||
const idCarregamento = ++carregamentoSequencial
|
||||
carregando.value = true
|
||||
erro.value = null
|
||||
try {
|
||||
acoesVisiveis.value = []
|
||||
linhasExpandidas.value = {}
|
||||
const limite = Math.max(1, registrosPorConsulta.value)
|
||||
const offset = (paginaAtual.value - 1) * limite
|
||||
const parametros: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic
|
||||
filtros?: tipoFiltroDeltaColunaTipada<any>[]
|
||||
coluna_ordem?: never
|
||||
direcao_ordem?: "asc" | "desc"
|
||||
offSet: number
|
||||
limit: number
|
||||
texto_busca?: string
|
||||
} = { offSet: offset, limit: limite }
|
||||
const busca = (valorBusca.value ?? "").trim()
|
||||
if (busca) {
|
||||
parametros.texto_busca = busca
|
||||
} else {
|
||||
const filtros = filtrosAvancadosAtivos.value
|
||||
if (filtros.length) parametros.filtros = filtros
|
||||
}
|
||||
if (colunaOrdenacao.value) {
|
||||
parametros.coluna_ordem = colunaOrdenacao.value as never
|
||||
parametros.direcao_ordem = direcaoOrdenacao.value
|
||||
}
|
||||
if (isDev) console.log("[EliTabela] consulta(parametros)", parametros) // eslint-disable-line no-console
|
||||
const res = await props.tabela.consulta(parametros)
|
||||
if (idCarregamento !== carregamentoSequencial) return
|
||||
if (res.cod !== codigosResposta.sucesso) {
|
||||
linhas.value = []
|
||||
quantidade.value = 0
|
||||
erro.value = res.mensagem
|
||||
return
|
||||
}
|
||||
const valores = res.valor?.valores ?? []
|
||||
const total = res.valor?.quantidade ?? valores.length
|
||||
linhas.value = valores
|
||||
quantidade.value = Number(total) || 0
|
||||
const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidade.value || 0) / limite))
|
||||
if (paginaAtual.value > totalPaginasRecalculado) paginaAtual.value = totalPaginasRecalculado
|
||||
const acoesConf = props.tabela.acoesLinha ?? []
|
||||
if (!acoesConf.length) {
|
||||
acoesVisiveis.value = []
|
||||
return
|
||||
}
|
||||
acoesVisiveis.value = valores.map(() =>
|
||||
acoesConf.map((a) =>
|
||||
a.exibir === undefined ? true : typeof a.exibir === "boolean" ? a.exibir : false,
|
||||
),
|
||||
)
|
||||
const visibilidade = await Promise.all(
|
||||
valores.map((linha) =>
|
||||
Promise.all(
|
||||
acoesConf.map(async (acao) => {
|
||||
if (acao.exibir === undefined) return true
|
||||
if (typeof acao.exibir === "boolean") return acao.exibir
|
||||
try {
|
||||
return Boolean(await Promise.resolve(acao.exibir(linha as never)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (idCarregamento === carregamentoSequencial) acoesVisiveis.value = visibilidade
|
||||
} catch (e) {
|
||||
console.error("[EliTabela] Erro capturado em carregar():", e)
|
||||
if (idCarregamento !== carregamentoSequencial) return
|
||||
linhas.value = []
|
||||
quantidade.value = 0
|
||||
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
|
||||
} finally {
|
||||
if (idCarregamento === carregamentoSequencial) carregando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function alternarOrdenacao(chave?: string) {
|
||||
if (!chave) return
|
||||
if (colunaOrdenacao.value === chave) {
|
||||
direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc"
|
||||
void carregar()
|
||||
return
|
||||
}
|
||||
colunaOrdenacao.value = chave
|
||||
direcaoOrdenacao.value = "asc"
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
function atualizarBusca(texto: string) {
|
||||
if (valorBusca.value === texto) return
|
||||
valorBusca.value = texto
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
function irParaPagina(pagina: number) {
|
||||
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
|
||||
if (alvo !== paginaAtual.value) paginaAtual.value = alvo
|
||||
}
|
||||
|
||||
watch(paginaAtual, (nova, antiga) => {
|
||||
if (nova !== antiga) void carregar()
|
||||
})
|
||||
watch(linhas, () => {
|
||||
linhasExpandidas.value = {}
|
||||
})
|
||||
watch(
|
||||
() => props.tabela.mostrarCaixaDeBusca,
|
||||
(mostrar) => {
|
||||
if (!mostrar && valorBusca.value) {
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => props.tabela.registros_por_consulta,
|
||||
() => {
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
isDev,
|
||||
tabela,
|
||||
carregando,
|
||||
erro,
|
||||
linhas,
|
||||
linhasPaginadas,
|
||||
quantidade,
|
||||
quantidadeFiltrada,
|
||||
acoesVisiveis,
|
||||
colunaOrdenacao,
|
||||
direcaoOrdenacao,
|
||||
registrosPorConsulta,
|
||||
totalPaginas,
|
||||
temAcoes,
|
||||
exibirBusca,
|
||||
acoesSuperiores,
|
||||
acoesInferiores,
|
||||
temAcoesSuperiores,
|
||||
temAcoesInferiores,
|
||||
parametrosConsultaAtuais,
|
||||
carregar,
|
||||
alternarOrdenacao,
|
||||
atualizarBusca,
|
||||
irParaPagina,
|
||||
}
|
||||
}
|
||||
128
src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts
Normal file
128
src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* useEliTabelaFiltrosAvancados
|
||||
* Gerencia o estado de filtros avançados da EliTabela, incluindo:
|
||||
* - Linhas de filtro ativas (`filtrosUi`) e fórmula booleana (`filtrosFormula`)
|
||||
* - Computed `filtrosAvancadosAtivos` que converte as linhas em FiltroDelta para a API
|
||||
* - Abertura/fechamento e limpeza do modal de filtro avançado
|
||||
*
|
||||
* A fórmula é armazenada no formato `{chave}` (ex: `{email} E {nome}`) e convertida
|
||||
* para IDs globais `{c1} {c2}` durante a montagem do FiltroDelta final.
|
||||
*
|
||||
* @param props - Props do componente (tabela.filtroAvancado, tabela.nome)
|
||||
* @param paginaAtual - Ref da página atual (para resetar ao aplicar filtros)
|
||||
* @param valorBusca - Ref da busca textual (limpa ao aplicar filtros avançados)
|
||||
* @param carregar - Função de recarregamento dos dados
|
||||
*/
|
||||
import type { tipoCondicaoFiltroDelta, tipoFiltroDeltaColunaTipada } from "p-comuns"
|
||||
import { computed, type Ref, ref } from "vue"
|
||||
import {
|
||||
carregarFiltroAvancado,
|
||||
limparFiltroAvancado,
|
||||
salvarFiltroAvancado,
|
||||
} from "./filtroAvancadoStorage"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dinâmico — genérico passado por prop
|
||||
type PropsFiltros = { tabela: { filtroAvancado?: any[]; nome: string } }
|
||||
|
||||
type LinhaFiltroUI = { chave: string; valor: unknown }
|
||||
|
||||
export function useEliTabelaFiltrosAvancados(
|
||||
props: PropsFiltros,
|
||||
paginaAtual: Ref<number>,
|
||||
valorBusca: Ref<string>,
|
||||
carregar: () => Promise<void>,
|
||||
) {
|
||||
const _filtrosCarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
|
||||
const filtrosUi = ref<LinhaFiltroUI[]>(_filtrosCarregados.linhas)
|
||||
const filtrosFormula = ref<string>(_filtrosCarregados.formula ?? "")
|
||||
const modalFiltroAberto = ref(false)
|
||||
|
||||
const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic
|
||||
const filtrosAvancadosAtivos = computed<tipoFiltroDeltaColunaTipada<any>[]>(() => {
|
||||
const base = props.tabela.filtroAvancado ?? []
|
||||
const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined)
|
||||
if (ativos.length === 0) return []
|
||||
|
||||
const filtrosIndividuais = ativos
|
||||
.map((f) => {
|
||||
const b = base.find((x) => String(x.chave) === String(f.chave))
|
||||
if (!b) return null
|
||||
return { chave: f.chave, filtro: b.filtro(f.valor) }
|
||||
})
|
||||
.filter(
|
||||
(f): f is { chave: string; filtro: tipoFiltroDeltaColunaTipada<string> } =>
|
||||
f !== null && f.filtro !== undefined && f.filtro !== null,
|
||||
)
|
||||
|
||||
if (filtrosFormula.value && filtrosIndividuais.length > 1) {
|
||||
const condicoesFinais: tipoCondicaoFiltroDelta[] = []
|
||||
let formulaFinal = filtrosFormula.value
|
||||
filtrosIndividuais.forEach(({ chave }, i) => {
|
||||
formulaFinal = formulaFinal.replace(new RegExp(`\\{${chave}\\}`, "g"), `{c${i + 1}}`)
|
||||
})
|
||||
let globalIdx = 1
|
||||
for (let i = 0; i < filtrosIndividuais.length; i++) {
|
||||
const { filtro } = filtrosIndividuais[i]
|
||||
const idMap: Record<string, string> = {}
|
||||
for (const cond of filtro.condicoes) {
|
||||
const novoId = `{c${globalIdx++}}`
|
||||
idMap[cond.id] = novoId
|
||||
condicoesFinais.push({ ...cond, id: novoId })
|
||||
}
|
||||
let formulaLocal = filtro.formula
|
||||
for (const [oldId, newId] of Object.entries(idMap)) {
|
||||
formulaLocal = formulaLocal.replace(new RegExp(`\\{${oldId.slice(1, -1)}\\}`, "g"), newId)
|
||||
}
|
||||
const subst = filtro.condicoes.length > 1 ? `(${formulaLocal})` : formulaLocal
|
||||
formulaFinal = formulaFinal.replace(new RegExp(`\\{c${i + 1}\\}`, "g"), subst)
|
||||
}
|
||||
return [{ condicoes: condicoesFinais, formula: formulaFinal }]
|
||||
}
|
||||
return filtrosIndividuais.map((f) => f.filtro)
|
||||
})
|
||||
|
||||
function abrirModalFiltro() {
|
||||
modalFiltroAberto.value = true
|
||||
}
|
||||
|
||||
function fecharModalFiltro() {
|
||||
modalFiltroAberto.value = false
|
||||
}
|
||||
|
||||
function limparFiltrosAvancados() {
|
||||
filtrosUi.value = []
|
||||
filtrosFormula.value = ""
|
||||
limparFiltroAvancado(props.tabela.nome)
|
||||
modalFiltroAberto.value = false
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
function salvarFiltrosAvancados(novoLinhas: LinhaFiltroUI[], novaFormula?: string) {
|
||||
filtrosUi.value = novoLinhas ?? []
|
||||
filtrosFormula.value = novaFormula ?? ""
|
||||
salvarFiltroAvancado<Record<string, unknown>>(props.tabela.nome, {
|
||||
linhas: filtrosUi.value,
|
||||
formula: filtrosFormula.value,
|
||||
})
|
||||
modalFiltroAberto.value = false
|
||||
valorBusca.value = ""
|
||||
if (paginaAtual.value !== 1) paginaAtual.value = 1
|
||||
else void carregar()
|
||||
}
|
||||
|
||||
return {
|
||||
filtrosUi,
|
||||
filtrosFormula,
|
||||
modalFiltroAberto,
|
||||
exibirFiltroAvancado,
|
||||
filtrosAvancadosAtivos,
|
||||
abrirModalFiltro,
|
||||
fecharModalFiltro,
|
||||
limparFiltrosAvancados,
|
||||
salvarFiltrosAvancados,
|
||||
}
|
||||
}
|
||||
91
src/componentes/EliTabela/useEliTabelaMenu.ts
Normal file
91
src/componentes/EliTabela/useEliTabelaMenu.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* useEliTabelaMenu
|
||||
* Gerencia o menu de ações por linha da EliTabela.
|
||||
* Controla abertura/fechamento, posicionamento do popup e detecção de clique externo.
|
||||
*
|
||||
* @param props - Props do componente (tabela.acoesLinha)
|
||||
* @param acoesVisiveis - Ref com matriz de visibilidade das ações por linha
|
||||
*/
|
||||
import { nextTick, onBeforeUnmount, onMounted, type Ref, ref } from "vue"
|
||||
import type { tipoEliTabelaAcao } from "./types-eli-tabela"
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: genérico de linha passado por prop
|
||||
type PropsMenu = { tabela: { acoesLinha?: tipoEliTabelaAcao<any>[] } }
|
||||
|
||||
export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref<boolean[][]>) {
|
||||
const menuAberto = ref<number | null>(null)
|
||||
const menuPopup = ref<{ menuEl: HTMLElement | null } | null>(null)
|
||||
const menuPopupPos = ref({ top: 0, left: 0 })
|
||||
|
||||
function atualizarPosicaoMenu(anchor: HTMLElement) {
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const gap = 8
|
||||
const alturaMenu = menuPopup.value?.menuEl?.offsetHeight ?? 0
|
||||
const larguraMenu = menuPopup.value?.menuEl?.offsetWidth ?? 180
|
||||
let top = rect.bottom + gap
|
||||
const left = rect.right - larguraMenu
|
||||
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
|
||||
top = rect.top - gap - alturaMenu
|
||||
}
|
||||
menuPopupPos.value = {
|
||||
top: Math.max(gap, Math.round(top)),
|
||||
left: Math.max(gap, Math.round(left)),
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickFora(evento: MouseEvent) {
|
||||
if (menuAberto.value === null) return
|
||||
const alvo = evento.target as Node
|
||||
if (menuPopup.value?.menuEl?.contains(alvo)) return
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value })
|
||||
}
|
||||
menuAberto.value = null
|
||||
}
|
||||
|
||||
function acoesDisponiveisPorLinha(i: number) {
|
||||
const acoesLinha = props.tabela.acoesLinha ?? []
|
||||
const visibilidade = acoesVisiveis.value[i] ?? []
|
||||
return acoesLinha
|
||||
.map((acao, indice) => {
|
||||
const fallbackVisivel =
|
||||
acao.exibir === undefined ? true : typeof acao.exibir === "boolean" ? acao.exibir : false
|
||||
return { acao, indice, visivel: visibilidade[indice] ?? fallbackVisivel }
|
||||
})
|
||||
.filter((item) => item.visivel)
|
||||
}
|
||||
|
||||
function possuiAcoes(i: number) {
|
||||
return acoesDisponiveisPorLinha(i).length > 0
|
||||
}
|
||||
|
||||
function toggleMenu(i: number, evento?: MouseEvent) {
|
||||
if (!possuiAcoes(i)) return
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value })
|
||||
}
|
||||
if (menuAberto.value === i) {
|
||||
menuAberto.value = null
|
||||
return
|
||||
}
|
||||
menuAberto.value = i
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[EliTabela] toggleMenu => abriu", { i })
|
||||
}
|
||||
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
|
||||
if (anchor) {
|
||||
atualizarPosicaoMenu(anchor)
|
||||
nextTick(() => {
|
||||
if (menuAberto.value !== null) atualizarPosicaoMenu(anchor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("click", handleClickFora))
|
||||
onBeforeUnmount(() => document.removeEventListener("click", handleClickFora))
|
||||
|
||||
return { menuAberto, menuPopup, menuPopupPos, possuiAcoes, toggleMenu, acoesDisponiveisPorLinha }
|
||||
}
|
||||
96
src/componentes/EliTabela/useEliTabelaSelecao.ts
Normal file
96
src/componentes/EliTabela/useEliTabelaSelecao.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* useEliTabelaSelecao
|
||||
* Gerencia a seleção múltipla de linhas na EliTabela.
|
||||
* Suporta seleção por objeto completo ou por chave configurável (`chave_selecao`).
|
||||
*
|
||||
* @param props - Props do componente (selecionados, tabela.chave_selecao, tabela.mostrar_selecao)
|
||||
* @param emit - Função de emit para `update:selecionados`
|
||||
* @param linhasPaginadas - Ref com as linhas da página atual (para todosSelecionados/algunsSelecionados)
|
||||
*/
|
||||
import { computed, type Ref } from "vue"
|
||||
|
||||
type PropsSelecao = {
|
||||
selecionados: unknown[]
|
||||
tabela: { mostrar_selecao?: boolean; chave_selecao?: string | number | symbol }
|
||||
}
|
||||
|
||||
export function useEliTabelaSelecao(
|
||||
props: PropsSelecao,
|
||||
emit: (event: "update:selecionados", value: unknown[]) => void,
|
||||
linhasPaginadas: Ref<unknown[]>,
|
||||
) {
|
||||
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
|
||||
const chaveSelecao = computed(() => props.tabela.chave_selecao as string | undefined)
|
||||
|
||||
function obterChaveLinha(linha: unknown): unknown {
|
||||
if (chaveSelecao.value && linha && typeof linha === "object") {
|
||||
return (linha as Record<string, unknown>)[chaveSelecao.value]
|
||||
}
|
||||
return linha
|
||||
}
|
||||
|
||||
function isSelecionado(linha: unknown): boolean {
|
||||
const chave = obterChaveLinha(linha)
|
||||
if (chaveSelecao.value) {
|
||||
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
|
||||
}
|
||||
return props.selecionados.includes(linha)
|
||||
}
|
||||
|
||||
function alternarSelecao(linha: unknown) {
|
||||
const atual = [...props.selecionados]
|
||||
const chave = obterChaveLinha(linha)
|
||||
|
||||
let index = -1
|
||||
if (chaveSelecao.value) {
|
||||
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
|
||||
} else {
|
||||
index = atual.indexOf(linha)
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
atual.push(linha)
|
||||
} else {
|
||||
atual.splice(index, 1)
|
||||
}
|
||||
emit("update:selecionados", atual)
|
||||
}
|
||||
|
||||
const todosSelecionados = computed(() => {
|
||||
if (linhasPaginadas.value.length === 0) return false
|
||||
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
|
||||
})
|
||||
|
||||
const algunsSelecionados = computed(() => {
|
||||
if (linhasPaginadas.value.length === 0) return false
|
||||
return linhasPaginadas.value.some((linha) => isSelecionado(linha)) && !todosSelecionados.value
|
||||
})
|
||||
|
||||
function alternarSelecaoTodos(forcarMarcar?: boolean) {
|
||||
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
|
||||
let atual = [...props.selecionados]
|
||||
|
||||
if (marcarTodos) {
|
||||
for (const linha of linhasPaginadas.value) {
|
||||
if (!isSelecionado(linha)) atual.push(linha)
|
||||
}
|
||||
} else {
|
||||
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
|
||||
if (chaveSelecao.value) {
|
||||
atual = atual.filter((s) => !chavesPagina.includes(obterChaveLinha(s)))
|
||||
} else {
|
||||
atual = atual.filter((s) => !chavesPagina.includes(s))
|
||||
}
|
||||
}
|
||||
emit("update:selecionados", atual)
|
||||
}
|
||||
|
||||
return {
|
||||
mostrarSelecao,
|
||||
isSelecionado,
|
||||
alternarSelecao,
|
||||
todosSelecionados,
|
||||
algunsSelecionados,
|
||||
alternarSelecaoTodos,
|
||||
}
|
||||
}
|
||||
211
src/componentes/EliTabela/useFiltroAvancado.ts
Normal file
211
src/componentes/EliTabela/useFiltroAvancado.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* useFiltroAvancado
|
||||
* Composable que encapsula a lógica do modal de filtro avançado da EliTabela.
|
||||
*
|
||||
* Responsável por:
|
||||
* - Gerenciar a lista de linhas de filtro ativas
|
||||
* - Converter fórmulas booleanas entre notação de exibição (`{c1}`) e persistência (`{chave}`)
|
||||
* - Normalizar filtros carregados do localStorage (compatibilidade com formatos legados)
|
||||
* - Resolver o componente de entrada correto por tipo (`texto`, `numero`, `dataHora`)
|
||||
*
|
||||
* Extraído de `EliTabelaModalFiltroAvancado.vue` para manter o componente abaixo de 250 linhas.
|
||||
*/
|
||||
import { computed, type Ref, ref, watch } from "vue"
|
||||
import { EliEntradaDataHora, EliEntradaNumero, EliEntradaTexto } from "../EliEntrada"
|
||||
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
|
||||
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
|
||||
|
||||
export type FiltroBase<T> = NonNullable<tipoEliTabelaConsulta<T>["filtroAvancado"]>[number]
|
||||
|
||||
export type LinhaFiltro = {
|
||||
chave: string
|
||||
entrada: ComponenteEntrada
|
||||
/** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */
|
||||
valor: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o rótulo de exibição de uma definição de filtro.
|
||||
* Utilizado no template do modal para exibir o nome do campo ao usuário.
|
||||
*
|
||||
* @param f - Definição de filtro da configuração da tabela
|
||||
* @returns Rótulo textual do filtro
|
||||
*/
|
||||
export function rotuloDoFiltro(f: FiltroBase<string>) {
|
||||
return f.rotulo
|
||||
}
|
||||
|
||||
interface UseFiltroAvancadoOptions {
|
||||
aberto: Ref<boolean>
|
||||
filtrosBase: Ref<FiltroBase<string>[]>
|
||||
modelo: Ref<Array<{ chave: string; valor: unknown }>>
|
||||
modeloFormula: Ref<string>
|
||||
emit: {
|
||||
(event: "fechar"): void
|
||||
(event: "limpar"): void
|
||||
(event: "salvar", linhas: Array<{ chave: string; valor: unknown }>, formula?: string): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable principal do modal de filtro avançado da EliTabela.
|
||||
* Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam.
|
||||
*
|
||||
* @param options.aberto - Ref que controla se o modal está aberto
|
||||
* @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela
|
||||
* @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai)
|
||||
* @param options.modeloFormula - Ref com a fórmula booleana persistida no pai
|
||||
* @param options.emit - Função de emit do componente Vue pai
|
||||
* @returns Estado reativo e ações do modal de filtro
|
||||
*/
|
||||
export function useFiltroAvancado({
|
||||
aberto,
|
||||
filtrosBase,
|
||||
modelo,
|
||||
modeloFormula,
|
||||
emit,
|
||||
}: UseFiltroAvancadoOptions) {
|
||||
const linhas = ref<LinhaFiltro[]>([])
|
||||
const chaveParaAdicionar = ref<string>("")
|
||||
const formulaCustomizada = ref("")
|
||||
|
||||
const formulaGerada = computed(() => linhas.value.map((_, i) => `{c${i + 1}}`).join(" E "))
|
||||
|
||||
function formulaParaExibicao(formula: string): string {
|
||||
let resultado = formula
|
||||
linhas.value.forEach((l, i) => {
|
||||
resultado = resultado.replace(new RegExp(`\\{${l.chave}\\}`, "g"), `{c${i + 1}}`)
|
||||
})
|
||||
return resultado
|
||||
}
|
||||
|
||||
function formulaParaPersistencia(formula: string): string {
|
||||
let resultado = formula
|
||||
for (let i = linhas.value.length; i >= 1; i--) {
|
||||
const chave = linhas.value[i - 1]?.chave
|
||||
if (chave) {
|
||||
resultado = resultado.replace(new RegExp(`\\{c${i}\\}`, "g"), `{${chave}}`)
|
||||
}
|
||||
}
|
||||
return resultado
|
||||
}
|
||||
|
||||
const chavesDisponiveis = computed(() => (filtrosBase.value ?? []).map((b) => String(b.chave)))
|
||||
|
||||
const opcoesParaAdicionar = computed(() => {
|
||||
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
|
||||
return (filtrosBase.value ?? []).filter((b) => !usadas.has(String(b.chave)))
|
||||
})
|
||||
|
||||
function componenteEntrada(entrada: ComponenteEntrada) {
|
||||
const tipo = entrada?.[0]
|
||||
if (tipo === "numero") return EliEntradaNumero
|
||||
if (tipo === "dataHora") return EliEntradaDataHora
|
||||
return EliEntradaTexto
|
||||
}
|
||||
|
||||
function opcoesEntrada(entrada: ComponenteEntrada) {
|
||||
const opcoes: Record<string, unknown> =
|
||||
typeof entrada?.[1] === "object" && entrada[1] !== null
|
||||
? { ...(entrada[1] as Record<string, unknown>) }
|
||||
: {}
|
||||
return { ...opcoes, rotulo: "" }
|
||||
}
|
||||
|
||||
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
|
||||
if (entrada?.[0] === "numero") return null
|
||||
return ""
|
||||
}
|
||||
|
||||
function normalizarModelo() {
|
||||
const base = filtrosBase.value ?? []
|
||||
const modeloArr = Array.isArray(modelo.value) ? modelo.value : []
|
||||
linhas.value = modeloArr
|
||||
.map((m: unknown) => {
|
||||
if (!m || typeof m !== "object") return null
|
||||
const item = m as Record<string, unknown>
|
||||
const baseItem = base.find((b) => String(b.chave) === String(item["chave"])) ?? base[0]
|
||||
if (!baseItem) return null
|
||||
const entrada = (baseItem.entrada ?? item["entrada"]) as ComponenteEntrada
|
||||
const chave = String(baseItem.chave ?? item["chave"])
|
||||
const val = item["valor"] ?? valorInicialPorEntrada(entrada)
|
||||
return { chave, entrada, valor: val } as LinhaFiltro
|
||||
})
|
||||
.filter(Boolean) as LinhaFiltro[]
|
||||
|
||||
linhas.value = linhas.value.filter((l) => chavesDisponiveis.value.includes(String(l.chave)))
|
||||
|
||||
if (modeloFormula.value) {
|
||||
formulaCustomizada.value = formulaParaExibicao(modeloFormula.value)
|
||||
} else {
|
||||
formulaCustomizada.value = formulaGerada.value
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [aberto.value, filtrosBase.value, modelo.value] as const,
|
||||
() => {
|
||||
if (aberto.value) normalizarModelo()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
function adicionar() {
|
||||
if (!chaveParaAdicionar.value) return
|
||||
const b0 = (filtrosBase.value ?? []).find(
|
||||
(b) => String(b.chave) === String(chaveParaAdicionar.value),
|
||||
)
|
||||
if (!b0) return
|
||||
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
|
||||
linhas.value.push({
|
||||
chave: String(b0.chave),
|
||||
entrada: b0.entrada,
|
||||
valor: valorInicialPorEntrada(b0.entrada),
|
||||
})
|
||||
formulaCustomizada.value = formulaGerada.value
|
||||
chaveParaAdicionar.value = ""
|
||||
}
|
||||
|
||||
function remover(idx: number) {
|
||||
linhas.value.splice(idx, 1)
|
||||
formulaCustomizada.value = formulaGerada.value
|
||||
}
|
||||
|
||||
function emitFechar() {
|
||||
emit("fechar")
|
||||
}
|
||||
function emitLimpar() {
|
||||
emit("limpar")
|
||||
}
|
||||
|
||||
function emitSalvar() {
|
||||
const formulaPersistida = formulaParaPersistencia(formulaCustomizada.value)
|
||||
emit(
|
||||
"salvar",
|
||||
linhas.value.map((l) => ({ chave: l.chave, valor: l.valor })),
|
||||
formulaPersistida,
|
||||
)
|
||||
}
|
||||
|
||||
function rotuloDoFiltroAtivo(chave: string): string {
|
||||
const base = (filtrosBase.value ?? []).find((b) => String(b.chave) === chave)
|
||||
return base ? rotuloDoFiltro(base) : chave
|
||||
}
|
||||
|
||||
return {
|
||||
linhas,
|
||||
opcoesParaAdicionar,
|
||||
chaveParaAdicionar,
|
||||
formulaCustomizada,
|
||||
formulaGerada,
|
||||
componenteEntrada,
|
||||
opcoesEntrada,
|
||||
adicionar,
|
||||
remover,
|
||||
emitFechar,
|
||||
emitSalvar,
|
||||
emitLimpar,
|
||||
rotuloDoFiltro,
|
||||
rotuloDoFiltroAtivo,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue