diff --git a/.cursorrules b/.agent similarity index 85% rename from .cursorrules rename to .agent index 2ae1843..13ae9ca 100644 --- a/.cursorrules +++ b/.agent @@ -22,41 +22,11 @@ Construir um Design System de componentes em **Vue 3** para reutilização em m - TypeScript (modo estrito e tipagem forte) - **defineComponent** (obrigatório) - Sem TSX (padrão: ` + diff --git a/src/componentes/EliEntrada/EliEntradaParagrafo.vue b/src/componentes/EliEntrada/EliEntradaParagrafo.vue index f2d6638..d74014b 100644 --- a/src/componentes/EliEntrada/EliEntradaParagrafo.vue +++ b/src/componentes/EliEntrada/EliEntradaParagrafo.vue @@ -13,7 +13,6 @@ :persistent-hint="Boolean(opcoes?.dicaPersistente)" :density="opcoes?.densidade ?? 'comfortable'" :variant="opcoes?.variante ?? 'outlined'" - :readonly="Boolean(opcoes?.apenasLeitura)" auto-grow v-bind="attrs" @focus="() => emit('focus')" @@ -22,11 +21,11 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/componentes/EliEntrada/EliEntradaSelecao.vue b/src/componentes/EliEntrada/EliEntradaSelecao.vue index 7e987cf..1c0f4d7 100644 --- a/src/componentes/EliEntrada/EliEntradaSelecao.vue +++ b/src/componentes/EliEntrada/EliEntradaSelecao.vue @@ -1,15 +1,14 @@ - + diff --git a/src/componentes/EliEntrada/index.ts b/src/componentes/EliEntrada/index.ts index 95948d4..e4a747c 100644 --- a/src/componentes/EliEntrada/index.ts +++ b/src/componentes/EliEntrada/index.ts @@ -1,20 +1,8 @@ -import EliEntradaConfirmacao from "./EliEntradaConfirmacao.vue" -import EliEntradaCoordenadas from "./EliEntradaCoordenadas.vue" -import EliEntradaDataHora from "./EliEntradaDataHora/index.vue" -import EliEntradaNumero from "./EliEntradaNumero.vue" -import EliEntradaParagrafo from "./EliEntradaParagrafo.vue" -import EliEntradaSelecao from "./EliEntradaSelecao.vue" -import EliEntradaSelecaoMultipla from "./EliEntradaSelecaoMultipla.vue" -import EliEntradaTexto from "./EliEntradaTexto.vue" +import EliEntradaTexto from "./EliEntradaTexto.vue"; +import EliEntradaNumero from "./EliEntradaNumero.vue"; +import EliEntradaDataHora from "./EliEntradaDataHora.vue"; +import EliEntradaParagrafo from "./EliEntradaParagrafo.vue"; +import EliEntradaSelecao from "./EliEntradaSelecao.vue"; -export type { PadroesEntradas, TipoEntrada } from "./tiposEntradas" -export { - EliEntradaConfirmacao, - EliEntradaCoordenadas, - EliEntradaDataHora, - EliEntradaNumero, - EliEntradaParagrafo, - EliEntradaSelecao, - EliEntradaSelecaoMultipla, - EliEntradaTexto, -} +export { EliEntradaTexto, EliEntradaNumero, EliEntradaDataHora, EliEntradaParagrafo, EliEntradaSelecao }; +export type { PadroesEntradas, TipoEntrada } from "./tiposEntradas"; diff --git a/src/componentes/EliEntrada/registryEliEntradas.ts b/src/componentes/EliEntrada/registryEliEntradas.ts index 433afca..1febd7d 100644 --- a/src/componentes/EliEntrada/registryEliEntradas.ts +++ b/src/componentes/EliEntrada/registryEliEntradas.ts @@ -1,32 +1,17 @@ -import type { Component } from "vue" -import EliEntradaConfirmacao from "./EliEntradaConfirmacao.vue" -import EliEntradaCoordenadas from "./EliEntradaCoordenadas.vue" -import EliEntradaDataHora from "./EliEntradaDataHora/index.vue" -import EliEntradaNumero from "./EliEntradaNumero.vue" -import EliEntradaParagrafo from "./EliEntradaParagrafo.vue" -import EliEntradaSelecao from "./EliEntradaSelecao.vue" -import EliEntradaSelecaoMultipla from "./EliEntradaSelecaoMultipla.vue" -import EliEntradaTexto from "./EliEntradaTexto.vue" +import type { Component } from "vue"; -import type { TipoEntrada } from "./tiposEntradas" +import EliEntradaTexto from "./EliEntradaTexto.vue"; +import EliEntradaNumero from "./EliEntradaNumero.vue"; +import EliEntradaDataHora from "./EliEntradaDataHora.vue"; +import EliEntradaParagrafo from "./EliEntradaParagrafo.vue"; +import EliEntradaSelecao from "./EliEntradaSelecao.vue"; -/** - * 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: Record = { +import type { TipoEntrada } from "./tiposEntradas"; + +export const registryTabelaCelulas = { texto: EliEntradaTexto, - coordenadas: EliEntradaCoordenadas, numero: EliEntradaNumero, dataHora: EliEntradaDataHora, paragrafo: EliEntradaParagrafo, selecao: EliEntradaSelecao, - selecaoMultipla: EliEntradaSelecaoMultipla, - confirmacao: EliEntradaConfirmacao, -} +} as const satisfies Record; diff --git a/src/componentes/EliEntrada/tiposEntradas.ts b/src/componentes/EliEntrada/tiposEntradas.ts index eb47e03..faa1313 100644 --- a/src/componentes/EliEntrada/tiposEntradas.ts +++ b/src/componentes/EliEntrada/tiposEntradas.ts @@ -1,13 +1,17 @@ /** - * Tipos base para componentes de entrada (EliEntrada*). - * PadroesEntradas[tipo] → tipagem completa (value + opcoes). - * TipoEntrada → união de todos os tipos suportados. + * Tipos base para componentes de entrada (EliEntrada*) + * + * Objetivo: + * - Padronizar o shape de dados de todos os componentes de entrada. + * - Cada entrada possui sempre: + * 1) `value`: o valor atual (tipado) + * 2) `opcoes`: configuração do componente (rótulo, placeholder e extras por tipo) + * + * Como usar: + * - `PadroesEntradas[tipo]` retorna a tipagem completa (value + opcoes) daquele tipo. + * - `TipoEntrada` é a união com todos os tipos suportados (ex.: "texto" | "numero"). */ -import type { zCoordenadas } from "p-comuns" -import type z from "zod" -import type { OpcoesSelecaoBase } from "./tiposSelecao" - /** * Contrato padrão de uma entrada. * @@ -22,11 +26,9 @@ export type tipoPadraoEntrada = {}> = { opcoes: { /** Rótulo exibido ao usuário */ rotulo: string + /** Texto de ajuda dentro do input quando vazio */ placeholder?: string - - /** Se true, o campo será somente leitura (não editável) */ - apenasLeitura?: boolean } & Mais } @@ -57,6 +59,8 @@ export type PadroesEntradas = { numero: tipoPadraoEntrada< number | null | undefined, { + + /** Unidade de medida (ex.: "kg", "m³") */ sufixo?: string @@ -80,22 +84,31 @@ export type PadroesEntradas = { { /** Define o tipo de entrada. - `dataHora`: datetime-local - `data`: date */ modo?: "data" | "dataHora" + /** Se true, mostra ícone para limpar o valor (Vuetify clearable). */ limpavel?: boolean + /** Estado de erro (visual). */ erro?: boolean + /** Mensagens de erro. */ mensagensErro?: string | string[] + /** Texto de apoio. */ dica?: string + /** Mantém a dica sempre visível. */ dicaPersistente?: boolean + /** Valor mínimo permitido (ISO 8601 - offset ou Z). */ min?: string + /** Valor máximo permitido (ISO 8601 - offset ou Z). */ max?: string + /** Densidade do campo (Vuetify). */ densidade?: import("../../tipos").CampoDensidade + /** Variante do v-text-field (Vuetify). */ variante?: import("../../tipos").CampoVariante } @@ -106,65 +119,64 @@ export type PadroesEntradas = { { /** Quantidade de linhas visíveis no textarea (Vuetify `rows`). */ linhas?: number + /** Limite máximo de caracteres permitidos (se definido). */ limiteCaracteres?: number + /** Se true, mostra ícone para limpar o valor (Vuetify clearable). */ limpavel?: boolean + /** Estado de erro (visual). */ erro?: boolean + /** Mensagens de erro. */ mensagensErro?: string | string[] + /** Texto de apoio. */ dica?: string + /** Mantém a dica sempre visível. */ dicaPersistente?: boolean + /** Densidade do campo (Vuetify). */ densidade?: import("../../tipos").CampoDensidade + /** Variante do v-text-field (Vuetify). */ variante?: import("../../tipos").CampoVariante } > - /** Seleção simples — ver {@link OpcoesSelecaoBase} para opções disponíveis. */ - selecao: tipoPadraoEntrada - - /** Seleção múltipla — ver {@link OpcoesSelecaoBase} para opções disponíveis. */ - selecaoMultipla: tipoPadraoEntrada - - confirmacao: tipoPadraoEntrada< - boolean, + selecao: tipoPadraoEntrada< + string | null | undefined, { - /** Cor do switch quando ativo (Vuetify color). */ - cor?: string + /** + * Carrega os itens da seleção (sincrono ou async). + * - Cada item precisa ter uma chave estável (value) e um rótulo (title). + */ + itens: () => + | { chave: string; rotulo: string }[] + | Promise<{ chave: string; rotulo: string }[]> - /** Se true, desabilita a interação. */ - desabilitado?: boolean + /** Se true, mostra ícone para limpar o valor (Vuetify clearable). */ + limpavel?: boolean - /** Texto exibido ao lado do switch quando ativo. */ - rotuloAtivo?: string + /** Estado de erro (visual). */ + erro?: boolean - /** Texto exibido ao lado do switch quando inativo. */ - rotuloInativo?: string + /** Mensagens de erro. */ + mensagensErro?: string | string[] + + /** Texto de apoio. */ + dica?: string + + /** Mantém a dica sempre visível. */ + dicaPersistente?: boolean /** Densidade do campo (Vuetify). */ densidade?: import("../../tipos").CampoDensidade - } - > - coordenadas: tipoPadraoEntrada< - z.infer | null | undefined, - { - /** Nível de zoom inicial do mapa Leaflet (default: 4) */ - zoom?: number - /** Altura do container do mapa em pixels (default: 300) */ - alturaMapa?: number - /** Camadas vetoriais GeoJSON (contornos do IBGE) */ - camadas?: { - /** Exibir contornos dos estados */ - estados?: boolean - /** Exibir contornos dos municípios (carregados por UF conforme viewport) */ - municipios?: boolean - } + /** Variante do v-text-field (Vuetify). */ + variante?: import("../../tipos").CampoVariante } > } @@ -175,11 +187,12 @@ export type PadroesEntradas = { */ export type TipoEntrada = keyof PadroesEntradas -export type PadraoComponenteEntrada = readonly [ - T, - PadroesEntradas[T]["opcoes"], -] + + + +export type PadraoComponenteEntrada = + readonly [T, PadroesEntradas[T]['opcoes']] export type ComponenteEntrada = { [K in TipoEntrada]: PadraoComponenteEntrada -}[TipoEntrada] +}[TipoEntrada] \ No newline at end of file diff --git a/src/componentes/EliEntrada/tiposSelecao.ts b/src/componentes/EliEntrada/tiposSelecao.ts deleted file mode 100644 index d3eca1b..0000000 --- a/src/componentes/EliEntrada/tiposSelecao.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Tipos específicos dos componentes de seleção (simples e múltipla). - * Extraído de tiposEntradas.ts para respeitar o limite de 250 linhas. - */ - -import type { CampoDensidade, CampoVariante } from "../../tipos" - -/** Item padrão dos componentes de seleção. */ -export type ItemSelecaoOpcoes = { chave: string; rotulo: string } - -/** Carregador de itens (síncrono ou async). */ -export type CarregadorItens = () => ItemSelecaoOpcoes[] | Promise - -/** Opções compartilhadas entre seleção simples e múltipla. */ -export type OpcoesSelecaoBase = { - /** - * Carrega os itens da seleção (sincrono ou async). - * - Cada item precisa ter uma chave estável (value) e um rótulo (title). - */ - itens: CarregadorItens - - /** - * Lista de chaves (valores de `chave`) que devem aparecer desativadas. - * Os itens aparecerão visíveis no menu, mas não poderão ser selecionados. - */ - desativado?: string[] - - /** Se true, mostra ícone para limpar o valor (Vuetify clearable). */ - limpavel?: boolean - - /** Estado de erro (visual). */ - erro?: boolean - - /** Mensagens de erro. */ - mensagensErro?: string | string[] - - /** Texto de apoio. */ - dica?: string - - /** Mantém a dica sempre visível. */ - dicaPersistente?: boolean - - /** Densidade do campo (Vuetify). */ - densidade?: CampoDensidade - - /** Variante do v-text-field (Vuetify). */ - variante?: CampoVariante -} diff --git a/src/componentes/EliEntrada/utils/camadas.ts b/src/componentes/EliEntrada/utils/camadas.ts deleted file mode 100644 index aaadb63..0000000 --- a/src/componentes/EliEntrada/utils/camadas.ts +++ /dev/null @@ -1,160 +0,0 @@ -import L from "leaflet" - -/** URL base do bucket MinIO para arquivos GeoJSON */ -const URL_BASE = "https://paiol.idz.one/geo" - -/** Zoom mínimo para exibir municípios */ -const ZOOM_MUNICIPIOS = 7 -/** Zoom a partir do qual usa resolução intermediária */ -const ZOOM_HD = 10 -/** Zoom a partir do qual usa resolução máxima */ -const ZOOM_UHD = 13 - -/** - * Diretórios no MinIO por nível de detalhe: - * - municipios/ → qualidade mínima (zoom 7-9) - * - municipios_hd/ → qualidade média (zoom 10-12) - * - municipios_uhd/ → qualidade máxima (zoom 13+) - */ -type NivelDetalhe = "municipios" | "municipios_hd" | "municipios_uhd" - -/** Cache global — evita re-download entre instâncias do componente */ -const cacheGeoJSON = new Map() - -export interface OpcoesCamadas { - estados?: boolean - municipios?: boolean -} - -interface InfoEstado { - codigo: string - bounds: L.LatLngBounds -} - -async function buscarGeoJSON(url: string): Promise { - const cached = cacheGeoJSON.get(url) - if (cached) return cached - try { - const resp = await fetch(url) - if (!resp.ok) return null - const dados = (await resp.json()) as GeoJSON.GeoJsonObject - cacheGeoJSON.set(url, dados) - return dados - } catch { - return null - } -} - -function estiloEstados(): L.PathOptions { - return { - color: "#1565C0", - weight: 1.5, - fillColor: "#42A5F5", - fillOpacity: 0.05, - dashArray: "4 4", - } -} - -function estiloMunicipios(): L.PathOptions { - return { - color: "#78909C", - weight: 0.8, - fillColor: "#B0BEC5", - fillOpacity: 0.03, - } -} - -function nivelPorZoom(zoom: number): NivelDetalhe { - if (zoom >= ZOOM_UHD) return "municipios_uhd" - if (zoom >= ZOOM_HD) return "municipios_hd" - return "municipios" -} - -/** - * Inicializa camadas vetoriais GeoJSON no mapa Leaflet. - * - Estados: overlay imediato - * - Municípios: sob demanda por UF, com 3 níveis de detalhe por zoom - */ -export function inicializarCamadas(mapa: L.Map, opcoes: OpcoesCamadas): void { - if (!opcoes.estados && !opcoes.municipios) return - carregarEstados(mapa, opcoes) -} - -async function carregarEstados(mapa: L.Map, opcoes: OpcoesCamadas): Promise { - const dados = await buscarGeoJSON(`${URL_BASE}/estados.geojson`) - if (!dados) return - - const estadosInfo: InfoEstado[] = [] - const camada = L.geoJSON(dados, { - style: estiloEstados, - onEachFeature: (feature, layer) => { - const codigo = String(feature.properties?.codarea ?? "") - if (!codigo) return - estadosInfo.push({ - codigo, - bounds: (layer as L.Polygon).getBounds(), - }) - }, - }) - - if (opcoes.estados) camada.addTo(mapa) - if (opcoes.municipios) configurarMunicipios(mapa, estadosInfo) -} - -function configurarMunicipios(mapa: L.Map, estadosInfo: InfoEstado[]): void { - /** Grupo de layers por nível de detalhe */ - const grupos: Record = { - municipios: L.layerGroup(), - municipios_hd: L.layerGroup(), - municipios_uhd: L.layerGroup(), - } - /** UFs já carregadas por nível */ - const carregados: Record> = { - municipios: new Set(), - municipios_hd: new Set(), - municipios_uhd: new Set(), - } - let nivelAtivo: NivelDetalhe | null = null - - const verificarViewport = () => { - const zoom = mapa.getZoom() - if (zoom < ZOOM_MUNICIPIOS) { - if (nivelAtivo && mapa.hasLayer(grupos[nivelAtivo])) { - mapa.removeLayer(grupos[nivelAtivo]) - } - nivelAtivo = null - return - } - - const nivel = nivelPorZoom(zoom) - if (nivel !== nivelAtivo) { - if (nivelAtivo && mapa.hasLayer(grupos[nivelAtivo])) { - mapa.removeLayer(grupos[nivelAtivo]) - } - grupos[nivel].addTo(mapa) - nivelAtivo = nivel - } - - const bounds = mapa.getBounds() - for (const estado of estadosInfo) { - if (carregados[nivel].has(estado.codigo)) continue - if (!bounds.intersects(estado.bounds)) continue - carregados[nivel].add(estado.codigo) - carregarMunicipiosUF(nivel, estado.codigo, grupos[nivel]) - } - } - - mapa.on("moveend", verificarViewport) - mapa.on("zoomend", verificarViewport) - verificarViewport() -} - -async function carregarMunicipiosUF( - nivel: NivelDetalhe, - codigoUF: string, - grupo: L.LayerGroup, -): Promise { - const dados = await buscarGeoJSON(`${URL_BASE}/${nivel}/${codigoUF}.geojson`) - if (!dados) return - grupo.addLayer(L.geoJSON(dados, { style: estiloMunicipios })) -} diff --git a/src/componentes/EliEntrada/utils/cep.ts b/src/componentes/EliEntrada/utils/cep.ts index cc0c5de..9eb4cb6 100644 --- a/src/componentes/EliEntrada/utils/cep.ts +++ b/src/componentes/EliEntrada/utils/cep.ts @@ -1,10 +1,10 @@ function somenteNumeros(valor: string) { - return valor.replace(/\D+/g, "") + return valor.replace(/\D+/g, ""); } /** Formata CEP no padrão 00000-000 */ export function formatarCep(valor: string) { - const digitos = somenteNumeros(valor) - if (!digitos) return "" - return digitos.replace(/^(\d{5})(\d)/, "$1-$2").slice(0, 9) + const digitos = somenteNumeros(valor); + if (!digitos) return ""; + return digitos.replace(/^(\d{5})(\d)/, "$1-$2").slice(0, 9); } diff --git a/src/componentes/EliEntrada/utils/coordenadas.ts b/src/componentes/EliEntrada/utils/coordenadas.ts deleted file mode 100644 index f1a8d20..0000000 --- a/src/componentes/EliEntrada/utils/coordenadas.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { zCoordenadas } from "p-comuns" -import type z from "zod" - -export type TipoCoordenadas = z.infer - -/** Centro padrão do Brasil (Brasília) */ -export const COORDENADAS_PADRAO: TipoCoordenadas = { - formato: "decimal", - datum: "EPSG:4326", - latitude: -15.7801, - longitude: -47.9292, -} - -/** Zoom padrão do mapa */ -export const ZOOM_PADRAO = 4 - -/** Altura padrão do mapa em pixels */ -export const ALTURA_MAPA_PADRAO = 300 - -/** Limites válidos para latitude (-90 a 90) e longitude (-180 a 180) */ -export function coordenadaValida(lat: number, lng: number): boolean { - return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 -} - -/** Formata número de coordenada para exibição com vírgula (padrão BR) */ -export function formatarCoordenada(valor: number): string { - return valor.toFixed(6).replace(".", ",") -} - -/** Converte texto digitado (aceita vírgula ou ponto) em número. Retorna null se inválido. */ -export function parseCoordenada(v: string | number): number | null { - if (typeof v === "number") return Number.isNaN(v) ? null : v - const normalizado = v.replace(",", ".") - const n = Number.parseFloat(normalizado) - return Number.isNaN(n) ? null : n -} - -/** Tenta geolocalização do navegador; se falhar, usa GeoIP como fallback */ -export async function obterLocalizacao(): Promise<{ latitude: number; longitude: number }> { - try { - return await obterViaBrowser() - } catch { - return await obterViaGeoIp() - } -} - -function obterViaBrowser(): Promise<{ latitude: number; longitude: number }> { - return new Promise((resolve, reject) => { - if (!navigator.geolocation) return reject(new Error("Geolocation indisponível")) - navigator.geolocation.getCurrentPosition( - (pos) => resolve({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }), - (err) => reject(err), - { enableHighAccuracy: true, timeout: 10000 }, - ) - }) -} - -async function obterViaGeoIp(): Promise<{ latitude: number; longitude: number }> { - const resp = await fetch("https://ipapi.co/json/") - if (!resp.ok) throw new Error(`GeoIP falhou: ${resp.status}`) - const dados: { latitude: number; longitude: number } = await resp.json() - return { latitude: dados.latitude, longitude: dados.longitude } -} diff --git a/src/componentes/EliEntrada/utils/cpfCnpj.ts b/src/componentes/EliEntrada/utils/cpfCnpj.ts index 9298d8a..8e66ac1 100644 --- a/src/componentes/EliEntrada/utils/cpfCnpj.ts +++ b/src/componentes/EliEntrada/utils/cpfCnpj.ts @@ -1,17 +1,9 @@ function somenteNumeros(v: string): string { - return v.replace(/\D+/g, "") + 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) + const d = somenteNumeros(v); // CPF if (d.length <= 11) { @@ -19,7 +11,7 @@ export function formatarCpfCnpj(v: string): string { .replace(/(\d{3})(\d)/, "$1.$2") .replace(/(\d{3})(\d)/, "$1.$2") .replace(/(\d{3})(\d{1,2})$/, "$1-$2") - .slice(0, 14) + .slice(0, 14); } // CNPJ @@ -28,5 +20,5 @@ export function formatarCpfCnpj(v: string): string { .replace(/^(\d{2})\.(\d{3})(\d)/, "$1.$2.$3") .replace(/\.(\d{3})(\d)/, ".$1/$2") .replace(/(\d{4})(\d)/, "$1-$2") - .slice(0, 18) + .slice(0, 18); } diff --git a/src/componentes/EliEntrada/utils/filtroSelecao.ts b/src/componentes/EliEntrada/utils/filtroSelecao.ts deleted file mode 100644 index facfca0..0000000 --- a/src/componentes/EliEntrada/utils/filtroSelecao.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Utilitário de filtragem para os componentes de seleção (EliEntradaSelecao e EliEntradaSelecaoMultipla). - * - * Normaliza a string removendo acentos e convertendo para minúsculas antes de comparar, - * garantindo que "São Paulo" seja encontrado digitando "sao paulo" ou "SÃO PAULO". - */ - -/** Remove acentos e converte para minúsculas para comparação insensível. */ -function normalizar(texto: string): string { - return texto - .normalize("NFD") // decompõe caracteres acentuados (ex: "ã" → "a" + diacrítico) - .replace(/\p{Diacritic}/gu, "") // remove os diacríticos - .toLowerCase() -} - -/** - * Função de filtro customizada para v-autocomplete do Vuetify. - * - * Vuetify passa (value, query, item): - * - `value` → texto do item renderizado (item-title resolvido) - * - `query` → texto digitado pelo usuário - * - `item` → objeto completo do item (não usado aqui) - */ -export function filtroSelecao(value: string, query: string): boolean { - if (!query) return true - return normalizar(value).includes(normalizar(query)) -} diff --git a/src/componentes/EliEntrada/utils/telefone.ts b/src/componentes/EliEntrada/utils/telefone.ts index 787ad4c..dcd7d04 100644 --- a/src/componentes/EliEntrada/utils/telefone.ts +++ b/src/componentes/EliEntrada/utils/telefone.ts @@ -2,28 +2,28 @@ * Remove tudo que não é número */ export function sanitizeTelefone(value: string): string { - return value.replace(/\D+/g, "") + return value.replace(/\D+/g, ""); } /** * Aplica máscara dinâmica de telefone BR */ export function formatTelefone(value: string): string { - const digits = sanitizeTelefone(value) + const digits = sanitizeTelefone(value); - if (!digits) return "" + if (!digits) return ""; // (99) 9999-9999 if (digits.length <= 10) { return digits .replace(/^(\d{2})(\d)/, "($1) $2") .replace(/(\d{4})(\d)/, "$1-$2") - .slice(0, 14) + .slice(0, 14); } // (99) 99999-9999 return digits .replace(/^(\d{2})(\d)/, "($1) $2") .replace(/(\d{5})(\d)/, "$1-$2") - .slice(0, 15) + .slice(0, 15); } diff --git a/src/componentes/EliEntrada/utils/useCoordenadas.ts b/src/componentes/EliEntrada/utils/useCoordenadas.ts deleted file mode 100644 index 60cefc9..0000000 --- a/src/componentes/EliEntrada/utils/useCoordenadas.ts +++ /dev/null @@ -1,205 +0,0 @@ -import L from "leaflet" -import { computed, onBeforeUnmount, onMounted, type Ref, ref, watch } from "vue" -import type { PadroesEntradas } from "../tiposEntradas" -import { inicializarCamadas } from "./camadas" -import { - ALTURA_MAPA_PADRAO, - COORDENADAS_PADRAO, - coordenadaValida, - formatarCoordenada, - obterLocalizacao, - parseCoordenada, - ZOOM_PADRAO, -} from "./coordenadas" - -type EntradaCoordenadas = PadroesEntradas["coordenadas"] -type EmitFn = (event: string, ...args: unknown[]) => void - -interface OpcoesComposable { - value: () => EntradaCoordenadas["value"] - opcoes: () => EntradaCoordenadas["opcoes"] - emit: EmitFn - mapaRef: Ref -} - -export function useCoordenadas({ value, opcoes, emit, mapaRef }: OpcoesComposable) { - const geolocalizando = ref(false) - let mapa: L.Map | null = null - let marcador: L.Marker | null = null - - const alturaEfetiva = computed(() => opcoes()?.alturaMapa ?? ALTURA_MAPA_PADRAO) - const zoomEfetivo = computed(() => opcoes()?.zoom ?? ZOOM_PADRAO) - const somenteLeitura = computed(() => Boolean(opcoes()?.apenasLeitura)) - const possuiValor = computed(() => value() != null) - - const latDisplay = computed(() => { - const v = value() - return v ? formatarCoordenada(v.latitude) : "" - }) - const lngDisplay = computed(() => { - const v = value() - return v ? formatarCoordenada(v.longitude) : "" - }) - - function emitirValor(lat: number, lng: number) { - if (!coordenadaValida(lat, lng)) return - const novoValor: NonNullable = { - formato: "decimal", - datum: "EPSG:4326", - latitude: Number.parseFloat(lat.toFixed(6)), - longitude: Number.parseFloat(lng.toFixed(6)), - } - emit("update:value", novoValor) - emit("input", novoValor) - emit("change", novoValor) - } - - function limpar() { - emit("update:value", null) - emit("input", null) - emit("change", null) - removerMarcador() - } - - function removerMarcador() { - if (marcador && mapa) { - mapa.removeLayer(marcador) - marcador = null - } - } - - function adicionarMarcador(lat: number, lng: number) { - if (!mapa) return - marcador = L.marker([lat, lng], { draggable: !somenteLeitura.value }).addTo(mapa) - marcador.on("dragend", () => { - if (!marcador) return - const pos = marcador.getLatLng() - emitirValor(pos.lat, pos.lng) - }) - mapa.setView([lat, lng], mapa.getZoom()) - } - - function onLatChange(v: string | number) { - const lat = parseCoordenada(v) - if (lat === null) return - emitirValor(lat, value()?.longitude ?? COORDENADAS_PADRAO.longitude) - } - - function onLngChange(v: string | number) { - const lng = parseCoordenada(v) - if (lng === null) return - emitirValor(value()?.latitude ?? COORDENADAS_PADRAO.latitude, lng) - } - - function atualizarMarcador(lat: number, lng: number) { - if (!mapa) return - if (!marcador) { - adicionarMarcador(lat, lng) - return - } - const posAtual = marcador.getLatLng() - if (posAtual.lat === lat && posAtual.lng === lng) return - marcador.setLatLng([lat, lng]) - mapa.setView([lat, lng], mapa.getZoom()) - } - - async function usarMinhaLocalizacao() { - geolocalizando.value = true - try { - const pos = await obterLocalizacao() - emitirValor(pos.latitude, pos.longitude) - } finally { - geolocalizando.value = false - } - } - - async function inicializarMapa() { - if (!mapaRef.value) return - const v = value() - const temValor = v != null - - let centroLat: number - let centroLng: number - let zoomInicial = zoomEfetivo.value - - if (temValor) { - centroLat = v.latitude - centroLng = v.longitude - zoomInicial = Math.max(zoomInicial, 13) - } else { - try { - const pos = await obterLocalizacao() - centroLat = pos.latitude - centroLng = pos.longitude - zoomInicial = 13 - } catch { - centroLat = COORDENADAS_PADRAO.latitude - centroLng = COORDENADAS_PADRAO.longitude - } - } - - mapa = L.map(mapaRef.value).setView([centroLat, centroLng], zoomInicial) - - const camadaMapa = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, - }) - const camadaSatelite = L.tileLayer( - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - { attribution: "© Esri", maxZoom: 19 }, - ) - camadaMapa.addTo(mapa) - L.control.layers({ Mapa: camadaMapa, Satélite: camadaSatelite }).addTo(mapa) - - if (temValor) adicionarMarcador(v.latitude, v.longitude) - - mapa.on("click", (e: L.LeafletMouseEvent) => { - if (somenteLeitura.value) return - emitirValor(e.latlng.lat, e.latlng.lng) - }) - - const cfgCamadas = opcoes()?.camadas - if (cfgCamadas) inicializarCamadas(mapa, cfgCamadas) - } - - watch( - value, - (v) => { - if (!v) { - removerMarcador() - return - } - atualizarMarcador(v.latitude, v.longitude) - }, - { deep: true }, - ) - - onMounted(() => { - inicializarMapa() - if (mapaRef.value) { - const observer = new ResizeObserver(() => mapa?.invalidateSize()) - observer.observe(mapaRef.value) - onBeforeUnmount(() => observer.disconnect()) - } - }) - - onBeforeUnmount(() => { - if (mapa) { - mapa.remove() - mapa = null - marcador = null - } - }) - - return { - alturaEfetiva, - geolocalizando, - possuiValor, - latDisplay, - lngDisplay, - onLatChange, - onLngChange, - usarMinhaLocalizacao, - limpar, - } -} diff --git a/src/componentes/EliTabela/EliTabela.css b/src/componentes/EliTabela/EliTabela.css index 9846349..cbc5f4a 100644 --- a/src/componentes/EliTabela/EliTabela.css +++ b/src/componentes/EliTabela/EliTabela.css @@ -1,23 +1,7 @@ -@import "./EliTabelaAcoes.css"; -@import "./EliTabelaSelecao.css"; .eli-tabela { 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; @@ -27,48 +11,25 @@ overflow: visible; } -/* Corrige o vazamento (pontas quadradas) do fundo dos th/td nas bordas arredondadas da tabela */ -.eli-tabela__table thead tr:first-child th:first-child { - border-top-left-radius: 11px; -} -.eli-tabela__table thead tr:first-child th:last-child { - border-top-right-radius: 11px; -} -.eli-tabela__table tbody tr:last-child td:first-child { - border-bottom-left-radius: 11px; -} -.eli-tabela__table tbody tr:last-child td:last-child { - border-bottom-right-radius: 11px; -} - .eli-tabela__tbody { 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: var(--cor-eli-primaria-zebra, #F6F9E8); + background: rgba(15, 23, 42, 0.02); } .eli-tabela__th, .eli-tabela__td { padding: 10px 12px; - border-bottom: 1px solid var(--cor-eli-borda); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); vertical-align: top; } .eli-tabela__th { text-align: left; font-weight: 600; - 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); + background: rgba(0, 0, 0, 0.03); } .eli-tabela__th--ordenavel { @@ -96,16 +57,16 @@ .eli-tabela__th-botao:hover, .eli-tabela__th-botao:focus-visible { - color: var(--cor-eli-primaria-texto, #2E3F00); + color: rgba(15, 23, 42, 0.85); } .eli-tabela__th-botao:focus-visible { - outline: 2px solid var(--cor-eli-primaria, #688C00); + outline: 2px solid rgba(37, 99, 235, 0.45); outline-offset: 2px; } .eli-tabela__th-botao--ativo { - color: var(--cor-eli-primaria-texto, #2E3F00); + color: rgba(37, 99, 235, 0.95); } .eli-tabela__th-texto { @@ -134,19 +95,15 @@ background: rgba(0, 0, 0, 0.03); } + + .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; @@ -163,33 +120,12 @@ } .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; @@ -211,31 +147,6 @@ z-index: 200; } -.eli-tabela__rodape { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - margin-top: 12px; - flex-wrap: wrap; -} - -.eli-tabela__paginacao { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - margin-top: 0; - flex-wrap: wrap; -} - -.eli-tabela__acoes-inferiores { - display: inline-flex; - gap: 8px; - align-items: center; - flex-wrap: wrap; -} - .eli-tabela__cabecalho { width: 100%; box-sizing: border-box; @@ -245,6 +156,217 @@ gap: 12px; margin-bottom: 12px; flex-wrap: wrap; + + /* Altura padrão para controles do cabeçalho (busca e botões) + * - Mantém alinhamento visual entre input de busca e ações + * - Pode ser sobrescrita via CSS no consumidor, se necessário + */ --eli-tabela-cabecalho-controle-altura: 34px; + + /* Borda dos controles do cabeçalho: menos "pílula", mais quadrado. + * Ajuste fino via CSS no consumidor, se necessário. + */ --eli-tabela-cabecalho-controle-radius: 8px; } + +.eli-tabela__acoes-cabecalho { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; +} + +.eli-tabela__acoes-cabecalho-botao { + display: inline-flex; + align-items: center; + gap: 6px; + height: var(--eli-tabela-cabecalho-controle-altura); + padding: 0 14px; + border-radius: var(--eli-tabela-cabecalho-controle-radius); + border: none; + background: rgba(37, 99, 235, 0.12); + color: rgba(37, 99, 235, 0.95); + font-size: 0.875rem; + font-weight: 500; + line-height: 1; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.eli-tabela__acoes-cabecalho-botao:hover, +.eli-tabela__acoes-cabecalho-botao:focus-visible { + background: rgba(37, 99, 235, 0.2); +} + +.eli-tabela__acoes-cabecalho-botao:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.35); + outline-offset: 2px; +} + +.eli-tabela__acoes-cabecalho-icone { + display: inline-block; +} + +.eli-tabela__acoes-cabecalho-rotulo { + line-height: 1; +} + +.eli-tabela__acoes-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9999px; + border: none; + background: transparent; + color: rgba(15, 23, 42, 0.72); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.eli-tabela__acoes-toggle-icone { + display: block; +} + +.eli-tabela__acoes-toggle:hover, +.eli-tabela__acoes-toggle:focus-visible { + background-color: rgba(15, 23, 42, 0.08); + color: rgba(15, 23, 42, 0.95); +} + +.eli-tabela__acoes-toggle:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.45); + outline-offset: 2px; +} + +.eli-tabela__acoes-toggle:disabled { + cursor: default; + color: rgba(148, 163, 184, 0.8); + background: transparent; +} + +.eli-tabela__acoes-menu { + min-width: 180px; + padding: 6px 0; + margin: 0; + list-style: none; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18); + z-index: 1000; +} + +.eli-tabela__acoes-item { + margin: 0; +} + +.eli-tabela__acoes-item-botao { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.eli-tabela__acoes-item-botao:hover, +.eli-tabela__acoes-item-botao:focus-visible { + background-color: rgba(15, 23, 42, 0.06); +} + +.eli-tabela__acoes-item-botao:focus-visible { + outline: 2px solid currentColor; + outline-offset: -2px; +} + +.eli-tabela__acoes-item-icone { + flex-shrink: 0; +} + +.eli-tabela__acoes-item-texto { + flex: 1; + text-align: left; +} + +/* ========================= + * Expander (colunas invisíveis) + * ========================= */ + +.eli-tabela__th--expander, +.eli-tabela__td--expander { + width: 42px; + padding: 6px 8px; + text-align: center; + vertical-align: middle; +} + +.eli-tabela__expander-botao { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 9999px; + border: none; + background: transparent; + color: rgba(15, 23, 42, 0.72); + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.eli-tabela__expander-botao:hover, +.eli-tabela__expander-botao:focus-visible { + background-color: rgba(15, 23, 42, 0.08); + color: rgba(15, 23, 42, 0.95); +} + +.eli-tabela__expander-botao:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.45); + outline-offset: 2px; +} + +.eli-tabela__expander-botao--ativo { + background-color: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.95); +} + +.eli-tabela__td--detalhes { + padding: 12px; +} + +.eli-tabela__tr--detalhes .eli-tabela__td { + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +.eli-tabela__detalhes { + display: grid; + gap: 10px; + padding-left: 4px; +} + +.eli-tabela__detalhe { + display: grid; + grid-template-columns: 180px 1fr; + gap: 10px; + align-items: start; +} + +.eli-tabela__detalhe-rotulo { + font-weight: 600; + color: rgba(15, 23, 42, 0.85); +} + +.eli-tabela__detalhe-valor { + min-width: 0; +} + +@media (max-width: 720px) { + .eli-tabela__detalhe { + grid-template-columns: 1fr; + } +} diff --git a/src/componentes/EliTabela/EliTabela.vue b/src/componentes/EliTabela/EliTabela.vue index 76d948c..a3c37f4 100644 --- a/src/componentes/EliTabela/EliTabela.vue +++ b/src/componentes/EliTabela/EliTabela.vue @@ -1,39 +1,44 @@ @@ -105,29 +87,46 @@ /** * 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 */ -import { defineComponent, type PropType } from "vue" -import "./EliTabela.css" -import EliTabelaBody from "./EliTabelaBody.vue" -import EliTabelaCabecalho from "./EliTabelaCabecalho.vue" -import EliTabelaEstados from "./EliTabelaEstados.vue" -import EliTabelaHead from "./EliTabelaHead.vue" -import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue" -import EliTabelaModalColunas from "./EliTabelaModalColunas.vue" -import EliTabelaModalFiltroAvancado from "./EliTabelaModalFiltroAvancado.vue" -import EliTabelaPaginacao from "./EliTabelaPaginacao.vue" -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 +/** Dependências do Vue (Composition API) */ +import { + computed, + defineComponent, + onBeforeUnmount, + onMounted, + PropType, + ref, + watch, +} from "vue"; +/** Enum de códigos de resposta utilizado na consulta */ +import { codigosResposta } from "p-respostas"; +/** Componentes auxiliares */ +import EliTabelaCabecalho from "./EliTabelaCabecalho.vue"; +import EliTabelaEstados from "./EliTabelaEstados.vue"; +import EliTabelaDebug from "./EliTabelaDebug.vue"; +import EliTabelaHead from "./EliTabelaHead.vue"; +import EliTabelaBody from "./EliTabelaBody.vue"; +import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue"; +import EliTabelaPaginacao from "./EliTabelaPaginacao.vue"; +import EliTabelaModalColunas from "./EliTabelaModalColunas.vue"; +import EliTabelaModalFiltroAvancado from "./EliTabelaModalFiltroAvancado.vue"; +import type { EliColuna } from "./types-eli-tabela"; +/** Tipos da configuração/contrato da tabela */ +import type { EliTabelaConsulta } from "./types-eli-tabela"; +import type { tipoFiltro } from "./types-eli-tabela"; +// operadores usados no tipo de configuração; o operador aplicado vem travado no filtroAvancado. +import { + carregarConfigColunas, + salvarConfigColunas, + type EliTabelaColunasConfig, +} from "./colunasStorage"; + +import { + carregarFiltroAvancado, + salvarFiltroAvancado, + limparFiltroAvancado, +} from "./filtroAvancadoStorage"; export default defineComponent({ name: "EliTabela", @@ -135,6 +134,7 @@ export default defineComponent({ components: { EliTabelaCabecalho, EliTabelaEstados, + EliTabelaDebug, EliTabelaHead, EliTabelaBody, EliTabelaMenuAcoes, @@ -145,24 +145,686 @@ export default defineComponent({ props: { /** Configuração principal da tabela (colunas, consulta e ações) */ tabela: { - // biome-ignore lint/suspicious/noExplicitAny: dynamic table - type: Object as PropType>, - required: true as const, - }, - /** Array de itens selecionados (v-model:selecionados) */ - selecionados: { - type: Array as PropType, - default: () => [], - }, - /** Ocultar painel de debug lateral (EliTabelaDebug) */ - esconderDebug: { - type: Boolean, - default: false, + type: Object as PropType>, + required: true, }, }, - emits: ["update:selecionados"], - setup(props, { emit }) { - return useEliTabela(props, emit) + setup(props) { + /** Flag para habilitar elementos de debug */ + const isDev = import.meta.env.DEV; + /** Estados de carregamento/erro e dados retornados */ + const carregando = ref(false); + const erro = ref(null); + const linhas = ref([]); + const quantidade = ref(0); + + /** Controle de visibilidade das ações por linha */ + const acoesVisiveis = ref([]); + /** Estado do menu de ações (aberto, elemento e posição) */ + const menuAberto = ref(null); + // O componente EliTabelaMenuAcoes expõe `menuEl` (ref do elemento
    ) + const menuPopup = ref<{ menuEl: { value: HTMLElement | null } } | null>(null); + const menuPopupPos = ref({ top: 0, left: 0 }); + + /** Filtros e ordenação */ + const valorBusca = ref(""); + const paginaAtual = ref(1); + const colunaOrdenacao = ref(null); + const direcaoOrdenacao = ref<"asc" | "desc">("asc"); + + /** Filtro avançado (config + estado modal) */ + const modalFiltroAberto = ref(false); + type LinhaFiltroUI = { + coluna: keyof T; + valor: any; + }; + + const filtrosUi = ref>>(carregarFiltroAvancado(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; + if (paginaAtual.value !== 1) paginaAtual.value = 1; + } + + function salvarFiltrosAvancados(novo: any[]) { + filtrosUi.value = (novo ?? []) as any; + salvarFiltroAvancado(props.tabela.nome, (novo ?? []) as any); + modalFiltroAberto.value = false; + if (paginaAtual.value !== 1) paginaAtual.value = 1; + } + + const filtrosAvancadosAtivos = computed(() => { + // Operador vem travado na definição (`tabela.filtroAvancado`). + const base = (props.tabela.filtroAvancado ?? []) as Array<{ coluna: string; operador: any }>; + + return (filtrosUi.value ?? []) + .filter((f) => f && f.coluna !== undefined) + .map((f) => { + const b = base.find((x) => String(x.coluna) === String(f.coluna)); + if (!b) return null; + + return { + coluna: String(b.coluna), + operador: b.operador as any, + valor: (f as any).valor, + } as tipoFiltro; + }) + .filter(Boolean) as tipoFiltro[]; + }); + + /** Alias reativo da prop tabela */ + const tabela = computed(() => props.tabela); + + /** Exibição da busca e ações do cabeçalho */ + const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca)); + const acoesCabecalho = computed(() => props.tabela.acoesTabela ?? []); + const temAcoesCabecalho = computed(() => acoesCabecalho.value.length > 0); + + /** Colunas: visibilidade/ordem com persistência */ + const modalColunasAberto = ref(false); + const configColunas = ref( + carregarConfigColunas(props.tabela.nome) + ); + + /** Linhas expandidas (para exibir colunas invisíveis) */ + const linhasExpandidas = ref>({}); + + const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo)); + + const colunasInvisiveisEfetivas = computed(() => { + const colunas = props.tabela.colunas as Array>; + + const configTemDados = + (configColunas.value.visiveis?.length ?? 0) > 0 || + (configColunas.value.invisiveis?.length ?? 0) > 0; + + const invisiveisBaseRotulos = configTemDados + ? configColunas.value.invisiveis ?? [] + : colunas.filter((c) => c.visivel === false).map((c) => c.rotulo); + + const invisiveisSet = new Set(invisiveisBaseRotulos); + const base = colunas.filter((c) => invisiveisSet.has(c.rotulo)); + + // ordenação: usa a lista (salva ou derivada do default) e adiciona novas ao final + const ordemSalva = invisiveisBaseRotulos; + const mapa = new Map>(); + for (const c of base) { + if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c); + } + + const ordenadas: Array> = []; + for (const r of ordemSalva) { + const c = mapa.get(r); + if (c) ordenadas.push(c); + } + for (const c of base) { + if (!ordenadas.includes(c)) ordenadas.push(c); + } + + return ordenadas; + }); + + const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0); + + const colunasEfetivas = computed(() => { + const colunas = props.tabela.colunas; + const todosRotulos = rotulosColunas.value; + + const configTemDados = + (configColunas.value.visiveis?.length ?? 0) > 0 || + (configColunas.value.invisiveis?.length ?? 0) > 0; + + const invisiveisBaseRotulos = configTemDados + ? configColunas.value.invisiveis ?? [] + : (props.tabela.colunas as Array>) + .filter((c) => c.visivel === false) + .map((c) => c.rotulo); + + const invisiveisSet = new Set(invisiveisBaseRotulos); + + // base visiveis: remove invisiveis + const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r)); + const visiveisSet = new Set(visiveisBaseRotulos); + + // aplica ordem salva; novas (sem definicao) entram no fim, respeitando ordem original + const ordemSalva = configTemDados ? configColunas.value.visiveis ?? [] : []; + const ordemFinal: string[] = []; + + for (const r of ordemSalva) { + if (visiveisSet.has(r)) ordemFinal.push(r); + } + for (const r of visiveisBaseRotulos) { + if (!ordemFinal.includes(r)) ordemFinal.push(r); + } + + // mapeia rótulo -> coluna, preservando duplicatas (se existirem) pelo primeiro match. + // OBS: pressupoe rotulo unico; se repetir, comportamento fica indefinido. + const mapa = new Map(); + for (const c of colunas) { + if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c); + } + + return ordemFinal.map((r) => mapa.get(r)).filter(Boolean); + }); + + function abrirModalColunas() { + modalColunasAberto.value = true; + } + + function fecharModalColunas() { + modalColunasAberto.value = false; + } + + function salvarModalColunas(cfg: EliTabelaColunasConfig) { + configColunas.value = cfg; + salvarConfigColunas(props.tabela.nome, cfg); + modalColunasAberto.value = false; + + // ao mudar colunas, fecha detalhes expandidos + linhasExpandidas.value = {}; + } + + function alternarLinhaExpandida(indice: number) { + const atual = Boolean(linhasExpandidas.value[indice]); + linhasExpandidas.value = { + ...linhasExpandidas.value, + [indice]: !atual, + }; + } + + /** Registros por consulta (normaliza para inteiro positivo) */ + const registrosPorConsulta = computed(() => { + const valor = props.tabela.registros_por_consulta; + if (typeof valor === "number" && valor > 0) { + return Math.floor(valor); + } + return 10; + }); + + function aplicarFiltroTexto(linhasIn: unknown[]) { + const q = (valorBusca.value ?? "").trim().toLowerCase(); + if (!q) return linhasIn; + // filtro simples: stringifica o objeto + return linhasIn.filter((l) => JSON.stringify(l).toLowerCase().includes(q)); + } + + function compararOperador(operador: string, valorLinha: any, valorFiltro: any): boolean { + switch (operador) { + case "=": + return valorLinha == valorFiltro; + case "!=": + return valorLinha != valorFiltro; + case ">": + return Number(valorLinha) > Number(valorFiltro); + case ">=": + return Number(valorLinha) >= Number(valorFiltro); + case "<": + return Number(valorLinha) < Number(valorFiltro); + case "<=": + return Number(valorLinha) <= Number(valorFiltro); + case "like": { + const a = String(valorLinha ?? "").toLowerCase(); + const b = String(valorFiltro ?? "").toLowerCase(); + return a.includes(b); + } + case "in": { + // aceita "a,b,c" ou array + const arr = Array.isArray(valorFiltro) + ? valorFiltro + : String(valorFiltro ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return arr.includes(String(valorLinha)); + } + case "isNull": + return valorLinha === null || valorLinha === undefined || valorLinha === ""; + default: + return true; + } + } + + function aplicarFiltroAvancado(linhasIn: unknown[]) { + const filtros = filtrosAvancadosAtivos.value; + if (!filtros.length) return linhasIn; + + return linhasIn.filter((l: any) => { + return filtros.every((f) => { + const vLinha = l?.[f.coluna as any]; + return compararOperador(String(f.operador), vLinha, (f as any).valor); + }); + }); + } + + const linhasFiltradas = computed(() => { + const base = linhas.value ?? []; + return aplicarFiltroAvancado(aplicarFiltroTexto(base)); + }); + + /** Quantidade agora segue a filtragem local */ + const quantidadeFiltrada = computed(() => linhasFiltradas.value.length); + + /** Total de páginas calculado com base no filtrado */ + const totalPaginas = computed(() => { + const limite = registrosPorConsulta.value; + if (!limite || limite <= 0) return 1; + + const total = quantidadeFiltrada.value; + if (!total) return 1; + + return Math.max(1, Math.ceil(total / limite)); + }); + + const linhasPaginadas = computed(() => { + const limite = Math.max(1, registrosPorConsulta.value); + const offset = (paginaAtual.value - 1) * limite; + return linhasFiltradas.value.slice(offset, offset + limite); + }); + + /** 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 && menuPopup.value.menuEl.value.contains(alvo)) return; + + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value }); + } + + menuAberto.value = null; + } + + /** Alterna ordenação e recarrega os dados */ + function alternarOrdenacao(chave?: string) { + if (!chave) return; + + if (colunaOrdenacao.value === chave) { + direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc"; + void carregar(); + return; + } + + colunaOrdenacao.value = chave; + direcaoOrdenacao.value = "asc"; + if (paginaAtual.value !== 1) { + paginaAtual.value = 1; + } else { + void carregar(); + } + } + + /** Atualiza a busca e reinicia paginação, se necessário */ + function atualizarBusca(texto: string) { + if (valorBusca.value === texto) return; + + valorBusca.value = texto; + if (paginaAtual.value !== 1) { + paginaAtual.value = 1; + } else { + void carregar(); + } + } + + /** Navega para a página solicitada com limites */ + function irParaPagina(pagina: number) { + const alvo = Math.min(Math.max(1, pagina), totalPaginas.value); + if (alvo !== paginaAtual.value) { + paginaAtual.value = alvo; + } + } + + /** + * Lista ações visíveis por linha, respeitando regras sync/async de `exibir`. + */ + function acoesDisponiveisPorLinha(i: number) { + const acoesLinha = props.tabela.acoesLinha ?? []; + const visibilidade = acoesVisiveis.value[i] ?? []; + + return acoesLinha + .map((acao, indice) => { + const fallbackVisivel = + acao.exibir === undefined + ? true + : typeof acao.exibir === "boolean" + ? acao.exibir + : false; + + return { + acao, + indice, + visivel: visibilidade[indice] ?? fallbackVisivel, + }; + }) + .filter((item) => item.visivel); + } + + /** Informa se a linha possui ações disponíveis */ + function possuiAcoes(i: number) { + return acoesDisponiveisPorLinha(i).length > 0; + } + + /** Abre/fecha o menu de ações da linha e posiciona o popup */ + function toggleMenu(i: number, evento?: MouseEvent) { + if (!possuiAcoes(i)) return; + + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value }); + } + + if (menuAberto.value === i) { + menuAberto.value = null; + + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] toggleMenu => fechou", { i }); + } + return; + } + + menuAberto.value = i; + + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] toggleMenu => abriu", { i }); + } + + // posiciona assim que abrir + const anchor = (evento?.currentTarget as HTMLElement | null) ?? null; + if (anchor) { + // primeiro posicionamento (antes do menu medir) + atualizarPosicaoMenu(anchor); + // reposiciona no próximo frame para pegar altura real + requestAnimationFrame(() => atualizarPosicaoMenu(anchor)); + } + } + + /** + * Executa consulta, aplica filtros e resolve visibilidade de ações. + * Usa o contador sequencial para ignorar respostas antigas. + */ + async function carregar() { + const idCarregamento = ++carregamentoSequencial; + carregando.value = true; + erro.value = null; + acoesVisiveis.value = []; + menuAberto.value = null; + linhasExpandidas.value = {}; + + // Em modo simulação (filtro local), sempre buscamos a lista completa. + // A paginação é aplicada APÓS a filtragem. + const limite = Math.max(1, registrosPorConsulta.value); + const offset = 0; + + const parametrosConsulta: { + coluna_ordem?: never; + direcao_ordem?: "asc" | "desc"; + offSet: number; + limit: number; + texto_busca?: string; + } = { + offSet: offset, + limit: 999999, + }; + + // texto_busca ficará somente para filtragem local. + + if (colunaOrdenacao.value) { + parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never; + parametrosConsulta.direcao_ordem = direcaoOrdenacao.value; + } + + try { + const tabelaConfig = props.tabela; + const res = await tabelaConfig.consulta(parametrosConsulta); + + if (idCarregamento !== carregamentoSequencial) return; + + if (res.cod !== codigosResposta.sucesso) { + linhas.value = []; + quantidade.value = 0; + erro.value = res.mensagem; + return; + } + + const valores = res.valor?.valores ?? []; + const total = valores.length; + + linhas.value = valores; + quantidade.value = total; + + const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidadeFiltrada.value || 0) / limite)); + if (paginaAtual.value > totalPaginasRecalculado) { + paginaAtual.value = totalPaginasRecalculado; + return; + } + + const acoesLinhaConfiguradas = tabelaConfig.acoesLinha ?? []; + if (!acoesLinhaConfiguradas.length) { + acoesVisiveis.value = []; + return; + } + + const preResultado = valores.map(() => + acoesLinhaConfiguradas.map((acao) => { + if (acao.exibir === undefined) return true; + if (typeof acao.exibir === "boolean") return acao.exibir; + return false; + }) + ); + + acoesVisiveis.value = preResultado; + + const visibilidade = await Promise.all( + valores.map(async (linha) => + Promise.all( + acoesLinhaConfiguradas.map(async (acao) => { + if (acao.exibir === undefined) return true; + if (typeof acao.exibir === "boolean") return acao.exibir; + + try { + const resultado = acao.exibir(linha as never); + return Boolean(await Promise.resolve(resultado)); + } catch { + return false; + } + }) + ) + ) + ); + + if (idCarregamento === carregamentoSequencial) { + acoesVisiveis.value = visibilidade; + } + } catch (e) { + if (idCarregamento !== carregamentoSequencial) return; + + linhas.value = []; + quantidade.value = 0; + erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."; + } finally { + if (idCarregamento === carregamentoSequencial) { + carregando.value = false; + } + } + } + + /** Ciclo de vida: registra listener global e carrega dados iniciais */ + onMounted(() => { + document.addEventListener("click", handleClickFora); + void carregar(); + }); + + /** Ciclo de vida: remove listener global */ + onBeforeUnmount(() => { + document.removeEventListener("click", handleClickFora); + }); + + /** Watch: ao desabilitar busca, limpa termo e recarrega */ + watch( + () => props.tabela.mostrarCaixaDeBusca, + (mostrar) => { + if (!mostrar && valorBusca.value) { + valorBusca.value = ""; + if (paginaAtual.value !== 1) { + paginaAtual.value = 1; + } else { + void carregar(); + } + } + } + ); + + /** Watch: mudança de página dispara nova consulta */ + watch(paginaAtual, (nova, antiga) => { + // paginação local não precisa recarregar + if (nova !== antiga) { + // noop + } + }); + + /** 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); + filtrosUi.value = carregarFiltroAvancado(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, + quantidadeFiltrada, + quantidade, + menuAberto, + valorBusca, + paginaAtual, + colunaOrdenacao, + direcaoOrdenacao, + totalPaginas, + + // computed + exibirBusca, + exibirFiltroAvancado, + acoesCabecalho, + temAcoesCabecalho, + temAcoes, + colunasEfetivas, + rotulosColunas, + modalColunasAberto, + configColunas, + + temColunasInvisiveis, + colunasInvisiveisEfetivas, + linhasExpandidas, + + abrirModalColunas, + abrirModalFiltro, + fecharModalColunas, + salvarModalColunas, + + modalFiltroAberto, + filtrosUi, + salvarFiltrosAvancados, + limparFiltrosAvancados, + fecharModalFiltro, + + alternarLinhaExpandida, + + // actions + alternarOrdenacao, + atualizarBusca, + irParaPagina, + acoesDisponiveisPorLinha, + possuiAcoes, + toggleMenu, + + // popup + menuPopup, + menuPopupPos, + }; }, -}) +}); + + diff --git a/src/componentes/EliTabela/EliTabelaAcoes.css b/src/componentes/EliTabela/EliTabelaAcoes.css deleted file mode 100644 index d89a07c..0000000 --- a/src/componentes/EliTabela/EliTabelaAcoes.css +++ /dev/null @@ -1,207 +0,0 @@ -/* ========================= - * EliTabela — Ações de linha, Menu e Seleção (Checkboxes) - * Extraído de EliTabela.css para manter cada arquivo CSS abaixo de 250 linhas. - * ========================= */ - -.eli-tabela__acoes-cabecalho { - display: inline-flex; - gap: 8px; - flex-wrap: wrap; -} - -.eli-tabela__acoes-cabecalho-botao { - display: inline-flex; - align-items: center; - gap: 6px; - height: var(--eli-tabela-cabecalho-controle-altura); - padding: 0 14px; - border-radius: var(--eli-tabela-cabecalho-controle-radius); - border: none; - background: var(--cor-eli-primaria-bg); - color: var(--cor-eli-primaria-escura); - font-size: 0.875rem; - font-weight: 500; - line-height: 1; - cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease; -} - -.eli-tabela__acoes-cabecalho-botao:hover, -.eli-tabela__acoes-cabecalho-botao:focus-visible { - background: var(--cor-eli-primaria-clara); -} - -.eli-tabela__acoes-cabecalho-botao:focus-visible { - outline: 2px solid var(--cor-eli-primaria-clara); - outline-offset: 2px; -} - -.eli-tabela__acoes-cabecalho-icone { - display: inline-block; -} - -.eli-tabela__acoes-cabecalho-rotulo { - line-height: 1; -} - -.eli-tabela__acoes-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 9999px; - border: none; - background: transparent; - color: rgba(15, 23, 42, 0.72); - cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease; -} - -.eli-tabela__acoes-toggle-icone { - display: block; -} - -.eli-tabela__acoes-toggle:hover, -.eli-tabela__acoes-toggle:focus-visible { - background-color: rgba(15, 23, 42, 0.08); - color: rgba(15, 23, 42, 0.95); -} - -.eli-tabela__acoes-toggle:focus-visible { - outline: 2px solid var(--cor-eli-primaria-clara); - outline-offset: 2px; -} - -.eli-tabela__acoes-toggle:disabled { - cursor: default; - color: rgba(148, 163, 184, 0.8); - background: transparent; -} - -.eli-tabela__acoes-menu { - min-width: 180px; - padding: 6px 0; - margin: 0; - list-style: none; - background: 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); - z-index: 1000; -} - -.eli-tabela__acoes-item { - margin: 0; -} - -.eli-tabela__acoes-item-botao { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 8px 12px; - border: none; - background: transparent; - font-size: 0.9rem; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.eli-tabela__acoes-item-botao:hover, -.eli-tabela__acoes-item-botao:focus-visible { - background-color: rgba(15, 23, 42, 0.06); -} - -.eli-tabela__acoes-item-botao:focus-visible { - outline: 2px solid currentColor; - outline-offset: -2px; -} - -.eli-tabela__acoes-item-icone { - flex-shrink: 0; -} - -.eli-tabela__acoes-item-texto { - flex: 1; - text-align: left; -} - -/* ========================= - * Expander (colunas invisíveis) - * ========================= */ - -.eli-tabela__th--expander, -.eli-tabela__td--expander { - width: 42px; - padding: 6px 8px; - text-align: center; - vertical-align: middle; -} - -.eli-tabela__expander-botao { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 9999px; - border: none; - background: transparent; - color: rgba(15, 23, 42, 0.72); - cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease; -} - -.eli-tabela__expander-botao:hover, -.eli-tabela__expander-botao:focus-visible { - background-color: rgba(15, 23, 42, 0.08); - color: rgba(15, 23, 42, 0.95); -} - -.eli-tabela__expander-botao:focus-visible { - outline: 2px solid var(--cor-eli-primaria-clara); - outline-offset: 2px; -} - -.eli-tabela__expander-botao--ativo { - background-color: rgba(15, 23, 42, 0.06); - color: rgba(15, 23, 42, 0.95); -} - -.eli-tabela__td--detalhes { - padding: 12px; -} - -.eli-tabela__tr--detalhes .eli-tabela__td { - border-bottom: 1px solid rgba(0, 0, 0, 0.08); -} - -.eli-tabela__detalhes { - display: grid; - gap: 10px; - padding-left: 4px; -} - -.eli-tabela__detalhe { - display: grid; - grid-template-columns: 180px 1fr; - gap: 10px; - align-items: start; -} - -.eli-tabela__detalhe-rotulo { - font-weight: 600; - color: rgba(15, 23, 42, 0.85); -} - -.eli-tabela__detalhe-valor { - min-width: 0; -} - -@media (max-width: 720px) { - .eli-tabela__detalhe { - grid-template-columns: 1fr; - } -} - diff --git a/src/componentes/EliTabela/EliTabelaBody.vue b/src/componentes/EliTabela/EliTabelaBody.vue index 2e4038f..133bd27 100644 --- a/src/componentes/EliTabela/EliTabelaBody.vue +++ b/src/componentes/EliTabela/EliTabelaBody.vue @@ -25,17 +25,6 @@ - - - - @@ -84,11 +73,11 @@ diff --git a/src/componentes/EliTabela/EliTabelaCabecalho.vue b/src/componentes/EliTabela/EliTabelaCabecalho.vue index 81fb956..7a8b66e 100644 --- a/src/componentes/EliTabela/EliTabelaCabecalho.vue +++ b/src/componentes/EliTabela/EliTabelaCabecalho.vue @@ -8,7 +8,6 @@ class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas" @click="emitColunas" > - Colunas @@ -16,16 +15,8 @@ v-if="exibirBotaoFiltroAvancado" type="button" class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--filtro" - :title="tooltipFiltro" @click="emitFiltroAvancado" > - - - {{ filtrosAtivosResumo.quantidade }} - Filtro @@ -39,7 +30,7 @@ type="button" class="eli-tabela__acoes-cabecalho-botao" :style="botao.cor ? { backgroundColor: botao.cor, color: '#fff' } : undefined" - @click="botao.acao(parametrosConsulta)" + @click="botao.acao" > - diff --git a/src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue b/src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue index b4f3050..115fa31 100644 --- a/src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue +++ b/src/componentes/EliTabela/EliTabelaCaixaDeBusca.vue @@ -23,8 +23,8 @@ - diff --git a/src/componentes/EliTabela/EliTabelaDebug.vue b/src/componentes/EliTabela/EliTabelaDebug.vue index bea5ec1..bc16286 100644 --- a/src/componentes/EliTabela/EliTabelaDebug.vue +++ b/src/componentes/EliTabela/EliTabelaDebug.vue @@ -2,21 +2,16 @@
    -
    - EliTabela // Log Terminal -
    -
    menuAberto: {{ menuAberto }}
    -
    menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}
    -
    - -
    +
    EliTabela debug
    +
    menuAberto: {{ menuAberto }}
    +
    menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}
    diff --git a/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue b/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue index c2e87c3..a726fd6 100644 --- a/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue +++ b/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue @@ -10,27 +10,26 @@ - \ No newline at end of file diff --git a/src/componentes/EliTabela/EliTabelaEstados.vue b/src/componentes/EliTabela/EliTabelaEstados.vue index 31b4c77..caae911 100644 --- a/src/componentes/EliTabela/EliTabelaEstados.vue +++ b/src/componentes/EliTabela/EliTabelaEstados.vue @@ -1,41 +1,34 @@ diff --git a/src/componentes/EliTabela/EliTabelaHead.vue b/src/componentes/EliTabela/EliTabelaHead.vue index 800fe42..ed314a6 100644 --- a/src/componentes/EliTabela/EliTabelaHead.vue +++ b/src/componentes/EliTabela/EliTabelaHead.vue @@ -3,17 +3,6 @@ - - - - diff --git a/src/componentes/EliTabela/EliTabelaMenuAcoes.vue b/src/componentes/EliTabela/EliTabelaMenuAcoes.vue index 908e9eb..af9594b 100644 --- a/src/componentes/EliTabela/EliTabelaMenuAcoes.vue +++ b/src/componentes/EliTabela/EliTabelaMenuAcoes.vue @@ -1,99 +1,95 @@ diff --git a/src/componentes/EliTabela/EliTabelaModalColunas.css b/src/componentes/EliTabela/EliTabelaModalColunas.css deleted file mode 100644 index f3717d8..0000000 --- a/src/componentes/EliTabela/EliTabelaModalColunas.css +++ /dev/null @@ -1,166 +0,0 @@ -@import "./EliTabelaModalColunasItens.css"; - -.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); -} diff --git a/src/componentes/EliTabela/EliTabelaModalColunas.vue b/src/componentes/EliTabela/EliTabelaModalColunas.vue index 9a5091b..7538940 100644 --- a/src/componentes/EliTabela/EliTabelaModalColunas.vue +++ b/src/componentes/EliTabela/EliTabelaModalColunas.vue @@ -1,190 +1,408 @@ + diff --git a/src/componentes/EliTabela/EliTabelaModalColunasItens.css b/src/componentes/EliTabela/EliTabelaModalColunasItens.css deleted file mode 100644 index 1578638..0000000 --- a/src/componentes/EliTabela/EliTabelaModalColunasItens.css +++ /dev/null @@ -1,182 +0,0 @@ -/* ========================= - * EliTabelaModalColunas — Itens, estados e footer - * Extraído de EliTabelaModalColunas.css para manter cada arquivo abaixo de 250 linhas. - * ========================= */ - -/* ── 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); - } -} diff --git a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css deleted file mode 100644 index f821609..0000000 --- a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css +++ /dev/null @@ -1,221 +0,0 @@ -@import "./EliTabelaModalFiltroFormula.css"; - -.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); -} diff --git a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue index 4d91354..5fe7435 100644 --- a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue +++ b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue @@ -1,182 +1,391 @@ - + diff --git a/src/componentes/EliTabela/EliTabelaModalFiltroFormula.css b/src/componentes/EliTabela/EliTabelaModalFiltroFormula.css deleted file mode 100644 index 943ec29..0000000 --- a/src/componentes/EliTabela/EliTabelaModalFiltroFormula.css +++ /dev/null @@ -1,225 +0,0 @@ -/* ========================= - * EliTabelaModalFiltroAvancado — Fórmula, tooltip, botões e transições - * Extraído de EliTabelaModalFiltroAvancado.css para manter cada arquivo abaixo de 250 linhas. - * ========================= */ - -/* ── 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; -} diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.css b/src/componentes/EliTabela/EliTabelaPaginacao.css deleted file mode 100644 index c72a470..0000000 --- a/src/componentes/EliTabela/EliTabelaPaginacao.css +++ /dev/null @@ -1,87 +0,0 @@ -.eli-tabela__paginacao { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 12px; - margin-top: 12px; - flex-wrap: wrap; -} - -.eli-tabela__pagina-botao { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 6px 14px; - border-radius: 9999px; - border: 1px solid rgba(15, 23, 42, 0.12); - background: var(--cor-eli-superficie); - font-size: 0.875rem; - font-weight: 500; - color: rgba(15, 23, 42, 0.82); - cursor: pointer; - transition: background-color 0.2s ease, border-color 0.2s ease, - color 0.2s ease; -} - -.eli-tabela__pagina-botao:hover, -.eli-tabela__pagina-botao:focus-visible { - background-color: var(--cor-eli-primaria-bg); - border-color: var(--cor-eli-primaria-clara); - color: var(--cor-eli-primaria-escura); -} - -.eli-tabela__pagina-botao:focus-visible { - outline: 2px solid var(--cor-eli-primaria-clara); - outline-offset: 2px; -} - -.eli-tabela__pagina-botao:disabled { - cursor: default; - opacity: 0.5; - background: rgba(148, 163, 184, 0.08); - border-color: rgba(148, 163, 184, 0.18); - color: rgba(71, 85, 105, 0.75); -} - -.eli-tabela__pagina-botao--ativo { - background: var(--cor-eli-primaria-bg); - border-color: var(--cor-eli-primaria); - color: var(--cor-eli-primaria-escura); -} - -.eli-tabela__pagina-ellipsis { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - color: var(--cor-eli-neutro); - font-size: 0.9rem; -} - -.eli-tabela__acao-inferior { - display: inline-flex; - align-items: center; - gap: 6px; - height: 34px; - padding: 0 14px; - border-radius: 8px; - border: 1px solid var(--cor-eli-primaria); - background: var(--cor-eli-superficie); - color: var(--cor-eli-primaria); - font-size: 0.875rem; - font-weight: 500; - line-height: 1; - cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease; -} - -.eli-tabela__acao-inferior:hover, -.eli-tabela__acao-inferior:focus-visible { - background-color: var(--cor-eli-primaria-bg); -} - -.eli-tabela__acao-inferior:focus-visible { - outline: 2px solid var(--cor-eli-primaria); - outline-offset: 2px; -} diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.vue b/src/componentes/EliTabela/EliTabelaPaginacao.vue index d021fb0..50a5a87 100644 --- a/src/componentes/EliTabela/EliTabelaPaginacao.vue +++ b/src/componentes/EliTabela/EliTabelaPaginacao.vue @@ -1,83 +1,57 @@ + diff --git a/src/componentes/EliTabela/EliTabelaSelecao.css b/src/componentes/EliTabela/EliTabelaSelecao.css deleted file mode 100644 index d219e5e..0000000 --- a/src/componentes/EliTabela/EliTabelaSelecao.css +++ /dev/null @@ -1,66 +0,0 @@ -/* ========================================= - * Seleção (Checkboxes) - * Extraído de EliTabela.css para manter cada arquivo abaixo de 250 linhas. - * ========================================= */ -.eli-tabela__th--selecao, -.eli-tabela__td--selecao { - width: 48px; - min-width: 48px; - max-width: 48px; - text-align: center; - padding: 0 8px; - vertical-align: middle; -} - -.eli-tabela__checkbox { - appearance: none; - -webkit-appearance: none; - width: 15px; - height: 15px; - cursor: pointer; - border: 2px solid rgba(0, 0, 0, 0.54); - border-radius: 2px; - margin: 0; - vertical-align: middle; - position: relative; - transition: all 0.2s ease-in-out; - background-color: transparent; - outline: none; -} - -.eli-tabela__checkbox:hover { - border-color: rgba(0, 0, 0, 0.87); -} - -.eli-tabela__checkbox:checked, -.eli-tabela__checkbox:indeterminate { - background-color: var(--cor-eli-primaria); - border-color: var(--cor-eli-primaria); -} - -.eli-tabela__checkbox:checked::after { - content: ''; - position: absolute; - top: 0px; - left: 4px; - width: 4px; - height: 9px; - border: solid white; - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.eli-tabela__checkbox:indeterminate::after { - content: ''; - position: absolute; - top: 5px; - left: 2px; - width: 8px; - height: 2px; - background-color: white; - border-radius: 1px; -} - -.eli-tabela__checkbox:focus-visible { - box-shadow: 0 0 0 2px var(--cor-eli-primaria-clara); -} diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue index 35a4ac3..16521c7 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue @@ -8,36 +8,32 @@ diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaComponente.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaComponente.vue deleted file mode 100644 index cc83b83..0000000 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaComponente.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue index 709496d..d3bf558 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue @@ -12,57 +12,56 @@ - -``` - -#### Exemplo: usando na definição de colunas - -```ts -import CelulaOlaNome from "./CelulaOlaNome.vue" - -celula: (l) => celulaTabela("componente", { - componente: CelulaOlaNome, - props: () => ({ nome: l.empreendedor }), -}) -``` - -#### Quando usar - -| Cenário | Use `componente`? | -|---|---| -| Texto simples ou truncado | ❌ Use `textoSimples` / `textoTruncado` | -| Número com prefixo/sufixo | ❌ Use `numero` | -| Chips/badges com ação | ❌ Use `tags` | -| Data com formatação | ❌ Use `data` | -| Barra de progresso, gráfico inline, badge complexo | ✅ Sim | -| Componente com lógica interna (hover, tooltip, estado) | ✅ Sim | - -#### Observações - -- As **props não são tipadas** no contrato — é `Record`. A responsabilidade de passar props corretas é do consumidor. -- O componente é renderizado via `` + `v-bind`, então suporta qualquer componente Vue válido (SFC, `defineComponent`, funcional). -- Prefira sempre os tipos pré-definidos quando possível. O tipo `componente` é uma **escotilha de escape**, não o padrão. - ---- - -## Como criar um novo tipo de célula pré-definido - -Se o padrão de renderização se repete em múltiplas tabelas, considere promover de `componente` para um tipo próprio: - -1. Criar `EliTabelaCelula.vue` neste diretório -2. Adicionar a chave e tipagem em `tiposTabelaCelulas.ts` -3. Registrar no mapa em `registryTabelaCelulas.ts` - --- ## Onde ficam os tipos e o registry diff --git a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts index 422456f..283caaf 100644 --- a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts +++ b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts @@ -1,27 +1,16 @@ -import type { Component } from "vue" -import EliTabelaCelulaComponente from "./EliTabelaCelulaComponente.vue" -import EliTabelaCelulaData from "./EliTabelaCelulaData.vue" -import EliTabelaCelulaNumero from "./EliTabelaCelulaNumero.vue" -import EliTabelaCelulaTags from "./EliTabelaCelulaTags.vue" -import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue" -import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue" -import type { tipoTabelaCelula } from "./tiposTabelaCelulas" +import type { Component } from "vue"; + +import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue"; +import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue"; +import EliTabelaCelulaNumero from "./EliTabelaCelulaNumero.vue"; +import EliTabelaCelulaTags from "./EliTabelaCelulaTags.vue"; +import EliTabelaCelulaData from "./EliTabelaCelulaData.vue"; +import type { TipoTabelaCelula } from "./tiposTabelaCelulas"; -/** - * Mapa (dispatch table) que associa cada `tipoTabelaCelula` ao seu componente Vue de renderização. - * Usado por `EliTabelaCelula.vue` para resolver dinamicamente qual componente renderizar - * a partir do tipo retornado por `tipoEliColuna.celula(linha)`. - * - * Adicionar um novo tipo de célula requer: - * 1. Criar o componente `EliTabelaCelula.vue` - * 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts) - * 3. Registrar aqui no mapa - */ export const registryTabelaCelulas = { textoSimples: EliTabelaCelulaTextoSimples, textoTruncado: EliTabelaCelulaTextoTruncado, numero: EliTabelaCelulaNumero, tags: EliTabelaCelulaTags, data: EliTabelaCelulaData, - componente: EliTabelaCelulaComponente, -} as const satisfies Record +} as const satisfies Record; diff --git a/src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts b/src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts index 42d53b6..7f8eed5 100644 --- a/src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts +++ b/src/componentes/EliTabela/celulas/tiposTabelaCelulas.ts @@ -1,62 +1,49 @@ /** - * Tipagem dos dados de entrada dos componentes de celulas + * Tipagem dos dados de entrada dos componentes de celulas */ -import type { LucideIcon } from "@lucide/vue" -import type { Component } from "vue" +import type { LucideIcon } from "lucide-vue-next"; -export type tiposTabelaCelulas = { +export type TiposTabelaCelulas = { textoSimples: { - texto: string - acao?: () => void - } + texto: string; + acao?: () => void; + }; textoTruncado: { - texto: string - acao?: () => void - } + texto: string; + acao?: () => void; + }; numero: { - numero: number + numero: number; /** Texto opcional exibido antes do número (ex.: "R$", "≈"). */ - prefixo?: string + prefixo?: string; /** Texto opcional exibido depois do número (ex.: "kg", "%"). */ - sufixo?: string - acao?: () => void - } + sufixo?: string; + acao?: () => void; + }; tags: { opcoes: { /** Texto exibido dentro da tag. */ - rotulo: string + rotulo: string; /** Cor do chip (segue as cores do Vuetify, ex.: "primary", "success", "error"). */ - cor?: string + cor?: string; /** Ícone (Lucide) opcional exibido antes do rótulo. */ - icone?: LucideIcon + icone?: LucideIcon; /** Ação opcional da tag. Quando existir, o chip vira clicável. */ - acao?: () => void - }[] - } + acao?: () => void; + }[]; + }; data: { /** Valor em ISO 8601 (ex.: "2026-01-09T16:15:00Z"). */ - valor: string + valor: string; /** Define o formato de exibição. */ - formato: "data" | "data_hora" | "relativo" + formato: "data" | "data_hora" | "relativo"; /** Ação opcional ao clicar no valor. */ - acao?: () => void - } + acao?: () => void; + }; +}; - /** Célula que renderiza um componente Vue arbitrário com props dinâmicas. */ - componente: { - /** Componente Vue a ser renderizado na célula. */ - componente: Component - /** - * Props passadas ao componente via `v-bind`. - * Aceita: - * - objeto direto: `{ status: "ativo" }` - * - função assíncrona: `() => api.get(...)` — útil quando é necessário resolver dados antes de renderizar - */ - props?: () => Promise> | Record - } -} +export type TipoTabelaCelula = keyof TiposTabelaCelulas; -export type tipoTabelaCelula = keyof tiposTabelaCelulas diff --git a/src/componentes/EliTabela/colunasStorage.ts b/src/componentes/EliTabela/colunasStorage.ts index 5cbe999..80d306c 100644 --- a/src/componentes/EliTabela/colunasStorage.ts +++ b/src/componentes/EliTabela/colunasStorage.ts @@ -1,82 +1,49 @@ export type EliTabelaColunasConfig = { - /** Rótulos das colunas visíveis (em ordem). */ - visiveis: string[] - /** Rótulos das colunas invisíveis. */ - invisiveis: string[] -} + /** Rotulos das colunas visiveis (em ordem). */ + visiveis: string[]; + /** Rotulos das colunas invisiveis. */ + invisiveis: string[]; +}; -const STORAGE_PREFIX = "eli:tabela" +const STORAGE_PREFIX = "eli:tabela"; -/** - * Retorna a chave de `localStorage` utilizada para persistir a configuração de colunas de uma tabela. - * - * @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`) - * @returns Chave no formato `eli:tabela::colunas` - */ export function storageKeyColunas(nomeTabela: string) { - return `${STORAGE_PREFIX}:${nomeTabela}:colunas` + return `${STORAGE_PREFIX}:${nomeTabela}:colunas`; } function normalizarConfig(valor: unknown): EliTabelaColunasConfig { if (!valor || typeof valor !== "object") { - return { visiveis: [], invisiveis: [] } + return { visiveis: [], invisiveis: [] }; } - const v = valor as EliTabelaColunasConfig - const visiveis = Array.isArray(v.visiveis) - ? v.visiveis.filter((x: unknown) => typeof x === "string") - : [] - const invisiveis = Array.isArray(v.invisiveis) - ? v.invisiveis.filter((x: unknown) => typeof x === "string") - : [] - return { visiveis, invisiveis } + const v = valor as any; + const visiveis = Array.isArray(v.visiveis) ? v.visiveis.filter((x: any) => typeof x === "string") : []; + const invisiveis = Array.isArray(v.invisiveis) ? v.invisiveis.filter((x: any) => typeof x === "string") : []; + return { visiveis, invisiveis }; } -/** - * 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: [] } - return normalizarConfig(JSON.parse(raw)) + const raw = window.localStorage.getItem(storageKeyColunas(nomeTabela)); + if (!raw) return { visiveis: [], invisiveis: [] }; + return normalizarConfig(JSON.parse(raw)); } catch { - return { visiveis: [], invisiveis: [] } + return { visiveis: [], invisiveis: [] }; } } -/** - * 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)), - ) + window.localStorage.setItem(storageKeyColunas(nomeTabela), JSON.stringify(normalizarConfig(config))); } catch { - // Silencia erros de storage (modo privado, quota excedida) + // ignore } } -/** - * 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)) + window.localStorage.removeItem(storageKeyColunas(nomeTabela)); } catch { - // Silencia erros de storage + // ignore } } diff --git a/src/componentes/EliTabela/filtroAvancadoStorage.ts b/src/componentes/EliTabela/filtroAvancadoStorage.ts index 33eba84..33fb34e 100644 --- a/src/componentes/EliTabela/filtroAvancadoStorage.ts +++ b/src/componentes/EliTabela/filtroAvancadoStorage.ts @@ -1,77 +1,35 @@ -export type EliTabelaFiltroAvancadoSalvo = { - linhas: Array<{ - chave: keyof T - /** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */ - valor: unknown - }> - formula?: string -} +export type EliTabelaFiltroAvancadoSalvo = Array<{ + coluna: keyof T + valor: any +}>; function key(nomeTabela: string) { - return `eli_tabela:${nomeTabela}:filtro_avancado` + return `eli_tabela:${nomeTabela}:filtro_avancado`; } -/** - * Carrega do `localStorage` as linhas de filtro avançado salvas para uma tabela. - * Trata dois formatos legados: array direto de filtros (v1) e objeto `{ linhas, formula }` (v2). - * Em caso de dado ausente, corrompido ou formato desconhecido, retorna estado vazio. - * - * @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`) - * @returns Estado de filtros salvo, ou `{ linhas: [], formula: '' }` como fallback seguro - */ export function carregarFiltroAvancado(nomeTabela: string): EliTabelaFiltroAvancadoSalvo { - const vazio: EliTabelaFiltroAvancadoSalvo = { linhas: [], formula: "" } try { - const raw = localStorage.getItem(key(nomeTabela)) - if (!raw) return vazio - - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) { - return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo - } - - if (parsed && typeof parsed === "object") { - return { - linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [], - formula: typeof parsed.formula === "string" ? parsed.formula : "", - } as EliTabelaFiltroAvancadoSalvo - } - - return vazio + const raw = localStorage.getItem(key(nomeTabela)); + if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as EliTabelaFiltroAvancadoSalvo) : ([] as any); } catch { - return vazio + return [] as unknown as EliTabelaFiltroAvancadoSalvo; } } -/** - * Persiste no `localStorage` o estado do filtro avançado de uma tabela. - * Salva tanto as linhas de filtro quanto a fórmula booleana customizada. - * Falhas de armazenamento são silenciadas para não interromper o fluxo do usuário. - * - * @param nomeTabela - Identificador único da tabela - * @param filtros - Estado completo dos filtros a persistir - */ -export function salvarFiltroAvancado( - nomeTabela: string, - filtros: EliTabelaFiltroAvancadoSalvo, -) { +export function salvarFiltroAvancado(nomeTabela: string, filtros: EliTabelaFiltroAvancadoSalvo) { try { - localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? { linhas: [] })) + localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? [])); } catch { - // Silencia erros de storage (modo privado, quota excedida) + // ignore } } -/** - * 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)) + localStorage.removeItem(key(nomeTabela)); } catch { - // Silencia erros de storage + // ignore } } diff --git a/src/componentes/EliTabela/index.ts b/src/componentes/EliTabela/index.ts index 0964f43..08d7d20 100644 --- a/src/componentes/EliTabela/index.ts +++ b/src/componentes/EliTabela/index.ts @@ -1,6 +1,7 @@ -export * from "./celulas/tiposTabelaCelulas" -export { default as EliTabela } from "./EliTabela.vue" -export * from "./types-eli-tabela" +export { default as EliTabela } from "./EliTabela.vue"; + +export * from "./types-eli-tabela"; +export * from "./celulas/tiposTabelaCelulas"; // Helper para construção de células tipadas. -export { celulaTabela } from "./types-eli-tabela" +export { celulaTabela } from "./types-eli-tabela"; diff --git a/src/componentes/EliTabela/types-eli-tabela.ts b/src/componentes/EliTabela/types-eli-tabela.ts index 39c76af..bdef4fe 100644 --- a/src/componentes/EliTabela/types-eli-tabela.ts +++ b/src/componentes/EliTabela/types-eli-tabela.ts @@ -1,94 +1,78 @@ -import type { LucideIcon } from "@lucide/vue" -import type { tipoCondicaoFiltroDeltaTipado, tipoFiltroDeltaColunaTipada } from "p-comuns" +import type { tipoResposta } from "p-respostas"; +import type { LucideIcon } from "lucide-vue-next"; +import type { TipoTabelaCelula, TiposTabelaCelulas } from "./celulas/tiposTabelaCelulas"; +import { operadores, zFiltro } from "p-comuns"; +import { ComponenteEntrada } from "../EliEntrada/tiposEntradas"; -import type { tipoResposta } from "p-respostas" -import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas" -import type { tiposTabelaCelulas, tipoTabelaCelula } from "./celulas/tiposTabelaCelulas" +// `p-comuns` expõe `zFiltro` (schema). Inferimos o tipo a partir do `parse`. +export type tipoFiltro = ReturnType<(typeof zFiltro)["parse"]> -export type tipoComponenteCelulaBase = readonly [ - T, - tiposTabelaCelulas[T], -] -export type tipoComponenteCelula = { - [K in tipoTabelaCelula]: tipoComponenteCelulaBase -}[tipoTabelaCelula] -/** - * Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela. - * Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`, - * garantindo que não seja possível passar dados incorretos para um tipo de célula. - * - * Uso esperado dentro de `tipoEliColuna.celula`: - * ```ts - * celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome }) - * ``` - * - * @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`) - * @param dados - Dados tipados para o tipo de célula escolhido - * @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue` - */ -export const celulaTabela = ( + + +export type ComponenteCelulaBase = + readonly [T, TiposTabelaCelulas[T]] + +export type ComponenteCelula = { + [K in TipoTabelaCelula]: ComponenteCelulaBase +}[TipoTabelaCelula] + +export const celulaTabela = ( tipo: T, - dados: tiposTabelaCelulas[T], -): tipoComponenteCelulaBase => { + dados: TiposTabelaCelulas[T], +): ComponenteCelulaBase => { return [tipo, dados] as const } -export type { tiposTabelaCelulas, tipoTabelaCelula } +export type { TipoTabelaCelula, TiposTabelaCelulas }; -export type tipoEliColuna = { + + + +export type EliColuna = { /** Texto exibido no cabeçalho da coluna. */ - rotulo: string + rotulo: string; /** Função responsável por renderizar o conteúdo da célula. */ - celula: (linha: T) => tipoComponenteCelula + celula: (linha: T) => ComponenteCelula; /** Ação opcional disparada ao clicar na célula. */ /** * Campo de ordenação associado à coluna. Caso informado, a coluna passa a * exibir controles de ordenação e utiliza o valor como chave para o backend. */ - coluna_ordem?: keyof T + coluna_ordem?: keyof T; /** * indica que a coluna será visivel, se false incia em detalhe * Caso já tenha salvo a propriedade de visibilidade será adotado a propriedade salva */ visivel: boolean -} +}; -export type tipoEliConsultaPaginada = { +export type EliConsultaPaginada = { /** Registros retornados na consulta. */ - valores: T[] + valores: T[]; /** Total de registros disponíveis no backend. */ - quantidade: number -} + quantidade: number; +}; -export type tipoEliTabelaAcao = { +export type EliTabelaAcao = { /** Ícone (Lucide) exibido para representar a ação. */ - icone: LucideIcon + icone: LucideIcon; /** Cor aplicada ao ícone e rótulo. */ - cor: string + cor: string; /** Texto descritivo da ação. */ - rotulo: string + rotulo: string; /** Função executada quando o usuário ativa a ação. */ - acao: (linha: T) => void + acao: (linha: T) => void; /** * Define se a ação deve ser exibida para a linha. Pode ser um booleano fixo * ou uma função (sincrona/assíncrona) que recebe a linha para decisão dinâmica. */ - exibir?: boolean | ((linha: T) => Promise | boolean) -} + exibir?: boolean | ((linha: T) => Promise | boolean); +}; -export type parametrosConsulta = { - filtros?: tipoFiltroDeltaColunaTipada>[] - coluna_ordem?: keyof T - direcao_ordem?: "asc" | "desc" - offSet?: number - limit?: number - /** Texto digitado na caixa de busca, quando habilitada. */ - texto_busca?: string -} /** * Estrutura de dados para uma tabela alimentada por uma consulta. @@ -97,80 +81,63 @@ export type parametrosConsulta = { * - `consulta`: função que recupera os dados, com suporte a ordenação/paginação * - `mostrarCaixaDeBusca`: habilita um campo de busca textual no cabeçalho */ -export type tipoEliTabelaConsulta = { +export type EliTabelaConsulta = { /** nome da tabela, um identificador unico */ nome: string /** Indica se a caixa de busca deve ser exibida acima da tabela. */ - mostrarCaixaDeBusca?: boolean - /** Habilita seleção múltipla (checkboxes) nas linhas da tabela. */ - mostrar_selecao?: boolean - /** Chave (ex: "id") para identificar unicamente a linha nas seleções entre páginas. */ - chave_selecao?: keyof T + mostrarCaixaDeBusca?: boolean; /** Lista de colunas da tabela. */ - colunas: tipoEliColuna[] + colunas: EliColuna[]; /** Quantidade de registros solicitados por consulta (padrão `10`). */ - registros_por_consulta?: number + registros_por_consulta?: number; /** * Função responsável por buscar os dados. Recebe parâmetros opcionais de * ordenação (`coluna_ordem`/`direcao_ordem`) e paginação (`offSet`/`limit`). */ - consulta: ( - parametrosConsulta?: parametrosConsulta, - ) => Promise>> + consulta: (parametrosConsulta?: { + + //Todo: Esse filtros são recebido do processamento de filtro avandado + + filtros?: tipoFiltro[] + coluna_ordem?: keyof T; + direcao_ordem?: "asc" | "desc"; + offSet?: number; + limit?: number; + /** Texto digitado na caixa de busca, quando habilitada. */ + texto_busca?: string; + }) => Promise>>; /** Quantidade máxima de botões exibidos na paginação (padrão `7`). */ - maximo_botoes_paginacao?: number + maximo_botoes_paginacao?: number; /** Mensagem exibida quando a consulta retorna ok porém sem dados. */ - mensagemVazio?: string + mensagemVazio?: string; /** Ações exibidas à direita de cada linha. */ - acoesLinha?: tipoEliTabelaAcao[] + acoesLinha?: EliTabelaAcao[]; /** * Configurações dos botões que serão inseridos a direita da caixa de busca. * Seu uso mais comum será para criar novos registros, mas poderá ter outras utilidades. */ acoesTabela?: { - /** superio será exibido a direita da caixa de busca, inferior a direita da paginação */ - posicao: "superior" | "inferior" - /** Ícone (Lucide) exibido no botão */ - icone?: LucideIcon + icone?: LucideIcon; /** Cor aplicada ao botão. */ - cor?: string + cor?: string; /** Texto descritivo da ação. */ - rotulo: string + rotulo: string; /** Função executada ao clicar no botão. */ + acao: () => void; + }[]; - acao: ( - parametrosConsulta: parametrosConsulta & { - /** - * Callback opcional para forçar atualização da consulta. - * Observação: o componente `EliTabela` pode ignorar isso dependendo do modo de uso. - */ - atualizarConsulta: () => Promise - /** - * Callback opcional para permitir editar a lista localmente (sem refazer consulta). - * Observação: o componente `EliTabela` pode ignorar isso dependendo do modo de uso. - */ - editarLista: (lista: T[]) => Promise - }, - ) => void - }[] +/** configuração para aplicação dos filtros padrões */ +// Todo: quando exite aparace ap lado do obtão coluna o potão filtro avançado, onde abre um modal com dua colunas de compoentes que são contruidas conforme esse padrão +// todo: Os filtros criados deverão ser salvo em local storagem como um objeto tipofiltro[] filtroAvancado?: { - /** Identificador único do filtro (usado para persistência) */ - chave: string - /** Rótulo exibido no select de adicionar filtro */ - rotulo: string - /** vai gerar filtro e mescla com os filtros já existentes , id é gerado de forma altomatica para esse local*/ - filtro: ( - valor: unknown, - ) => - | Pick< - tipoCondicaoFiltroDeltaTipado>, - "coluna" | "operador" | "valor" - > - | undefined - /** componente de entrada que vai receber o valor para o filtro */ + rotulo: string, + coluna: keyof T, + operador: operadores | keyof typeof operadores, entrada: ComponenteEntrada }[] -} -;("") + + +}; + diff --git a/src/componentes/EliTabela/useColunasOrdenacao.ts b/src/componentes/EliTabela/useColunasOrdenacao.ts deleted file mode 100644 index 7f23781..0000000 --- a/src/componentes/EliTabela/useColunasOrdenacao.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * useColunasOrdenacao - * Composable que encapsula a lógica de drag-and-drop e sincronização de estado - * do modal de configuração de colunas da EliTabela. - * - * Responsável por: - * - Sincronizar colunas visíveis/invisíveis com a configuração persistida - * - Aplicar ordem salva e adicionar novas colunas ao final - * - Gerenciar eventos de arrastar-e-soltar entre as duas listas - * - * Extraído de `EliTabelaModalColunas.vue` para manter o componente abaixo de 250 linhas. - */ -import { type Ref, ref, watch } from "vue" -import type { EliTabelaColunasConfig } from "./colunasStorage" -import type { tipoEliColuna } from "./types-eli-tabela" - -type OrigemLista = "visiveis" | "invisiveis" - -interface UseColunasOrdenacaoOptions { - aberto: Ref - rotulosColunas: Ref - configInicial: Ref - // biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai - colunas: Ref[]> -} - -/** - * Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas. - * Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam. - * - * @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização) - * @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela - * @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis) - * @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão) - * @returns Estado reativo e handlers de drag-and-drop prontos para o template - */ -export function useColunasOrdenacao({ - aberto, - rotulosColunas, - configInicial, - colunas, -}: UseColunasOrdenacaoOptions) { - const visiveisLocal = ref([]) - const invisiveisLocal = ref([]) - const itemSendoArrastado = ref(null) - - function sincronizarEstado() { - const todos = rotulosColunas.value - const configTemDados = - (configInicial.value.visiveis?.length ?? 0) > 0 || - (configInicial.value.invisiveis?.length ?? 0) > 0 - - const invisiveisPadraoSet = new Set( - colunas.value.filter((c) => c.visivel === false).map((c) => c.rotulo), - ) - const invisiveisSet = configTemDados - ? new Set(configInicial.value.invisiveis ?? []) - : invisiveisPadraoSet - - const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r)) - const ordemSalva = configInicial.value.visiveis ?? [] - const setVis = new Set(baseVisiveis) - const ordenadas: string[] = [] - - for (const r of ordemSalva) { - if (setVis.has(r)) ordenadas.push(r) - } - for (const r of baseVisiveis) { - if (!ordenadas.includes(r)) ordenadas.push(r) - } - - visiveisLocal.value = ordenadas - invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r)) - } - - watch( - () => [aberto.value, rotulosColunas.value, configInicial.value, colunas.value] as const, - () => { - if (aberto.value) sincronizarEstado() - }, - { deep: true, immediate: true }, - ) - - function onDragStart(e: DragEvent, rotulo: string) { - itemSendoArrastado.value = rotulo - if (e.dataTransfer) { - e.dataTransfer.setData("text/plain", rotulo) - e.dataTransfer.effectAllowed = "move" - } - } - - function onDragEnd() { - itemSendoArrastado.value = null - } - - function onDragOverItem(alvoRotulo: string, destino: OrigemLista) { - const draggedRotulo = itemSendoArrastado.value - if (!draggedRotulo || draggedRotulo === alvoRotulo) return - - const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo) - const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo) - const origem = origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null - if (!origem) return - - const origemArr = origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value - const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value - - const dragIdx = origemArr.indexOf(draggedRotulo) - const alvoIdx = destinoArr.indexOf(alvoRotulo) - - origemArr.splice(dragIdx, 1) - destinoArr.splice(alvoIdx, 0, draggedRotulo) - } - - function onDragOverLista(destino: OrigemLista) { - const draggedRotulo = itemSendoArrastado.value - if (!draggedRotulo) return - - const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value - if (destinoArr[destinoArr.length - 1] === draggedRotulo) return - - const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo) - const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo) - if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1) - if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1) - - destinoArr.push(draggedRotulo) - } - - return { - visiveisLocal, - invisiveisLocal, - itemSendoArrastado, - onDragStart, - onDragEnd, - onDragOverItem, - onDragOverLista, - } -} diff --git a/src/componentes/EliTabela/useEliTabela.ts b/src/componentes/EliTabela/useEliTabela.ts deleted file mode 100644 index 973b52f..0000000 --- a/src/componentes/EliTabela/useEliTabela.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * useEliTabela - * Orquestrador principal da EliTabela — compõe todos os sub-composables - * e retorna o estado unificado para o template do componente. - * - * Composables compostos: - * - `useEliTabelaConsulta` — carregamento, paginação, ordenação, busca - * - `useEliTabelaFiltrosAvancados` — filtros avançados, modal e persistência - * - `useEliTabelaSelecao` — seleção múltipla de linhas - * - `useEliTabelaColunas` — visibilidade, ordem e modal de colunas - * - `useEliTabelaMenu` — menu de ações por linha e posicionamento de popup - * - * @see EliTabela.vue — componente que consome este composable - */ -import { onMounted, ref, watch } from "vue" -import { carregarConfigColunas } from "./colunasStorage" -import { carregarFiltroAvancado } from "./filtroAvancadoStorage" -import type { tipoEliTabelaConsulta } from "./types-eli-tabela" -import { useEliTabelaColunas } from "./useEliTabelaColunas" -import { useEliTabelaConsulta } from "./useEliTabelaConsulta" -import { useEliTabelaFiltrosAvancados } from "./useEliTabelaFiltrosAvancados" -import { useEliTabelaMenu } from "./useEliTabelaMenu" -import { useEliTabelaSelecao } from "./useEliTabelaSelecao" - -// biome-ignore lint/suspicious/noExplicitAny: dynamic table — genérico passado por prop -type TabelaProps = tipoEliTabelaConsulta - -export function useEliTabela( - props: { tabela: TabelaProps; selecionados: unknown[]; esconderDebug: boolean }, - emit: (event: "update:selecionados", value: unknown[]) => void, -) { - // Estado compartilhado entre sub-composables (passado por ref para reatividade) - const paginaAtual = ref(1) - const valorBusca = ref("") - - // Filtros avançados (precisa de carregar que ainda não existe; passa proxy que usará o da consulta) - // Usamos um ref de função para quebrar a dependência circular: filtros precisam de carregar, - // e consulta precisa de filtrosAvancadosAtivos. - let _carregarProxy: () => Promise = async () => { - /* será atribuído após consulta ser criada */ - } - const carregarProxy = () => _carregarProxy() - - const filtros = useEliTabelaFiltrosAvancados(props, paginaAtual, valorBusca, carregarProxy) - - // Colunas: temAcoes vem da consulta — usamos um ref intermediário - const _temAcoesRef = ref(false) - const colunas = useEliTabelaColunas(props, _temAcoesRef) - - const consulta = useEliTabelaConsulta( - props, - filtros.filtrosAvancadosAtivos, - paginaAtual, - valorBusca, - colunas.linhasExpandidas, - ) - - // Conecta o proxy ao carregar real - _carregarProxy = consulta.carregar - // Sincroniza temAcoes para o composable de colunas - // biome-ignore lint/suspicious/noExplicitAny: ref sync interno - ;(_temAcoesRef as any).value = consulta.temAcoes.value - watch(consulta.temAcoes, (v) => { - _temAcoesRef.value = v - }) - - const selecao = useEliTabelaSelecao(props, emit, consulta.linhasPaginadas) - const menu = useEliTabelaMenu(props, consulta.acoesVisiveis) - - // Watch: troca de configuração de tabela reseta todos os estados e recarrega - watch( - () => props.tabela, - () => { - menu.menuAberto.value = null - consulta.colunaOrdenacao.value = null - consulta.direcaoOrdenacao.value = "asc" - valorBusca.value = "" - colunas.modalColunasAberto.value = false - filtros.modalFiltroAberto.value = false - colunas.configColunas.value = carregarConfigColunas(props.tabela.nome) - const filtrosRecarregados = carregarFiltroAvancado>(props.tabela.nome) - filtros.filtrosUi.value = filtrosRecarregados.linhas - colunas.linhasExpandidas.value = {} - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void consulta.carregar() - }, - ) - - onMounted(() => void consulta.carregar()) - - return { - ...consulta, - ...filtros, - ...selecao, - ...colunas, - menuAberto: menu.menuAberto, - menuPopup: menu.menuPopup, - menuPopupPos: menu.menuPopupPos, - possuiAcoes: menu.possuiAcoes, - toggleMenu: menu.toggleMenu, - acoesDisponiveisPorLinha: menu.acoesDisponiveisPorLinha, - paginaAtual, - valorBusca, - } -} diff --git a/src/componentes/EliTabela/useEliTabelaColunas.ts b/src/componentes/EliTabela/useEliTabelaColunas.ts deleted file mode 100644 index 6b6bb19..0000000 --- a/src/componentes/EliTabela/useEliTabelaColunas.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * useEliTabelaColunas - * Gerencia visibilidade, ordenação e persistência da configuração de colunas da EliTabela. - * Controla também o modal de colunas e o estado de linhas expandidas (detalhe de colunas invisíveis). - * - * @param props - Props do componente (tabela.colunas, tabela.nome) - * @param temAcoes - Computed que indica se existem ações por linha (para totalColunas) - */ -import { computed, type Ref, ref } from "vue" -import { - carregarConfigColunas, - type EliTabelaColunasConfig, - salvarConfigColunas, -} from "./colunasStorage" -import type { tipoEliColuna } from "./types-eli-tabela" - -// biome-ignore lint/suspicious/noExplicitAny: colunas dinâmicas — genérico passado por prop -type PropsColunas = { tabela: { colunas: tipoEliColuna[]; nome: string } } - -export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref) { - const modalColunasAberto = ref(false) - const configColunas = ref(carregarConfigColunas(props.tabela.nome)) - const linhasExpandidas = ref>({}) - - const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo)) - - const colunasInvisiveisEfetivas = computed(() => { - // biome-ignore lint/suspicious/noExplicitAny: dynamic column - const colunas = props.tabela.colunas as tipoEliColuna[] - const configTemDados = - (configColunas.value.visiveis?.length ?? 0) > 0 || - (configColunas.value.invisiveis?.length ?? 0) > 0 - const invisiveisBaseRotulos = configTemDados - ? (configColunas.value.invisiveis ?? []) - : colunas.filter((c) => c.visivel === false).map((c) => c.rotulo) - const invisiveisSet = new Set(invisiveisBaseRotulos) - const base = colunas.filter((c) => invisiveisSet.has(c.rotulo)) - // biome-ignore lint/suspicious/noExplicitAny: dynamic map - const mapa = new Map>() - for (const c of base) { - if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c) - } - // biome-ignore lint/suspicious/noExplicitAny: dynamic column - const ordenadas: tipoEliColuna[] = [] - for (const r of invisiveisBaseRotulos) { - const c = mapa.get(r) - if (c) ordenadas.push(c) - } - for (const c of base) { - if (!ordenadas.includes(c)) ordenadas.push(c) - } - return ordenadas - }) - - const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0) - - const colunasEfetivas = computed(() => { - const colunas = props.tabela.colunas - const todosRotulos = rotulosColunas.value - const configTemDados = - (configColunas.value.visiveis?.length ?? 0) > 0 || - (configColunas.value.invisiveis?.length ?? 0) > 0 - const invisiveisBaseRotulos = configTemDados - ? (configColunas.value.invisiveis ?? []) - : // biome-ignore lint/suspicious/noExplicitAny: dynamic column - (props.tabela.colunas as tipoEliColuna[]) - .filter((c) => c.visivel === false) - .map((c) => c.rotulo) - const invisiveisSet = new Set(invisiveisBaseRotulos) - const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r)) - const visiveisSet = new Set(visiveisBaseRotulos) - const ordemSalva = configTemDados ? (configColunas.value.visiveis ?? []) : [] - const ordemFinal: string[] = [] - for (const r of ordemSalva) { - if (visiveisSet.has(r)) ordemFinal.push(r) - } - for (const r of visiveisBaseRotulos) { - if (!ordemFinal.includes(r)) ordemFinal.push(r) - } - // biome-ignore lint/suspicious/noExplicitAny: dynamic map - const mapa = new Map() - for (const c of colunas) { - if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c) - } - return ordemFinal.map((r) => mapa.get(r)).filter(Boolean) - }) - - const totalColunas = computed( - () => - colunasEfetivas.value.length + - (temAcoes.value ? 1 : 0) + - (temColunasInvisiveis.value ? 1 : 0), - ) - - function abrirModalColunas() { - modalColunasAberto.value = true - } - - function fecharModalColunas() { - modalColunasAberto.value = false - } - - function salvarModalColunas(cfg: EliTabelaColunasConfig) { - configColunas.value = cfg - salvarConfigColunas(props.tabela.nome, cfg) - modalColunasAberto.value = false - linhasExpandidas.value = {} - } - - function alternarLinhaExpandida(indice: number) { - const atual = Boolean(linhasExpandidas.value[indice]) - linhasExpandidas.value = { ...linhasExpandidas.value, [indice]: !atual } - } - - return { - modalColunasAberto, - configColunas, - linhasExpandidas, - rotulosColunas, - colunasEfetivas, - colunasInvisiveisEfetivas, - temColunasInvisiveis, - totalColunas, - abrirModalColunas, - fecharModalColunas, - salvarModalColunas, - alternarLinhaExpandida, - } -} diff --git a/src/componentes/EliTabela/useEliTabelaConsulta.ts b/src/componentes/EliTabela/useEliTabelaConsulta.ts deleted file mode 100644 index 6cfb853..0000000 --- a/src/componentes/EliTabela/useEliTabelaConsulta.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * useEliTabelaConsulta - * Gerencia o ciclo de consultas da EliTabela: carregamento, paginação, ordenação e busca. - * Usa um contador sequencial para descartar respostas de consultas antigas (race condition). - * - * @param props - Props do componente (tabela, esconderDebug) - * @param filtrosAvancadosAtivos - Computed com filtros ativos para envio à API - * @param paginaAtual - Ref da página atual (compartilhada com filtros e busca) - * @param valorBusca - Ref da busca textual (compartilhada com filtros avançados) - * @param linhasExpandidas - Ref das linhas expandidas (resetada ao carregar) - */ -import type { tipoFiltroDeltaColunaTipada } from "p-comuns" -import { codigosResposta } from "p-respostas" -import { computed, type Ref, ref, watch } from "vue" -import type { parametrosConsulta, tipoEliTabelaConsulta } from "./types-eli-tabela" - -// biome-ignore lint/suspicious/noExplicitAny: genérico passado por prop -type PropsConsulta = { tabela: tipoEliTabelaConsulta; esconderDebug: boolean } - -export function useEliTabelaConsulta( - props: PropsConsulta, - // biome-ignore lint/suspicious/noExplicitAny: genérico - filtrosAvancadosAtivos: Ref[]>, - paginaAtual: Ref, - valorBusca: Ref, - linhasExpandidas: Ref>, -) { - const isDev = import.meta.env.DEV && !props.esconderDebug - const carregando = ref(false) - const erro = ref(null) - const linhas = ref([]) - const quantidade = ref(0) - const acoesVisiveis = ref([]) - const colunaOrdenacao = ref(null) - const direcaoOrdenacao = ref<"asc" | "desc">("asc") - - const tabela = computed(() => props.tabela) - const registrosPorConsulta = computed(() => { - const valor = props.tabela.registros_por_consulta - return typeof valor === "number" && valor > 0 ? Math.floor(valor) : 10 - }) - const totalPaginas = computed(() => { - const limite = registrosPorConsulta.value - if (!limite || limite <= 0) return 1 - const total = quantidade.value ?? 0 - return total ? Math.max(1, Math.ceil(total / limite)) : 1 - }) - const linhasPaginadas = computed(() => linhas.value ?? []) - const quantidadeFiltrada = computed(() => quantidade.value ?? 0) - const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0) - const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca)) - const acoesSuperiores = computed(() => - (props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"), - ) - const acoesInferiores = computed(() => - (props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"), - ) - const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0) - const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0) - const parametrosConsultaAtuais = computed(() => { - const limite = Math.max(1, registrosPorConsulta.value) - const offset = (paginaAtual.value - 1) * limite - // biome-ignore lint/suspicious/noExplicitAny: dynamic params - const params: parametrosConsulta = { offSet: offset, limit: limite } - const busca = (valorBusca.value ?? "").trim() - if (busca) { - params.texto_busca = busca - } else { - const filtros = filtrosAvancadosAtivos.value - if (filtros.length) params.filtros = filtros - } - if (colunaOrdenacao.value) { - params.coluna_ordem = colunaOrdenacao.value - params.direcao_ordem = direcaoOrdenacao.value - } - return { - ...params, - atualizarConsulta: async () => { - await carregar() - }, - // biome-ignore lint/suspicious/noExplicitAny: dynamic list update - editarLista: async (novaLista: any[]) => { - linhas.value = novaLista - return novaLista - }, - } - }) - - let carregamentoSequencial = 0 - - async function carregar() { - const idCarregamento = ++carregamentoSequencial - carregando.value = true - erro.value = null - try { - acoesVisiveis.value = [] - linhasExpandidas.value = {} - const limite = Math.max(1, registrosPorConsulta.value) - const offset = (paginaAtual.value - 1) * limite - const parametros: { - // biome-ignore lint/suspicious/noExplicitAny: generic - filtros?: tipoFiltroDeltaColunaTipada[] - coluna_ordem?: never - direcao_ordem?: "asc" | "desc" - offSet: number - limit: number - texto_busca?: string - } = { offSet: offset, limit: limite } - const busca = (valorBusca.value ?? "").trim() - if (busca) { - parametros.texto_busca = busca - } else { - const filtros = filtrosAvancadosAtivos.value - if (filtros.length) parametros.filtros = filtros - } - if (colunaOrdenacao.value) { - parametros.coluna_ordem = colunaOrdenacao.value as never - parametros.direcao_ordem = direcaoOrdenacao.value - } - if (isDev) console.log("[EliTabela] consulta(parametros)", parametros) // eslint-disable-line no-console - const res = await props.tabela.consulta(parametros) - if (idCarregamento !== carregamentoSequencial) return - if (res.cod !== codigosResposta.sucesso) { - linhas.value = [] - quantidade.value = 0 - erro.value = res.mensagem - return - } - const valores = res.valor?.valores ?? [] - const total = res.valor?.quantidade ?? valores.length - linhas.value = valores - quantidade.value = Number(total) || 0 - const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidade.value || 0) / limite)) - if (paginaAtual.value > totalPaginasRecalculado) paginaAtual.value = totalPaginasRecalculado - const acoesConf = props.tabela.acoesLinha ?? [] - if (!acoesConf.length) { - acoesVisiveis.value = [] - return - } - acoesVisiveis.value = valores.map(() => - acoesConf.map((a) => - a.exibir === undefined ? true : typeof a.exibir === "boolean" ? a.exibir : false, - ), - ) - const visibilidade = await Promise.all( - valores.map((linha) => - Promise.all( - acoesConf.map(async (acao) => { - if (acao.exibir === undefined) return true - if (typeof acao.exibir === "boolean") return acao.exibir - try { - return Boolean(await Promise.resolve(acao.exibir(linha as never))) - } catch { - return false - } - }), - ), - ), - ) - if (idCarregamento === carregamentoSequencial) acoesVisiveis.value = visibilidade - } catch (e) { - console.error("[EliTabela] Erro capturado em carregar():", e) - if (idCarregamento !== carregamentoSequencial) return - linhas.value = [] - quantidade.value = 0 - erro.value = e instanceof Error ? e.message : "Erro ao carregar dados." - } finally { - if (idCarregamento === carregamentoSequencial) carregando.value = false - } - } - - function alternarOrdenacao(chave?: string) { - if (!chave) return - if (colunaOrdenacao.value === chave) { - direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc" - void carregar() - return - } - colunaOrdenacao.value = chave - direcaoOrdenacao.value = "asc" - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - } - - function atualizarBusca(texto: string) { - if (valorBusca.value === texto) return - valorBusca.value = texto - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - } - - function irParaPagina(pagina: number) { - const alvo = Math.min(Math.max(1, pagina), totalPaginas.value) - if (alvo !== paginaAtual.value) paginaAtual.value = alvo - } - - watch(paginaAtual, (nova, antiga) => { - if (nova !== antiga) void carregar() - }) - watch(linhas, () => { - linhasExpandidas.value = {} - }) - watch( - () => props.tabela.mostrarCaixaDeBusca, - (mostrar) => { - if (!mostrar && valorBusca.value) { - valorBusca.value = "" - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - } - }, - ) - watch( - () => props.tabela.registros_por_consulta, - () => { - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - }, - ) - - return { - isDev, - tabela, - carregando, - erro, - linhas, - linhasPaginadas, - quantidade, - quantidadeFiltrada, - acoesVisiveis, - colunaOrdenacao, - direcaoOrdenacao, - registrosPorConsulta, - totalPaginas, - temAcoes, - exibirBusca, - acoesSuperiores, - acoesInferiores, - temAcoesSuperiores, - temAcoesInferiores, - parametrosConsultaAtuais, - carregar, - alternarOrdenacao, - atualizarBusca, - irParaPagina, - } -} diff --git a/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts b/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts deleted file mode 100644 index ad20ef4..0000000 --- a/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * useEliTabelaFiltrosAvancados - * Gerencia o estado de filtros avançados da EliTabela, incluindo: - * - Linhas de filtro ativas (`filtrosUi`) e fórmula booleana (`filtrosFormula`) - * - Computed `filtrosAvancadosAtivos` que converte as linhas em FiltroDelta para a API - * - Abertura/fechamento e limpeza do modal de filtro avançado - * - * A fórmula é armazenada no formato `{chave}` (ex: `{email} E {nome}`) e convertida - * para IDs globais `{c1} {c2}` durante a montagem do FiltroDelta final. - * - * @param props - Props do componente (tabela.filtroAvancado, tabela.nome) - * @param paginaAtual - Ref da página atual (para resetar ao aplicar filtros) - * @param valorBusca - Ref da busca textual (limpa ao aplicar filtros avançados) - * @param carregar - Função de recarregamento dos dados - */ -import type { tipoCondicaoFiltroDelta, tipoFiltroDeltaColunaTipada } from "p-comuns" -import { computed, type Ref, ref } from "vue" -import { - carregarFiltroAvancado, - limparFiltroAvancado, - salvarFiltroAvancado, -} from "./filtroAvancadoStorage" - -// biome-ignore lint/suspicious/noExplicitAny: dinâmico — genérico passado por prop -type PropsFiltros = { tabela: { filtroAvancado?: any[]; nome: string } } - -type LinhaFiltroUI = { chave: string; valor: unknown } - -export function useEliTabelaFiltrosAvancados( - props: PropsFiltros, - paginaAtual: Ref, - valorBusca: Ref, - carregar: () => Promise, -) { - const _filtrosCarregados = carregarFiltroAvancado>(props.tabela.nome) - const filtrosUi = ref(_filtrosCarregados.linhas) - const filtrosFormula = ref(_filtrosCarregados.formula ?? "") - const modalFiltroAberto = ref(false) - - const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0) - - // biome-ignore lint/suspicious/noExplicitAny: generic - const filtrosAvancadosAtivos = computed[]>(() => { - const base = props.tabela.filtroAvancado ?? [] - const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined) - if (ativos.length === 0) return [] - - const filtrosIndividuais = ativos - .map((f) => { - const b = base.find((x) => String(x.chave) === String(f.chave)) - if (!b) return null - const condicao = b.filtro(f.valor) - if (!condicao) return null - return { chave: f.chave, condicao } - }) - .filter((f): f is { chave: string; condicao: tipoCondicaoFiltroDelta } => f !== null) - - if (filtrosIndividuais.length === 0) return [] - - 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}}`) - }) - filtrosIndividuais.forEach(({ condicao }, i) => { - const novoId = `{c${i + 1}}` - condicoesFinais.push({ ...condicao, id: novoId }) - }) - return [{ condicoes: condicoesFinais, formula: formulaFinal }] - } - - /** Sem fórmula customizada: monta condições com IDs sequenciais e fórmula "E" */ - const condicoesFinais: tipoCondicaoFiltroDelta[] = filtrosIndividuais.map( - ({ condicao }, i) => ({ - ...condicao, - id: `{c${i + 1}}`, - }), - ) - const formula = condicoesFinais.map((c) => c.id).join(" E ") - return [{ condicoes: condicoesFinais, formula }] - }) - - function abrirModalFiltro() { - modalFiltroAberto.value = true - } - - function fecharModalFiltro() { - modalFiltroAberto.value = false - } - - function limparFiltrosAvancados() { - filtrosUi.value = [] - filtrosFormula.value = "" - limparFiltroAvancado(props.tabela.nome) - modalFiltroAberto.value = false - valorBusca.value = "" - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - } - - function salvarFiltrosAvancados(novoLinhas: LinhaFiltroUI[], novaFormula?: string) { - filtrosUi.value = novoLinhas ?? [] - filtrosFormula.value = novaFormula ?? "" - salvarFiltroAvancado>(props.tabela.nome, { - linhas: filtrosUi.value, - formula: filtrosFormula.value, - }) - modalFiltroAberto.value = false - valorBusca.value = "" - if (paginaAtual.value !== 1) paginaAtual.value = 1 - else void carregar() - } - - const nomesOperadores: Record = { - "=": "igual a", - "!=": "diferente de", - ">": "maior que", - "<": "menor que", - ">=": "maior ou igual a", - "<=": "menor ou igual a", - like: "contém", - in: "em", - "∩": "possui", - } - - function formatarFiltroAtivo(entrada: unknown, valor: unknown): string { - const [tipo, opcoes] = Array.isArray(entrada) - ? (entrada as [string, Record]) - : [entrada as string, {} as Record] - - if (tipo === "confirmacao") { - const opt = opcoes || {} - if (valor === true) return (opt.rotuloAtivo as string) || "Sim" - if (valor === false) return (opt.rotuloInativo as string) || "Não" - return String(valor) - } - - if ((tipo === "selecao" || tipo === "selecaoMultipla") && typeof opcoes?.itens === "function") { - try { - const itensResult = opcoes.itens() - if (Array.isArray(itensResult)) { - const findLabel = (key: string) => { - const found = (itensResult as { chave: string; rotulo: string }[]).find( - (item) => String(item.chave) === String(key), - ) - return found ? found.rotulo : key - } - if (Array.isArray(valor)) { - return valor.map((v) => findLabel(String(v))).join(", ") - } - return findLabel(String(valor)) - } - } catch { - // Fallback - } - } - - if (Array.isArray(valor)) { - return valor.map(String).join(", ") - } - return String(valor) - } - - /** Resumo dos filtros ativos para badge e tooltip do cabecalho */ - const filtrosAtivosResumo = computed(() => { - const base = props.tabela.filtroAvancado ?? [] - const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined) - const rotulos: string[] = [] - for (const f of ativos) { - const b = base.find((x) => String(x.chave) === String(f.chave)) - if (b) { - const condicao = b.filtro(f.valor) - if (condicao) { - const opStr = nomesOperadores[condicao.operador] || condicao.operador - const valStr = formatarFiltroAtivo(b.entrada, condicao.valor) - rotulos.push(`${b.rotulo} ${opStr} "${valStr}"`) - } - } - } - return { quantidade: rotulos.length, rotulos } - }) - - return { - filtrosUi, - filtrosFormula, - modalFiltroAberto, - exibirFiltroAvancado, - filtrosAvancadosAtivos, - filtrosAtivosResumo, - abrirModalFiltro, - fecharModalFiltro, - limparFiltrosAvancados, - salvarFiltrosAvancados, - } -} diff --git a/src/componentes/EliTabela/useEliTabelaMenu.ts b/src/componentes/EliTabela/useEliTabelaMenu.ts deleted file mode 100644 index eb8c58a..0000000 --- a/src/componentes/EliTabela/useEliTabelaMenu.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * useEliTabelaMenu - * Gerencia o menu de ações por linha da EliTabela. - * Controla abertura/fechamento, posicionamento do popup e detecção de clique externo. - * - * @param props - Props do componente (tabela.acoesLinha) - * @param acoesVisiveis - Ref com matriz de visibilidade das ações por linha - */ -import { nextTick, onBeforeUnmount, onMounted, type Ref, ref } from "vue" -import type { tipoEliTabelaAcao } from "./types-eli-tabela" - -// biome-ignore lint/suspicious/noExplicitAny: genérico de linha passado por prop -type PropsMenu = { tabela: { acoesLinha?: tipoEliTabelaAcao[] } } - -export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref) { - const menuAberto = ref(null) - const menuPopup = ref<{ menuEl: HTMLElement | null } | null>(null) - const menuPopupPos = ref({ top: 0, left: 0 }) - - function atualizarPosicaoMenu(anchor: HTMLElement) { - const rect = anchor.getBoundingClientRect() - const gap = 8 - const alturaMenu = menuPopup.value?.menuEl?.offsetHeight ?? 0 - const larguraMenu = menuPopup.value?.menuEl?.offsetWidth ?? 180 - let top = rect.bottom + gap - const left = rect.right - larguraMenu - if (alturaMenu && top + alturaMenu > window.innerHeight - gap) { - top = rect.top - gap - alturaMenu - } - menuPopupPos.value = { - top: Math.max(gap, Math.round(top)), - left: Math.max(gap, Math.round(left)), - } - } - - function handleClickFora(evento: MouseEvent) { - if (menuAberto.value === null) return - const alvo = evento.target as Node - if (menuPopup.value?.menuEl?.contains(alvo)) return - if (import.meta.env.DEV) { - // eslint-disable-next-line no-console - console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value }) - } - menuAberto.value = null - } - - function acoesDisponiveisPorLinha(i: number) { - const acoesLinha = props.tabela.acoesLinha ?? [] - const visibilidade = acoesVisiveis.value[i] ?? [] - return acoesLinha - .map((acao, indice) => { - const fallbackVisivel = - acao.exibir === undefined ? true : typeof acao.exibir === "boolean" ? acao.exibir : false - return { acao, indice, visivel: visibilidade[indice] ?? fallbackVisivel } - }) - .filter((item) => item.visivel) - } - - function possuiAcoes(i: number) { - return acoesDisponiveisPorLinha(i).length > 0 - } - - function toggleMenu(i: number, evento?: MouseEvent) { - if (!possuiAcoes(i)) return - if (import.meta.env.DEV) { - // eslint-disable-next-line no-console - console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value }) - } - if (menuAberto.value === i) { - menuAberto.value = null - return - } - menuAberto.value = i - if (import.meta.env.DEV) { - // eslint-disable-next-line no-console - console.log("[EliTabela] toggleMenu => abriu", { i }) - } - const anchor = (evento?.currentTarget as HTMLElement | null) ?? null - if (anchor) { - atualizarPosicaoMenu(anchor) - nextTick(() => { - if (menuAberto.value !== null) atualizarPosicaoMenu(anchor) - }) - } - } - - onMounted(() => document.addEventListener("click", handleClickFora)) - onBeforeUnmount(() => document.removeEventListener("click", handleClickFora)) - - return { menuAberto, menuPopup, menuPopupPos, possuiAcoes, toggleMenu, acoesDisponiveisPorLinha } -} diff --git a/src/componentes/EliTabela/useEliTabelaSelecao.ts b/src/componentes/EliTabela/useEliTabelaSelecao.ts deleted file mode 100644 index cebd5b3..0000000 --- a/src/componentes/EliTabela/useEliTabelaSelecao.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * useEliTabelaSelecao - * Gerencia a seleção múltipla de linhas na EliTabela. - * Suporta seleção por objeto completo ou por chave configurável (`chave_selecao`). - * - * @param props - Props do componente (selecionados, tabela.chave_selecao, tabela.mostrar_selecao) - * @param emit - Função de emit para `update:selecionados` - * @param linhasPaginadas - Ref com as linhas da página atual (para todosSelecionados/algunsSelecionados) - */ -import { computed, type Ref } from "vue" - -type PropsSelecao = { - selecionados: unknown[] - tabela: { mostrar_selecao?: boolean; chave_selecao?: string | number | symbol } -} - -export function useEliTabelaSelecao( - props: PropsSelecao, - emit: (event: "update:selecionados", value: unknown[]) => void, - linhasPaginadas: Ref, -) { - const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao)) - const chaveSelecao = computed(() => props.tabela.chave_selecao as string | undefined) - - function obterChaveLinha(linha: unknown): unknown { - if (chaveSelecao.value && linha && typeof linha === "object") { - return (linha as Record)[chaveSelecao.value] - } - return linha - } - - function isSelecionado(linha: unknown): boolean { - const chave = obterChaveLinha(linha) - if (chaveSelecao.value) { - return props.selecionados.some((s) => obterChaveLinha(s) === chave) - } - return props.selecionados.includes(linha) - } - - function alternarSelecao(linha: unknown) { - const atual = [...props.selecionados] - const chave = obterChaveLinha(linha) - - let index = -1 - if (chaveSelecao.value) { - index = atual.findIndex((s) => obterChaveLinha(s) === chave) - } else { - index = atual.indexOf(linha) - } - - if (index === -1) { - atual.push(linha) - } else { - atual.splice(index, 1) - } - emit("update:selecionados", atual) - } - - const todosSelecionados = computed(() => { - if (linhasPaginadas.value.length === 0) return false - return linhasPaginadas.value.every((linha) => isSelecionado(linha)) - }) - - const algunsSelecionados = computed(() => { - if (linhasPaginadas.value.length === 0) return false - return linhasPaginadas.value.some((linha) => isSelecionado(linha)) && !todosSelecionados.value - }) - - function alternarSelecaoTodos(forcarMarcar?: boolean) { - const marcarTodos = forcarMarcar ?? !todosSelecionados.value - let atual = [...props.selecionados] - - if (marcarTodos) { - for (const linha of linhasPaginadas.value) { - if (!isSelecionado(linha)) atual.push(linha) - } - } else { - const chavesPagina = linhasPaginadas.value.map(obterChaveLinha) - if (chaveSelecao.value) { - atual = atual.filter((s) => !chavesPagina.includes(obterChaveLinha(s))) - } else { - atual = atual.filter((s) => !chavesPagina.includes(s)) - } - } - emit("update:selecionados", atual) - } - - return { - mostrarSelecao, - isSelecionado, - alternarSelecao, - todosSelecionados, - algunsSelecionados, - alternarSelecaoTodos, - } -} diff --git a/src/componentes/EliTabela/useFiltroAvancado.ts b/src/componentes/EliTabela/useFiltroAvancado.ts deleted file mode 100644 index 1ac3716..0000000 --- a/src/componentes/EliTabela/useFiltroAvancado.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 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 { type Component, computed, type Ref, ref, watch } from "vue" -import { - EliEntradaConfirmacao, - EliEntradaDataHora, - EliEntradaNumero, - EliEntradaParagrafo, - EliEntradaSelecao, - EliEntradaSelecaoMultipla, - EliEntradaTexto, -} from "../EliEntrada" -import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas" -import type { tipoEliTabelaConsulta } from "./types-eli-tabela" - -export type FiltroBase = NonNullable["filtroAvancado"]>[number] - -export type LinhaFiltro = { - chave: string - entrada: ComponenteEntrada - /** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */ - valor: unknown -} - -/** - * Retorna o rótulo de exibição de uma definição de filtro. - * Utilizado no template do modal para exibir o nome do campo ao usuário. - * - * @param f - Definição de filtro da configuração da tabela - * @returns Rótulo textual do filtro - */ -export function rotuloDoFiltro(f: FiltroBase) { - return f.rotulo -} - -interface UseFiltroAvancadoOptions { - aberto: Ref - filtrosBase: Ref[]> - modelo: Ref> - modeloFormula: Ref - emit: { - (event: "fechar"): void - (event: "limpar"): void - (event: "salvar", linhas: Array<{ chave: string; valor: unknown }>, formula?: string): void - } -} - -/** - * Composable principal do modal de filtro avançado da EliTabela. - * Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam. - * - * @param options.aberto - Ref que controla se o modal está aberto - * @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela - * @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai) - * @param options.modeloFormula - Ref com a fórmula booleana persistida no pai - * @param options.emit - Função de emit do componente Vue pai - * @returns Estado reativo e ações do modal de filtro - */ -export function useFiltroAvancado({ - aberto, - filtrosBase, - modelo, - modeloFormula, - emit, -}: UseFiltroAvancadoOptions) { - const linhas = ref([]) - const chaveParaAdicionar = ref("") - const formulaCustomizada = ref("") - - const formulaGerada = computed(() => linhas.value.map((_, i) => `{c${i + 1}}`).join(" E ")) - - function formulaParaExibicao(formula: string): string { - let resultado = formula - linhas.value.forEach((l, i) => { - resultado = resultado.replace(new RegExp(`\\{${l.chave}\\}`, "g"), `{c${i + 1}}`) - }) - return resultado - } - - function formulaParaPersistencia(formula: string): string { - let resultado = formula - for (let i = linhas.value.length; i >= 1; i--) { - const chave = linhas.value[i - 1]?.chave - if (chave) { - resultado = resultado.replace(new RegExp(`\\{c${i}\\}`, "g"), `{${chave}}`) - } - } - return resultado - } - - const chavesDisponiveis = computed(() => (filtrosBase.value ?? []).map((b) => String(b.chave))) - - const opcoesParaAdicionar = computed(() => { - const usadas = new Set(linhas.value.map((l) => String(l.chave))) - return (filtrosBase.value ?? []).filter((b) => !usadas.has(String(b.chave))) - }) - - function componenteEntrada(entrada: ComponenteEntrada): Component { - const tipo = entrada?.[0] - if (tipo === "numero") return EliEntradaNumero - if (tipo === "dataHora") return EliEntradaDataHora - if (tipo === "selecao") return EliEntradaSelecao - if (tipo === "selecaoMultipla") return EliEntradaSelecaoMultipla - if (tipo === "paragrafo") return EliEntradaParagrafo - if (tipo === "confirmacao") return EliEntradaConfirmacao - return EliEntradaTexto - } - - function opcoesEntrada(entrada: ComponenteEntrada) { - const opcoes: Record = - typeof entrada?.[1] === "object" && entrada[1] !== null - ? { ...(entrada[1] as Record) } - : {} - return { ...opcoes, rotulo: "" } - } - - function valorInicialPorEntrada(entrada: ComponenteEntrada) { - if (entrada?.[0] === "numero") return null - if (entrada?.[0] === "dataHora") return null - if (entrada?.[0] === "selecao") return null - if (entrada?.[0] === "selecaoMultipla") return [] - if (entrada?.[0] === "confirmacao") return false - return "" - } - - function normalizarModelo() { - const base = filtrosBase.value ?? [] - const modeloArr = Array.isArray(modelo.value) ? modelo.value : [] - linhas.value = modeloArr - .map((m: unknown) => { - if (!m || typeof m !== "object") return null - const item = m as Record - const baseItem = base.find((b) => String(b.chave) === String(item["chave"])) ?? base[0] - if (!baseItem) return null - const entrada = (baseItem.entrada ?? item["entrada"]) as ComponenteEntrada - const chave = String(baseItem.chave ?? item["chave"]) - const val = item["valor"] ?? valorInicialPorEntrada(entrada) - return { chave, entrada, valor: val } as LinhaFiltro - }) - .filter(Boolean) as LinhaFiltro[] - - linhas.value = linhas.value.filter((l) => chavesDisponiveis.value.includes(String(l.chave))) - - if (modeloFormula.value) { - formulaCustomizada.value = formulaParaExibicao(modeloFormula.value) - } else { - formulaCustomizada.value = formulaGerada.value - } - } - - watch( - () => [aberto.value, filtrosBase.value, modelo.value] as const, - () => { - if (aberto.value) normalizarModelo() - }, - { deep: true, immediate: true }, - ) - - function adicionar() { - if (!chaveParaAdicionar.value) return - const b0 = (filtrosBase.value ?? []).find( - (b) => String(b.chave) === String(chaveParaAdicionar.value), - ) - if (!b0) return - if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return - linhas.value.push({ - chave: String(b0.chave), - entrada: b0.entrada, - valor: valorInicialPorEntrada(b0.entrada), - }) - formulaCustomizada.value = formulaGerada.value - chaveParaAdicionar.value = "" - } - - function remover(idx: number) { - linhas.value.splice(idx, 1) - formulaCustomizada.value = formulaGerada.value - } - - function emitFechar() { - emit("fechar") - } - function emitLimpar() { - emit("limpar") - } - - function emitSalvar() { - const formulaPersistida = formulaParaPersistencia(formulaCustomizada.value) - emit( - "salvar", - linhas.value.map((l) => ({ chave: l.chave, valor: l.valor })), - formulaPersistida, - ) - } - - function rotuloDoFiltroAtivo(chave: string): string { - const base = (filtrosBase.value ?? []).find((b) => String(b.chave) === chave) - return base ? rotuloDoFiltro(base) : chave - } - - return { - linhas, - opcoesParaAdicionar, - chaveParaAdicionar, - formulaCustomizada, - formulaGerada, - componenteEntrada, - opcoesEntrada, - adicionar, - remover, - emitFechar, - emitSalvar, - emitLimpar, - rotuloDoFiltro, - rotuloDoFiltroAtivo, - } -} diff --git a/src/componentes/FeedbackWidget/EliFeedbackPainel.css b/src/componentes/FeedbackWidget/EliFeedbackPainel.css deleted file mode 100644 index 5103073..0000000 --- a/src/componentes/FeedbackWidget/EliFeedbackPainel.css +++ /dev/null @@ -1,154 +0,0 @@ -/* ── EliFeedbackWidget — Painel de Comentário ── */ - -.eli-fb-painel { - position: fixed; - bottom: 68px; - right: 24px; - z-index: 99999; - width: 360px; - background: #fff; - border-radius: 14px; - box-shadow: - 0 12px 40px rgba(0, 0, 0, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.08); - overflow: hidden; - animation: eliFbSlideUp 0.25s ease; - font-family: "Inter", "Segoe UI", system-ui, sans-serif; -} - -.eli-fb-painel-header { - background: linear-gradient(135deg, #C62828, #E53935); - color: #fff; - padding: 14px 18px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.eli-fb-painel-header h3 { - margin: 0; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.02em; -} - -.eli-fb-painel-fechar { - background: rgba(255, 255, 255, 0.15); - border: none; - color: #fff; - width: 28px; - height: 28px; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - transition: background 0.15s; -} - -.eli-fb-painel-fechar:hover { - background: rgba(255, 255, 255, 0.3); -} - -.eli-fb-painel-corpo { - padding: 16px 18px; -} - -/* Info do elemento capturado */ -.eli-fb-el-info { - background: #F5F5F5; - border-radius: 8px; - padding: 10px 12px; - margin-bottom: 14px; - font-size: 11px; - color: #616161; - line-height: 1.6; -} - -.eli-fb-el-info code { - background: #E0E0E0; - padding: 1px 5px; - border-radius: 3px; - font-size: 10.5px; - font-family: "SFMono-Regular", Consolas, monospace; - color: #333; -} - -.eli-fb-el-info strong { - color: #424242; -} - -/* Textarea */ -.eli-fb-textarea { - width: 100%; - min-height: 80px; - max-height: 160px; - border: 1.5px solid #E0E0E0; - border-radius: 8px; - padding: 10px 12px; - font-size: 13px; - font-family: "Inter", "Segoe UI", system-ui, sans-serif; - resize: vertical; - outline: none; - transition: border-color 0.15s; - color: #333; - box-sizing: border-box; -} - -.eli-fb-textarea:focus { - border-color: #C62828; - box-shadow: 0 0 0 3px rgba(198, 40, 40, 0.08); -} - -.eli-fb-textarea::placeholder { - color: #BDBDBD; -} - -/* Botões do painel */ -.eli-fb-painel-acoes { - display: flex; - gap: 8px; - margin-top: 12px; - justify-content: flex-end; -} - -.eli-fb-btn-salvar { - background: linear-gradient(135deg, #C62828, #E53935); - color: #fff; - border: none; - border-radius: 8px; - padding: 8px 18px; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s; -} - -.eli-fb-btn-salvar:hover { - transform: translateY(-1px); - box-shadow: 0 3px 10px rgba(198, 40, 40, 0.3); -} - -.eli-fb-btn-salvar:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.eli-fb-btn-cancelar { - background: #F5F5F5; - color: #616161; - border: 1px solid #E0E0E0; - border-radius: 8px; - padding: 8px 14px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: background 0.15s; -} - -.eli-fb-btn-cancelar:hover { - background: #EEEEEE; -} diff --git a/src/componentes/FeedbackWidget/EliFeedbackWidget.css b/src/componentes/FeedbackWidget/EliFeedbackWidget.css deleted file mode 100644 index 06a8dcd..0000000 --- a/src/componentes/FeedbackWidget/EliFeedbackWidget.css +++ /dev/null @@ -1,152 +0,0 @@ -/* ── EliFeedbackWidget — Estilos ── */ - -.eli-feedback-fab { - position: fixed; - bottom: 24px; - right: 24px; - z-index: 99999; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 8px; - font-family: "Inter", "Segoe UI", system-ui, sans-serif; -} - -/* ── Botão principal ── */ - -.eli-fb-botao-principal { - width: 34px; - height: 34px; - border-radius: 50%; - border: none; - background: linear-gradient(135deg, #C62828, #E53935); - color: #fff; - font-size: 14px; - cursor: pointer; - box-shadow: - 0 2px 8px rgba(198, 40, 40, 0.35), - 0 1px 3px rgba(0, 0, 0, 0.12); - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - position: relative; -} - -.eli-fb-botao-principal:hover { - transform: scale(1.08); - box-shadow: - 0 6px 20px rgba(198, 40, 40, 0.45), - 0 2px 6px rgba(0, 0, 0, 0.15); -} - -.eli-fb-botao-principal:active { transform: scale(0.95); } - -/* Badge de contagem */ -.eli-fb-badge { - position: absolute; - top: -5px; - right: -5px; - background: #1565C0; - color: #fff; - font-size: 9px; - font-weight: 700; - min-width: 16px; - height: 16px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - padding: 0 3px; - line-height: 1; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); -} - -/* ── Botões secundários (ícones) ── */ - -.eli-fb-acoes { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - animation: eliFbSlideUp 0.2s ease; -} - -.eli-fb-botao-ico { - width: 34px; - height: 34px; - border-radius: 50%; - border: none; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.eli-fb-botao-ico:hover { - transform: scale(1.1); - box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22); -} - -.eli-fb-botao-exportar { background: #1565C0; color: #fff; } -.eli-fb-botao-feedback { background: linear-gradient(135deg, #C62828, #E53935); color: #fff; } -.eli-fb-botao-copiar { background: #2E7D32; color: #fff; } -.eli-fb-botao-limpar { background: #424242; color: #fff; } - -/* ── Tooltip de seleção (junto ao FAB) ── */ - -.eli-fb-tooltip-selecao { - position: absolute; - bottom: 14px; - right: 64px; - background: linear-gradient(135deg, #C62828, #B71C1C); - color: #fff; - padding: 8px 14px; - border-radius: 10px; - font-size: 12px; - font-weight: 500; - white-space: nowrap; - display: flex; - flex-direction: column; - gap: 2px; - box-shadow: 0 4px 16px rgba(198, 40, 40, 0.35); - animation: eliFbSlideUp 0.2s ease; - pointer-events: none; -} - -.eli-fb-tooltip-selecao::after { - content: ""; - position: absolute; - right: -6px; - top: 50%; - transform: translateY(-50%); - border: 6px solid transparent; - border-left-color: #B71C1C; - border-right: none; -} - -.eli-fb-tooltip-esc { font-size: 10px; opacity: 0.7; } - -.eli-fb-tooltip-esc kbd { - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; - padding: 1px 5px; - font-size: 10px; - font-weight: 700; - font-family: inherit; -} - -/* ── Animações ── */ - -@keyframes eliFbSlideUp { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes eliFbSlideDown { - from { opacity: 0; transform: translateY(-100%); } - to { opacity: 1; transform: translateY(0); } -} diff --git a/src/componentes/FeedbackWidget/EliFeedbackWidget.vue b/src/componentes/FeedbackWidget/EliFeedbackWidget.vue deleted file mode 100644 index 86e1e89..0000000 --- a/src/componentes/FeedbackWidget/EliFeedbackWidget.vue +++ /dev/null @@ -1,185 +0,0 @@ -