Atualização floresta

This commit is contained in:
Luiz Silva 2026-05-31 11:27:51 -03:00
parent 0886fd5c3e
commit 2d4d8f8929
103 changed files with 19266 additions and 14160 deletions

View file

@ -34,12 +34,13 @@ baixar "$BASE/workflows/aprimorar-IA.global.md" "$DIR/workflows
baixar "$BASE/workflows/adicionar-ajuda.global.md" "$DIR/workflows/adicionar-ajuda.global.md"
baixar "$BASE/workflows/atualizar-agente.global.md" "$DIR/workflows/atualizar-agente.global.md"
baixar "$BASE/workflows/criar-fluxograma.global.md" "$DIR/workflows/criar-fluxograma.global.md"
baixar "$BASE/workflows/documentar-codigo.global.md" "$DIR/workflows/documentar-codigo.global.md"
baixar "$BASE/workflows/otimizar-codigos.global.md" "$DIR/workflows/otimizar-codigos.global.md"
baixar "$BASE/workflows/comercial.global.md" "$DIR/workflows/comercial.global.md"
baixar "$BASE/workflows/marketing.global.md" "$DIR/workflows/marketing.global.md"
baixar "$BASE/workflows/orcamento.global.md" "$DIR/workflows/orcamento.global.md"
baixar "$BASE/workflows/reels.global.md" "$DIR/workflows/reels.global.md"
baixar "$BASE/workflows/documentar-regras-negocio.global.md" "$DIR/workflows/documentar-regras-negocio.global.md"
baixar "$BASE/workflows/ux.global.md" "$DIR/workflows/ux.global.md"
# skills

View file

@ -0,0 +1,60 @@
# Regra: Limite de Linhas em Arquivos Vue
## 🎯 Regra
**Nenhum arquivo `*.vue` deve ultrapassar 250 linhas.**
---
## Motivação
Arquivos Vue longos acumulam múltiplas responsabilidades:
- template, lógica e estilo no mesmo arquivo
- componentes internos não reutilizáveis
- dificuldade de teste e manutenção
O limite de 250 linhas é uma fronteira de manutenibilidade, não apenas de estética.
---
## Aplicação automática
O script `scripts/check-vue-linhas.sh` valida este limite em todo `pnpm run build`.
Arquivos legados em processo de refatoração estão listados como **isentos temporários** dentro do script.
---
## Obrigações do agente
### Antes de criar um novo arquivo Vue
- Estimar o tamanho do componente
- Se a estimativa ultrapassar 250 linhas, **quebrar em subcomponentes imediatamente**
### Ao editar um arquivo Vue existente
- Verificar o total de linhas atual antes de adicionar código
- Se a edição levar o arquivo acima de 250 linhas, propor extração de subcomponente
### Ao identificar um arquivo isento
- Sinalizar ao operador que o arquivo é legado e deve ser refatorado
- Não adicionar mais linhas a arquivos isentos sem refatorar algo equivalente
---
## Estratégias de extração obrigatórias
Quando um arquivo se aproximar do limite:
| Situação | Ação |
|---|---|
| Template longo | Extrair seções como `<NomeSecao />` próprio componente |
| CSS extenso | Mover para arquivo `.css` co-localizado e importar |
| Setup() > 100 linhas | Extrair lógica para composable `use*.ts` co-localizado |
| Modal/Dialog embutido | Sempre em componente separado (`*Modal.vue`) |
---
> Não existem isentos. Todos os arquivos Vue da biblioteca respeitam o limite de 250 linhas.

View file

@ -1,58 +0,0 @@
---
description: Workflow para criação e padronização de documentação em código (JSDoc, comentários e tipagem)
---
# Workflow: Documentar Código
## 🎯 Objetivo
Estabelecer um padrão global e unificado para a redação de documentação em código (comentários, `JSDoc` e explicações arquiteturais) em todas as aplicações, garantindo clareza sem prolixidade e respeitando a política de tipagem explícita.
---
## 1️⃣ Princípios Básicos de Documentação
- **Por que, não o Quê**: Documente o raciocínio por trás da decisão arquitetural ou a regra de negócio que forçou a estratégia, não o mecanismo óbvio que o TypeScript nativamente expressaria.
- **Nomenclatura Limpa**: Confie na auto-documentação de nomes de variáveis expressivos baseados no domínio antes de escrever um comentário de texto livre.
## 2️⃣ Funções Compartilhadas e JSDoc (Obrigatória)
Toda função central ou de uso compartilhado, independente da aplicação (frontend, backend, mobile ou bibliotecas comuns), deve portar cabeçalho em `JSDoc`.
### Padrão para JSDoc
```typescript
/**
* Busca os detalhes de uma entidade baseando-se no ID fornecido e calcula privilégios aplicáveis.
* Não utilizar em fluxos de processamento em lote para evitar sobrecarga de requisições ou banco.
*
* @param tenantId - Identificador único do cliente/tenant no sistema
* @param entityId - ID exclusivo identificando o registro na base de dados
* @returns Retorna o objeto consolidado com dados ou `null` caso não seja encontrado
*/
export const obterDetalhesEntidade = async (tenantId: string, entityId: string) => { ... }
```
## 3️⃣ Tratamento de Exceções e Casts Temporários
Devido à política `TypeScript sem any`, não utilizamos conversores obscuros imperativamente.
Porém, se uma coerção agressiva (`as unknown as X`) demonstrar-se temporariamente necessária por divergência ou limitação de SDK terceiro, **o comentário justificando a anomalia é obrigatório**.
```typescript
// Necessário cast duplo pois a biblioteca externa não provê tipagem genérica adequada na versão atual
const dados = payload.raw as unknown as RespostaFormatada
```
## 4️⃣ Atribuição de TODOs
Toda refatoração, debito técnico visualizado ou dúvida de negócio deixada aberta em código de longo prazo **não prescreve** e deve ser acompanhada do formato `// TODO`:
- **Tipagem**: `// TODO de tipagem: Criar interface estrita para o retorno da API de listagem (Removendo Partial)`
- **Negócio**: `// TODO de regra: Validar com a equipe de Produto se a nova taxa adicional será inserida para clientes antigos.`
## 5️⃣ Validação do Fluxo
Sempre ao propor documentação de um arquivo novo ou refatorado, valide via checklist:
* [ ] A documentação está aderente sem estar redundante à tipagem TS?
* [ ] A formatação de parágrafos/espaçamento quebra alguma regra no formatador?
* [ ] A sintaxe suporta autocompletion na sua camada dependente (`@params` estão exatos)?
---
## Referências
- Rules: `.agent/rules/typescript-sem-any.global.md` (política de tipagem que influencia a documentação)
- Skill: `.agent/skills/documentar-decisao-tecnica/SKILL.md` (para decisões arquiteturais que exigem ADR além de JSDoc)

View file

@ -0,0 +1,221 @@
---
description: Otimizar código e documentar padrões de qualidade (estrutura, limites, JSDoc e tipagem)
---
# Workflow: Qualidade e Documentação de Código
## 🎯 Objetivo
Unificar as regras de **estrutura de arquivos**, **otimização** e **documentação** em um único fluxo de referência para o projeto `eli-vue`.
---
## 1️⃣ Limite de Linhas por Arquivo
Nenhum arquivo `*.vue`, `*.ts` ou `*.tsx` pode ultrapassar **250 linhas**.
Essa regra existe para facilitar a leitura humana, a revisão de código e a manutenção a longo prazo.
> O script `scripts/check-vue-linhas.sh` valida automaticamente os arquivos `.vue` no `prebuild`.
**Estratégias quando o limite se aproxima:**
| Situação | Ação |
|---|---|
| Template longo | Extrair seções como subcomponentes |
| CSS extenso | Mover para `<nome>.css` co-localizado |
| `setup()` extenso | Extrair subcomponentes, CSS ou Dispatch Table |
| Modal/Dialog embutido | Sempre em componente separado (`*Modal.vue`) |
| Handlers e dispatch | Extrair para `handlers.ts` ou `dispatch.ts` |
---
## 2️⃣ Estrutura de Componentes Vue
### `defineComponent` obrigatório
Todo componente Vue deve usar `defineComponent` para garantir inferência de tipos correta e consistência.
```vue
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'MeuComponente',
setup() {
// lógica aqui
}
})
</script>
```
**Por que é importante:**
- Inferência de tipos TypeScript correta dentro do componente
- Reconhecimento por ferramentas de análise estática e IDEs
- Compatível com Options API e Composition API com `setup()`
- Padronização entre todos os componentes do projeto
---
### `setup()` não pode ser externo
O `setup()` deve estar sempre dentro do componente `.vue`. Não é permitido extrair ou delegar o `setup` para nenhum arquivo externo.
```ts
// ❌ Proibido — setup delegado inteiramente para arquivo externo
// useEliTabela.ts
export function useEliTabela(props, emit) { ... }
// EliTabela.vue
export default defineComponent({
setup(props, { emit }) {
return useEliTabela(props, emit) // setup é apenas um proxy vazio
}
})
```
```ts
// ✅ Correto — setup com lógica própria dentro do componente
// EliTabela.vue
export default defineComponent({
setup(props, { emit }) {
const estado = ref(null)
const carregar = async () => { ... }
return { estado, carregar }
}
})
```
> Composables `use*.ts` podem encapsular lógica reutilizável pontual (ex: `useFormatador`, `usePermissao`), mas nunca substituir ou esvaziar o `setup()` do componente. Se o componente ultrapassar 250 linhas, a solução é extrair subcomponentes, mover CSS ou usar Dispatch Table — não esvaziar o `setup`.
---
### Desassociação de CSS
A separação de estilos em arquivo externo é permitida e recomendada quando o CSS contribui significativamente para o total de linhas.
**Padrão de nomenclatura:**
```
MeuComponente.vue
MeuComponente.css
```
O `.vue` importa o arquivo CSS no script:
```ts
import './MeuComponente.css'
```
Ou via tag `<style>`:
```vue
<style src="./MeuComponente.css" />
```
---
### Dispatch Table em Arquivo Separado
O uso de Dispatch Table (mapa de funções) em arquivos separados é bem-vindo para eliminar blocos longos de `if/else` ou `switch`.
```ts
// handlers.ts
export const acaoHandlers: Record<string, () => void> = {
salvar: () => { ... },
cancelar: () => { ... },
excluir: () => { ... },
}
```
```ts
// MeuComponente.vue
import { acaoHandlers } from './handlers'
function executar(acao: string) {
acaoHandlers[acao]?.()
}
```
---
## 3️⃣ Documentação em Código (JSDoc)
### Princípios básicos
- **Por que, não o Quê**: Documente o raciocínio por trás da decisão arquitetural, não o mecanismo óbvio que o TypeScript já expressa.
- **Nomenclatura limpa**: Confie na auto-documentação de nomes expressivos antes de escrever comentários de texto livre.
### Funções compartilhadas — JSDoc obrigatório
Toda função central ou de uso compartilhado (frontend, backend, mobile ou bibliotecas comuns) deve ter cabeçalho em `JSDoc`.
```typescript
/**
* Busca os detalhes de uma entidade baseando-se no ID fornecido e calcula privilégios aplicáveis.
* Não utilizar em fluxos de processamento em lote para evitar sobrecarga de requisições.
*
* @param tenantId - Identificador único do cliente/tenant no sistema
* @param entityId - ID exclusivo identificando o registro na base de dados
* @returns Retorna o objeto consolidado com dados ou `null` caso não seja encontrado
*/
export const obterDetalhesEntidade = async (tenantId: string, entityId: string) => { ... }
```
---
## 4️⃣ Casts Temporários e Exceções de Tipagem
Devido à política **TypeScript sem any**, coerções agressivas requerem justificativa obrigatória em comentário.
```typescript
// Necessário cast duplo pois a biblioteca externa não provê tipagem genérica adequada na versão atual
const dados = payload.raw as unknown as RespostaFormatada
```
---
## 5️⃣ TODOs Estruturados
Toda dívida técnica ou dúvida de negócio deixada em código deve seguir o formato `// TODO`:
- **Tipagem**: `// TODO de tipagem: Criar interface estrita para o retorno da API de listagem`
- **Negócio**: `// TODO de regra: Validar com Produto se a taxa adicional se aplica a clientes antigos`
---
## 6️⃣ Resumo das Regras
| Regra | Permitido | Proibido |
|---|---|---|
| Tamanho de arquivo | Até 250 linhas | Acima de 250 linhas |
| `defineComponent` | Obrigatório em todo `.vue` | Componentes sem `defineComponent` |
| `setup()` externo | — | Proibido em qualquer forma |
| CSS externo | `<nome>.css` vinculado ao `.vue` | CSS inline quando ultrapassa o limite |
| Dispatch Table | Em arquivo separado | — |
| JSDoc | Obrigatório em funções compartilhadas | Omitir em funções de uso amplo |
| Casts `as any` | Com `biome-ignore` justificado | Sem comentário explicativo |
---
## 7️⃣ Checklist de Validação
Antes de concluir qualquer refatoração ou adição de código:
- [ ] O arquivo resultante tem menos de 250 linhas?
- [ ] Funções compartilhadas têm `JSDoc` com `@param` e `@returns`?
- [ ] Casts temporários têm comentário justificando?
- [ ] TODOs estão no formato padronizado?
- [ ] A documentação explica o **porquê**, não apenas o **o quê**?
- [ ] `defineComponent` está presente em todo componente `.vue`?
---
## Referências
- Script: `scripts/check-vue-linhas.sh` (validação automática de tamanho)
- Rule: `.agent/rules/vue-limite-linhas.global.md` (política de limite de linhas)
- Rule: `.agent/rules/typescript-sem-any.global.md` (política de tipagem)
- Skill: `.agent/skills/documentar-decisao-tecnica/SKILL.md` (ADRs para decisões arquiteturais)

View file

@ -0,0 +1,289 @@
---
description: Revisar e otimizar UX de componentes e telas Vue (Frontend)
---
# Workflow: Revisão de UX
Guia de revisão e otimização de interfaces Vue 3 + Vuetify 3 no ecossistema Azteca — para usuários corporativos com baixa familiaridade tecnológica.
---
## Perfil do Usuário
O público-alvo das interfaces é corporativo e diversificado em experiência técnica:
- Gestores públicos e ambientais
- Técnicos de campo
- Consultores ambientais
- Operadores administrativos
**Premissa central**: o usuário não tem familiaridade avançada com tecnologia. A interface precisa ser autoexplicativa, sem dependência de treinamento prévio.
---
## Paleta de Cores Institucional
Cor base: `#688C00` (verde floresta). Três escalas derivadas: **Verde floresta** (primária), **Âmbar terroso** (apoio/aviso), **Cinza musgo** (neutros). Semânticas independentes para garantir reconhecimento universal.
### Escala primária — Verde floresta
| Token | Hex | Stop | Uso |
|---|---|---|---|
| `--cor-eli-primaria-bg` | `#EEF3D0` | 50 | Fundo de cards destacados, linhas ativas, hover suave |
| `--cor-eli-primaria-zebra` | `#F6F9E8` | 30 | Linha zebrada de tabelas (quase branco esverdeado) |
| `--cor-eli-primaria-suave` | `#BFC99C` | — | **Cabeçalho de tabela** — sage/olive mudo, sem limão, texto escuro |
| `--cor-eli-primaria-clara` | `#D4E290` | 100 | Badges, chips, seleção, fundo de hover em listas |
| `--cor-eli-primaria-media` | `#ADCC44` | 200 | Tags, indicadores secundários |
| `--cor-eli-primaria-acao` | `#8AAF00` | 400 | Ação secundária, ícones ativos |
| `--cor-eli-primaria` | `#688C00` | 600 | **Botões principais, links ativos, destaques — CTA principal** |
| `--cor-eli-primaria-escura` | `#4A6500` | 800 | Hover em botões, bordas ativas, títulos com ênfase |
| `--cor-eli-primaria-texto` | `#2E3F00` | 900 | Texto escuro sobre fundos claros da paleta primária |
### Escala de apoio — Âmbar terroso
| Token | Hex | Stop | Uso |
|---|---|---|---|
| `--cor-eli-alerta-bg` | `#FAF3DF` | 50 | Fundo de banners de aviso |
| `--cor-eli-alerta-clara` | `#F5DC8A` | 100 | Badges de atenção, chips de alerta |
| `--cor-eli-alerta` | `#DDB847` | 200 | Ícones de aviso, bordas de alerta |
| `--cor-eli-alerta-forte` | `#8C6A00` | 600 | Texto sobre fundo de alerta, status pendente |
### Escala neutra — Cinza musgo
| Token | Hex | Stop | Uso |
|---|---|---|---|
| `--cor-eli-fundo` | `#F2F3EE` | 50 | Background geral de páginas |
| `--cor-eli-borda` | `#D5D8CC` | 100 | Bordas padrão de containers |
| `--cor-eli-borda-forte` | `#B5BAA8` | 200 | Bordas de foco, divisores |
| `--cor-eli-neutro` | `#5E6553` | 600 | Textos secundários, ícones inativos |
| `--cor-eli-neutro-escuro` | `#3E4436` | 800 | Texto de label, cabeçalhos de tabela |
| `--cor-eli-superficie` | `#FFFFFF` | — | Cards, modais, popovers |
### Cores semânticas
| Token | Hex | Uso |
|---|---|---|
| `--cor-eli-sucesso` | `#2e7d32` | Confirmações, status positivo |
| `--cor-eli-sucesso-bg` | `#E8F5E9` | Fundo de mensagens de sucesso |
| `--cor-eli-erro` | `#c62828` | Erros, ações destrutivas |
| `--cor-eli-erro-bg` | `#FFEBEE` | Fundo de mensagens de erro |
| `--cor-eli-info` | `#1565c0` | Informações auxiliares |
| `--cor-eli-info-bg` | `#E3F2FD` | Fundo de banners informativos |
### Regras de contraste
- Texto sobre `--cor-eli-primaria` (`#688C00`) deve ser **branco** (`#ffffff`) — contraste 4.6:1 ✓
- Texto sobre `--cor-eli-primaria-bg` (`#EEF3D0`) deve ser `--cor-eli-primaria-texto` (`#2E3F00`) — contraste 12:1 ✓
- Texto sobre `--cor-eli-primaria-escura` (`#4A6500`) deve ser **branco** (`#ffffff`) — contraste 7.1:1 ✓
- Texto sobre `--cor-eli-alerta-bg` (`#FAF3DF`) deve ser `--cor-eli-alerta-forte` (`#8C6A00`) — contraste 5.2:1 ✓
- Texto sobre `--cor-eli-primaria-suave` (`#BFC99C`) deve ser `--cor-eli-neutro-escuro` (`#3E4436`) — contraste ~12:1 ✓
- **⚠️ Efeito limão**: `--cor-eli-media` (`#ADCC44`) e `--cor-eli-acao` (`#8AAF00`) têm saturação 100% no hue amarelo-verde — **nunca usar como fundo de cabeçalho**. Para fundo verde mudo, usar `--cor-eli-primaria-suave`.
- Sempre validar contraste mínimo de **4.5:1** (WCAG AA) para texto normal e **3:1** para texto grande (18px+ ou 14px bold).
---
## Passos
### 1. Identificar o escopo da revisão
Determinar qual componente, tela ou modal será revisado. Pode ser:
- Um componente isolado (ex: `EliTabela`, `EliCartao`)
- Uma página completa (ex: `PaginaCadastro.vue`)
- Um modal ou drawer (ex: `EliTabelaModalFiltroAvancado.vue`)
- Uma interação específica (ex: fluxo de filtro, paginação, menu de ações)
### 2. Mapear o estado atual
Antes de alterar, entender o que já existe:
- Ler o template (`<template>`) para entender a estrutura visual
- Ler o CSS/SCSS associado para mapear os tokens de design em uso
- Identificar quais componentes Vuetify estão sendo utilizados (ex: `v-btn`, `v-dialog`, `v-select`)
- Verificar se existem estilos inline que deveriam migrar para classes CSS
### 3. Avaliar contra os critérios de UX
Para cada elemento da interface, avaliar:
#### Clareza
- [ ] O propósito de cada elemento é óbvio sem instrução?
- [ ] Labels e placeholders são descritivos? (ex: "Buscar por nome ou CPF" vs "Buscar")
- [ ] Ícones acompanham texto quando a ação não é universalmente reconhecível?
- [ ] O estado vazio (empty state) orienta o usuário sobre o próximo passo?
#### Hierarquia visual
- [ ] O olho do usuário é guiado para a ação principal primeiro?
- [ ] Ações primárias usam `--cor-eli-primaria` (`#688C00`) com destaque?
- [ ] Ações secundárias são visuais mas não competem com a primária?
- [ ] Ações destrutivas (Excluir, Limpar) usam `--cor-eli-erro` com variante `text` ou `outlined`?
#### Feedback e estados
- [ ] Botões têm estados claros de hover (`--cor-eli-primaria-escura`), focus e disabled (`--cor-eli-borda-forte`)?
- [ ] Carregamentos exibem indicador visual (spinner, skeleton, barra de progresso)?
- [ ] Erros de formulário aparecem próximos ao campo com mensagem descritiva, usando `--cor-eli-erro` + `--cor-eli-erro-bg`?
- [ ] Sucesso de operações tem confirmação visual (snackbar, toast, badge) com `--cor-eli-sucesso`?
#### Densidade e espaçamento
- [ ] O espaçamento segue múltiplos de 4px (4, 8, 12, 16, 24, 32)?
- [ ] Em desktop, o espaço horizontal é bem aproveitado? (ex: grids de 2+ colunas para formulários largos)
- [ ] Em mobile, os elementos empilham verticalmente sem quebra visual?
- [ ] Modais têm largura proporcional ao conteúdo?
#### Tipografia
- [ ] Tamanhos de fonte seguem a escala: 12px (caption), 14px (body), 16px (subtitle), 20px (title), 24px (heading)?
- [ ] Textos longos usam `line-height: 1.5` para legibilidade?
- [ ] Labels de formulário são visualmente distintos do conteúdo preenchido?
#### Acessibilidade básica
- [ ] Elementos interativos têm `aria-label` quando o texto visual não é suficiente?
- [ ] Foco via teclado (`Tab`) segue uma ordem lógica?
- [ ] Cores não são o único meio de comunicar estado (ex: ícone + cor para erro)?
- [ ] Todo texto sobre cor primária ou escura usa branco (`#FFFFFF`)?
- [ ] Todo texto sobre `--cor-eli-primaria-bg` usa `--cor-eli-primaria-texto` (`#2E3F00`)?
### 4. Aplicar padrões Vue/Vuetify do projeto
Ao implementar melhorias, seguir os padrões do ecossistema:
#### Componentes Vuetify
- Usar `v-btn` com variantes do tema (`color="primary"`) em vez de classes CSS manuais para botões
- Usar `v-dialog` para modais, `v-menu` para popovers contextuais
- Usar `v-text-field`, `v-select` etc. para inputs sempre que possível
- Aproveitar props de Vuetify: `density`, `variant`, `rounded`, `elevation`
#### Iconografia — Lucide (obrigatório)
Todos os ícones do projeto devem vir exclusivamente da biblioteca **`@lucide/vue`** (dependência já instalada no `package.json`).
**Proibido**:
- SVG inline manual (hardcoded no template)
- Ícones de `@mdi/font` ou Material Design Icons em componentes do projeto
- Bibliotecas alternativas (Font Awesome, Heroicons, etc.)
**Como usar**:
```vue
<script lang="ts">
import { Filter, Plus, X, Trash2, Eye } from "@lucide/vue"
</script>
<template>
<!-- Uso direto como componente -->
<Filter :size="18" :stroke-width="2" />
<!-- Dentro de botão -->
<button>
<Plus :size="15" :stroke-width="2.5" />
Adicionar
</button>
</template>
```
**Props úteis**: `:size` (número em px), `:stroke-width` (espessura do traço, padrão 2), `:color` (cor do ícone).
**Catálogo de referência**: https://lucide.dev/icons — buscar pelo nome do ícone em inglês.
#### CSS do projeto
```css
/* Escala primária — Verde floresta */
--cor-eli-primaria-bg: #EEF3D0;
--cor-eli-primaria-zebra: #F6F9E8;
--cor-eli-primaria-suave: #BFC99C;
--cor-eli-primaria-clara: #D4E290;
--cor-eli-primaria-media: #ADCC44;
--cor-eli-primaria-acao: #8AAF00;
--cor-eli-primaria: #688C00;
--cor-eli-primaria-escura: #4A6500;
--cor-eli-primaria-texto: #2E3F00;
/* Escala de apoio — Âmbar terroso */
--cor-eli-alerta-bg: #FAF3DF;
--cor-eli-alerta-clara: #F5DC8A;
--cor-eli-alerta: #DDB847;
--cor-eli-alerta-forte: #8C6A00;
/* Escala neutra — Cinza musgo */
--cor-eli-fundo: #F2F3EE;
--cor-eli-borda: #D5D8CC;
--cor-eli-borda-forte: #B5BAA8;
--cor-eli-neutro: #5E6553;
--cor-eli-neutro-escuro: #3E4436;
--cor-eli-superficie: #FFFFFF;
/* Semânticas */
--cor-eli-sucesso: #2E7D32;
--cor-eli-sucesso-bg: #E8F5E9;
--cor-eli-erro: #C62828;
--cor-eli-erro-bg: #FFEBEE;
--cor-eli-info: #1565C0;
--cor-eli-info-bg: #E3F2FD;
```
> **⚠️ Regra crítica para componentes distribuídos como biblioteca**: todo token CSS novo (`--cor-eli-primaria-zebra`, `--cor-eli-primaria-suave`, etc.) deve ter **fallback hardcoded** no CSS do componente: `var(--token, #valorhex)`. Projetos destino podem não ter a versão mais recente do `design-tokens.css` e a variável resolverá como `undefined`, tornando a propriedade invisível.
- Classes devem seguir BEM: `eli-componente__elemento--modificador`
- Evitar `!important` — resolver especificidade via seletor mais específico
- Usar `var(--cor-eli-primaria)` em vez de valores hex hardcoded no CSS dos componentes
- Transições padrão: `transition: all 0.2s ease` para hover/focus
#### Composição de componentes
- Componentes filhos devem receber dados via `props` e emitir eventos via `$emit`
- Evitar acessar o DOM diretamente; preferir `ref` do Vue
- Não duplicar CSS entre componentes — extrair para classe utilitária ou CSS do componente pai
### 5. Implementar melhorias prioritárias
Ordenar as melhorias por impacto no usuário:
1. **Crítico**: elementos que causam confusão ou impedem o uso (falta de feedback, botão invisível, estado vazio sem orientação)
2. **Alto**: melhorias que reduzem atrito (espaçamento, hierarquia de botões, responsividade)
3. **Médio**: refinamentos visuais (transições, micro-animações, consistência de cores)
4. **Baixo**: polish (sombras sutis, arredondamentos, tipografia refinada)
### 6. Validar resultado
Após implementar:
- [ ] A tela renderiza sem erros no console?
- [ ] Os estados (vazio, carregando, erro, com dados) estão todos cobertos?
- [ ] A paleta de cores está coerente com a base `#688C00`?
- [ ] Todos os tokens usados estão definidos na escala primária, de apoio ou neutra?
- [ ] Nenhuma cor está hardcoded no CSS do componente (usar `var(--token, #fallback)`)?
- [ ] Tokens novos têm fallback hardcoded? (ex: `var(--cor-eli-primaria-suave, #BFC99C)`)?
- [ ] Contraste de texto validado: branco sobre `--cor-eli-primaria` e `--cor-eli-primaria-escura`; `--cor-eli-neutro-escuro` sobre `--cor-eli-primaria-suave`?
- [ ] Nenhuma ação ficou sem feedback visual?
- [ ] O layout não quebra em larguras menores que 1024px?
- [ ] Componentes Vuetify recebem props de tema (`color`, `variant`) em vez de override CSS?
- [ ] Efeito zebra de tabelas tem fundo branco explícito nas linhas normais (para funcionar sobre qualquer fundo de página)?
---
## Anti-padrões a evitar
| ❌ Anti-padrão | ✅ Alternativa |
|---|---|
| Botão sem label de texto | Botão com ícone + texto, ou `aria-label` descritivo |
| Cor vermelha para ação primária | Vermelho apenas para destrutivas; primária usa `--cor-eli-primaria` |
| Modal com scroll interno extenso | Dividir em etapas ou usar drawer lateral |
| Tabela com 15+ colunas visíveis | Usar o modal de colunas para esconder as menos usadas |
| Placeholder como único label | Label fixo acima do campo + placeholder como exemplo |
| Ações alinhadas à esquerda em linha de tabela | Ações à direita, próximas ao fim da linha visual |
| Toast/snackbar sem ação de fechar | Sempre incluir botão de fechar ou auto-dismiss com tempo suficiente (5s+) |
| Estilos inline em `<template>` | Migrar para `<style scoped>` com classe BEM |
| Cor hex hardcoded no componente | Usar `var(--cor-eli-primaria)` e demais tokens com fallback |
| Texto escuro (`#333`) sobre `--cor-eli-primaria` | Usar branco (`#FFFFFF`) para garantir contraste WCAG AA |
| Amarelo/âmbar para sucesso | Âmbar é reservado para **aviso**; sucesso usa `--cor-eli-sucesso` (`#2E7D32`) |
| Misturar stops da paleta fora dos tokens | Usar apenas os tokens definidos; nunca interpolar valores avulsos |
| Usar `--cor-eli-media` ou `--cor-eli-acao` como fundo de cabeçalho | Usar `--cor-eli-primaria-suave` (`#BFC99C`) — verde mudo sem efeito limão |
| `var(--token)` sem fallback em componente de biblioteca | Sempre declarar `var(--token, #valor)` — o projeto destino pode não ter o token atualizado |
| Zebra de tabela sem fundo branco explícito nas linhas normais | Declarar `.eli-tabela__tr .eli-tabela__td { background: var(--cor-eli-superficie, #fff) }` antes da regra zebra |

13476
dist/eli-vue.es.js vendored

File diff suppressed because one or more lines are too long

298
dist/eli-vue.umd.js vendored

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,51 @@
/**
* Helpers de formatação e parsing numérico usados pelo EliEntradaNumero.
* Extraídos do componente Vue para manter o arquivo dentro de 250 linhas.
*/
export declare function casasDecimaisFromPrecisao(precisao: number): number;
/**
* Analisa a entrada de texto e converte em um valor numérico válido (float).
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
*
* @param texto - O texto bruto digitado ou vindo do estado
* @returns O número processado ou `null` caso inválido ou vazio
*/
export declare function parseNumero(texto: string): number | null;
/**
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
*
* @param value - O valor numérico de fato emitido
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
* @returns String formatada amigável para exibição em pt-BR
*/
export declare function formatarNumero(value: number | null | undefined, casas: number | null): string;
/**
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
*
* @param texto - String de entrada
* @returns Apenas dígitos de 0 a 9
*/
export declare function somenteNumeros(texto: string): string;
/**
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
*
* @param texto - Texto misto de entrada
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
*/
export declare function somenteNumerosESeparadorDecimal(texto: string): string;
/**
* Trunca a parte decimal de um texto numérico ao número máximo de casas permitidas.
* Aplicado durante a digitação para evitar que o usuário ultrapasse a precisão configurada.
*
* @param texto - Texto numérico já higienizado (com separador único)
* @param casas - Número máximo de casas decimais; `null` = sem limite
* @returns Texto com a parte decimal truncada no limite especificado
*/
export declare function limitarCasasDecimais(texto: string, casas: number | null): string;
/**
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
*
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
* @returns A conversão parcial estrita ou `null` caso falhe
*/
export declare function valorParcialDecimal(texto: string): number | null;

View file

@ -1,5 +1,6 @@
import { type PropType } from "vue";
import type { PadroesEntradas } from "./tiposEntradas";
import "./EliEntradaNumero.css";
type EntradaNumero = PadroesEntradas["numero"];
declare const _default: typeof __VLS_export;
export default _default;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +1,9 @@
/**
* 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 declare function formatarCpfCnpj(v: string): string;

View file

@ -1,8 +1,17 @@
/** Dependências do Vue (Composition API) */
/**
* 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 { type PropType } from "vue";
import { type EliTabelaColunasConfig } from "./colunasStorage";
/** Tipos da configuração/contrato da tabela */
import type { parametrosConsulta, tipoEliColuna, tipoEliTabelaConsulta, tipoFiltro } from "./types-eli-tabela";
import "./EliTabela.css";
import type { tipoEliTabelaConsulta } from "./types-eli-tabela";
import type { FiltroBase } from "./useFiltroAvancado";
declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
@ -16,119 +25,19 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
type: PropType<unknown[]>;
default: () => never[];
};
/** Ocultar painel de debug lateral (EliTabelaDebug) */
esconderDebug: {
type: BooleanConstructor;
default: boolean;
};
}>, {
isDev: boolean;
tabela: import("vue").ComputedRef<tipoEliTabelaConsulta<any>>;
carregando: import("vue").Ref<boolean, boolean>;
erro: import("vue").Ref<string | null, string | null>;
linhas: import("vue").Ref<unknown[], unknown[]>;
linhasPaginadas: import("vue").ComputedRef<unknown[]>;
filtrosAvancadosAtivos: import("vue").ComputedRef<tipoFiltro<any>[]>;
quantidadeFiltrada: import("vue").ComputedRef<number>;
quantidade: import("vue").Ref<number, number>;
menuAberto: import("vue").Ref<number | null, number | null>;
valorBusca: import("vue").Ref<string, string>;
paginaAtual: import("vue").Ref<number, number>;
colunaOrdenacao: import("vue").Ref<string | null, string | null>;
direcaoOrdenacao: import("vue").Ref<"desc" | "asc", "desc" | "asc">;
totalPaginas: import("vue").ComputedRef<number>;
registrosPorConsulta: import("vue").ComputedRef<number>;
exibirBusca: import("vue").ComputedRef<boolean>;
exibirFiltroAvancado: import("vue").ComputedRef<boolean>;
acoesSuperiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
temAcoesSuperiores: import("vue").ComputedRef<boolean>;
acoesInferiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
temAcoesInferiores: import("vue").ComputedRef<boolean>;
parametrosConsultaAtuais: import("vue").ComputedRef<{
atualizarConsulta: () => Promise<void>;
editarLista: (novaLista: any[]) => Promise<any[]>;
filtros?: import("p-comuns").tipoFiltro26<any>[] | undefined;
coluna_ordem?: string | number | symbol | undefined;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
texto_busca?: string;
}>;
temAcoes: import("vue").ComputedRef<boolean>;
totalColunas: import("vue").ComputedRef<number>;
colunasEfetivas: import("vue").ComputedRef<any[]>;
rotulosColunas: import("vue").ComputedRef<string[]>;
modalColunasAberto: import("vue").Ref<boolean, boolean>;
configColunas: import("vue").Ref<{
visiveis: string[];
invisiveis: string[];
}, EliTabelaColunasConfig | {
visiveis: string[];
invisiveis: string[];
}>;
temColunasInvisiveis: import("vue").ComputedRef<boolean>;
colunasInvisiveisEfetivas: import("vue").ComputedRef<tipoEliColuna<any>[]>;
linhasExpandidas: import("vue").Ref<Record<number, boolean>, Record<number, boolean>>;
mostrarSelecao: import("vue").ComputedRef<boolean>;
isSelecionado: (linha: unknown) => boolean;
alternarSelecao: (linha: unknown) => void;
todosSelecionados: import("vue").ComputedRef<boolean>;
algunsSelecionados: import("vue").ComputedRef<boolean>;
alternarSelecaoTodos: (forcarMarcar?: boolean) => void;
abrirModalColunas: () => void;
abrirModalFiltro: () => void;
fecharModalColunas: () => void;
salvarModalColunas: (cfg: EliTabelaColunasConfig) => void;
modalFiltroAberto: import("vue").Ref<boolean, boolean>;
filtrosUi: import("vue").Ref<{
chave: string;
valor: any;
}[], {
chave: string;
valor: any;
}[] | {
chave: string;
valor: any;
}[]>;
salvarFiltrosAvancados: (novo: any[]) => void;
limparFiltrosAvancados: () => void;
fecharModalFiltro: () => void;
alternarLinhaExpandida: (indice: number) => void;
alternarOrdenacao: (chave?: string) => void;
atualizarBusca: (texto: string) => void;
irParaPagina: (pagina: number) => void;
acoesDisponiveisPorLinha: (i: number) => {
acao: import("./types-eli-tabela").tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}[];
possuiAcoes: (i: number) => boolean;
toggleMenu: (i: number, evento?: MouseEvent) => void;
menuPopup: import("vue").Ref<{
menuEl: {
value: HTMLElement | null;
};
menuEl: HTMLElement | null;
} | null, {
menuEl: {
value: HTMLElement | null;
};
menuEl: HTMLElement | null;
} | {
menuEl: {
value: HTMLElement | null;
};
menuEl: HTMLElement | null;
} | null>;
menuPopupPos: import("vue").Ref<{
top: number;
@ -140,6 +49,111 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
top: number;
left: number;
}>;
possuiAcoes: (i: number) => boolean;
toggleMenu: (i: number, evento?: MouseEvent) => void;
acoesDisponiveisPorLinha: (i: number) => {
acao: import("./types-eli-tabela").tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}[];
paginaAtual: import("vue").Ref<number, number>;
valorBusca: import("vue").Ref<string, string>;
modalColunasAberto: import("vue").Ref<boolean, boolean>;
configColunas: import("vue").Ref<{
visiveis: string[];
invisiveis: string[];
}, import("./colunasStorage").EliTabelaColunasConfig | {
visiveis: string[];
invisiveis: string[];
}>;
linhasExpandidas: import("vue").Ref<Record<number, boolean>, Record<number, boolean>>;
rotulosColunas: import("vue").ComputedRef<string[]>;
colunasEfetivas: import("vue").ComputedRef<any[]>;
colunasInvisiveisEfetivas: import("vue").ComputedRef<import("./types-eli-tabela").tipoEliColuna<any>[]>;
temColunasInvisiveis: import("vue").ComputedRef<boolean>;
totalColunas: import("vue").ComputedRef<number>;
abrirModalColunas: () => void;
fecharModalColunas: () => void;
salvarModalColunas: (cfg: import("./colunasStorage").EliTabelaColunasConfig) => void;
alternarLinhaExpandida: (indice: number) => void;
mostrarSelecao: import("vue").ComputedRef<boolean>;
isSelecionado: (linha: unknown) => boolean;
alternarSelecao: (linha: unknown) => void;
todosSelecionados: import("vue").ComputedRef<boolean>;
algunsSelecionados: import("vue").ComputedRef<boolean>;
alternarSelecaoTodos: (forcarMarcar?: boolean) => void;
filtrosUi: import("vue").Ref<{
chave: string;
valor: unknown;
}[], {
chave: string;
valor: unknown;
}[] | {
chave: string;
valor: unknown;
}[]>;
filtrosFormula: import("vue").Ref<string, string>;
modalFiltroAberto: import("vue").Ref<boolean, boolean>;
exibirFiltroAvancado: import("vue").ComputedRef<boolean>;
filtrosAvancadosAtivos: import("vue").ComputedRef<import("p-comuns").tipoFiltroDeltaColunaTipada<any>[]>;
abrirModalFiltro: () => void;
fecharModalFiltro: () => void;
limparFiltrosAvancados: () => void;
salvarFiltrosAvancados: (novoLinhas: {
chave: string;
valor: unknown;
}[], novaFormula?: string) => void;
isDev: boolean;
tabela: import("vue").ComputedRef<tipoEliTabelaConsulta<any>>;
carregando: import("vue").Ref<boolean, boolean>;
erro: import("vue").Ref<string | null, string | null>;
linhas: import("vue").Ref<unknown[], unknown[]>;
linhasPaginadas: import("vue").ComputedRef<unknown[]>;
quantidade: import("vue").Ref<number, number>;
quantidadeFiltrada: import("vue").ComputedRef<number>;
acoesVisiveis: import("vue").Ref<boolean[][], boolean[][]>;
colunaOrdenacao: import("vue").Ref<string | null, string | null>;
direcaoOrdenacao: import("vue").Ref<"desc" | "asc", "desc" | "asc">;
registrosPorConsulta: import("vue").ComputedRef<number>;
totalPaginas: import("vue").ComputedRef<number>;
temAcoes: import("vue").ComputedRef<boolean>;
exibirBusca: import("vue").ComputedRef<boolean>;
acoesSuperiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: import("./types-eli-tabela").parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
acoesInferiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: import("./types-eli-tabela").parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
temAcoesSuperiores: import("vue").ComputedRef<boolean>;
temAcoesInferiores: import("vue").ComputedRef<boolean>;
parametrosConsultaAtuais: import("vue").ComputedRef<{
atualizarConsulta: () => Promise<void>;
editarLista: (novaLista: any[]) => Promise<any[]>;
filtros?: import("p-comuns").tipoFiltroDeltaColunaTipada<string>[] | undefined;
coluna_ordem?: string | number | symbol | undefined;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
texto_busca?: string;
}>;
carregar: () => Promise<void>;
alternarOrdenacao: (chave?: string) => void;
atualizarBusca: (texto: string) => void;
irParaPagina: (pagina: number) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, "update:selecionados"[], "update:selecionados", import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
/** Configuração principal da tabela (colunas, consulta e ações) */
tabela: {
@ -151,10 +165,16 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
type: PropType<unknown[]>;
default: () => never[];
};
/** Ocultar painel de debug lateral (EliTabelaDebug) */
esconderDebug: {
type: BooleanConstructor;
default: boolean;
};
}>> & Readonly<{
"onUpdate:selecionados"?: ((...args: any[]) => any) | undefined;
}>, {
selecionados: unknown[];
esconderDebug: boolean;
}, {}, {
EliTabelaCabecalho: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
exibirBusca: {
@ -181,28 +201,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
};
acoesCabecalho: {
type: PropType<Array<{
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any;
cor
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone?: any;
cor?: string;
rotulo: string;
acao: (params
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any) => void;
acao: (params?: any) => void;
}>>;
required: true;
};
@ -240,28 +242,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
};
acoesCabecalho: {
type: PropType<Array<{
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any;
cor
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone?: any;
cor?: string;
rotulo: string;
acao: (params
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any) => void;
acao: (params?: any) => void;
}>>;
required: true;
};
@ -273,6 +257,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
exibirBotaoColunas: boolean;
exibirBotaoFiltroAvancado: boolean;
}, {}, {
Columns3: import("@lucide/vue").LucideIcon;
EliTabelaCaixaDeBusca: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
modelo: {
type: StringConstructor;
@ -297,6 +282,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}, {}, {
Search: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
Filter: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaEstados: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
carregando: {
@ -329,7 +315,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>> & Readonly<{}>, {
erro: string | null;
mensagemVazio: string | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}, {}, {
AlertCircle: import("@lucide/vue").LucideIcon;
Inbox: import("@lucide/vue").LucideIcon;
LoaderCircle: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaDebug: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
isDev: {
type: BooleanConstructor;
@ -346,7 +336,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>;
required: true;
};
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, void, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
isDev: {
type: BooleanConstructor;
required: true;
@ -367,7 +357,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaHead: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
temAcoes: {
@ -401,14 +391,14 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>, {
ArrowUp: import("@lucide/vue").LucideIcon;
ArrowDown: import("@lucide/vue").LucideIcon;
isOrdenavel: (coluna: any) => boolean;
isOrdenavel: (coluna: import("./types-eli-tabela").tipoEliColuna<unknown>) => boolean;
emitAlternarOrdenacao: (chave: string) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
alternarOrdenacao(chave: string): boolean;
"alternar-selecao-todos": () => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
temAcoes: {
@ -453,11 +443,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaBody: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
temColunasInvisiveis: {
@ -469,7 +459,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
linhas: {
type: PropType<Array<unknown>>;
type: PropType<unknown[]>;
required: true;
};
temAcoes: {
@ -509,11 +499,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
ChevronDown: import("@lucide/vue").LucideIcon;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
temColunasInvisiveis: {
@ -525,7 +515,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
linhas: {
type: PropType<Array<unknown>>;
type: PropType<unknown[]>;
required: true;
};
temAcoes: {
@ -588,22 +578,37 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: import("@lucide/vue").LucideIcon;
acao
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: () => void;
}[];
} | {
@ -623,7 +628,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
@ -632,7 +637,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
}>> & Readonly<{}>, {}, {}, {
@ -661,22 +666,37 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: import("@lucide/vue").LucideIcon;
acao
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
?: () => void;
}[];
} | {
@ -708,11 +728,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
acoes: {
type: PropType<Array<{
type: PropType<{
acao: import("./types-eli-tabela").tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}>>;
}[]>;
required: true;
};
linha: {
@ -743,11 +763,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
acoes: {
type: PropType<Array<{
type: PropType<{
acao: import("./types-eli-tabela").tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}>>;
}[]>;
required: true;
};
linha: {
@ -778,28 +798,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
};
acoes: {
type: PropType<Array<{
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any;
cor
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone?: any;
cor?: string;
rotulo: string;
acao: (params
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any) => void;
acao: (params?: any) => void;
}>>;
required: false;
default: () => never[];
@ -837,28 +839,10 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
};
acoes: {
type: PropType<Array<{
icone
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any;
cor
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: string;
icone?: any;
cor?: string;
rotulo: string;
acao: (params
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any) => void;
acao: (params?: any) => void;
}>>;
required: false;
default: () => never[];
@ -874,13 +858,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
icone?: any;
cor?: string;
rotulo: string;
acao: (params
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
*/
/** Enum de códigos de resposta utilizado na consulta */
?: any) => void;
acao: (params?: any) => void;
}[];
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaModalColunas: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
@ -893,26 +871,26 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
configInicial: {
type: PropType<EliTabelaColunasConfig>;
type: PropType<import("./colunasStorage").EliTabelaColunasConfig>;
required: true;
};
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
}>, {
emitFechar: () => void;
emitSalvar: () => void;
visiveisLocal: import("vue").Ref<string[], string[]>;
invisiveisLocal: import("vue").Ref<string[], string[]>;
itemSendoArrastado: import("vue").Ref<string | null, string | null>;
emitFechar: () => void;
emitSalvar: () => void;
onDragStart: (e: DragEvent, rotulo: string) => void;
onDragEnd: () => void;
onDragOverItem: (alvoRotulo: string, destino: "visiveis" | "invisiveis") => void;
onDragOverLista: (destino: "visiveis" | "invisiveis") => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
fechar(): true;
salvar(_config: EliTabelaColunasConfig): true;
salvar(_config: import("./colunasStorage").EliTabelaColunasConfig): true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
aberto: {
type: BooleanConstructor;
@ -923,35 +901,44 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
configInicial: {
type: PropType<EliTabelaColunasConfig>;
type: PropType<import("./colunasStorage").EliTabelaColunasConfig>;
required: true;
};
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<import("./types-eli-tabela").tipoEliColuna<any>[]>;
required: true;
};
}>> & Readonly<{
onFechar?: (() => any) | undefined;
onSalvar?: ((_config: EliTabelaColunasConfig) => any) | undefined;
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
onSalvar?: ((_config: import("./colunasStorage").EliTabelaColunasConfig) => any) | undefined;
}>, {}, {}, {
Columns2: import("@lucide/vue").LucideIcon;
Eye: import("@lucide/vue").LucideIcon;
EyeOff: import("@lucide/vue").LucideIcon;
GripVertical: import("@lucide/vue").LucideIcon;
X: import("@lucide/vue").LucideIcon;
Check: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
EliTabelaModalFiltroAvancado: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
aberto: {
type: BooleanConstructor;
required: true;
};
filtrosBase: {
type: PropType<Array<{
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltro26<any>;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
}>>;
type: PropType<FiltroBase<string>[]>;
required: true;
};
modelo: {
type: PropType<Array<any>>;
type: PropType<Array<{
chave: string;
valor: unknown;
}>>;
required: true;
};
modeloFormula: {
type: StringConstructor;
default: string;
};
}>, {
linhas: import("vue").Ref<{
chave: string;
@ -1009,12 +996,8 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: any;
}[], {
chave: string;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
valor: any;
}[] | {
valor: unknown;
}[], import("./useFiltroAvancado").LinhaFiltro[] | {
chave: string;
entrada: readonly ["texto", {
rotulo: string;
@ -1070,15 +1053,17 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: any;
valor: unknown;
}[]>;
opcoesParaAdicionar: import("vue").ComputedRef<{
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltro26<any>;
filtro: (valor: unknown) => import("p-comuns").tipoFiltroDeltaColunaTipada<"small" | "length" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "toString" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "search" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "codePointAt" | "endsWith" | "normalize" | "repeat" | "startsWith" | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "link" | "strike" | "sub" | "sup" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "valueOf"> | undefined;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
}[]>;
chaveParaAdicionar: import("vue").Ref<string, string>;
formulaCustomizada: import("vue").Ref<string, string>;
formulaGerada: import("vue").ComputedRef<string>;
componenteEntrada: (entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada) => import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: PropType<string | null | undefined>;
@ -1418,43 +1403,57 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>, {
value: string | null | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
opcoesEntrada: (entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada) => any;
opcoesEntrada: (entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada) => {
rotulo: string;
};
adicionar: () => void;
remover: (idx: number) => void;
emitFechar: () => void;
emitSalvar: () => void;
emitLimpar: () => void;
rotuloDoFiltro: (f: {
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltro26<any>;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
}) => string;
rotuloDoFiltro: typeof import("./useFiltroAvancado").rotuloDoFiltro;
rotuloDoFiltroAtivo: (chave: string) => string;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
fechar: () => true;
limpar: () => true;
salvar: (_linhas: any[]) => true;
salvar: (_linhas: Array<{
chave: string;
valor: unknown;
}>, _formula?: string) => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
aberto: {
type: BooleanConstructor;
required: true;
};
filtrosBase: {
type: PropType<Array<{
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltro26<any>;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
}>>;
type: PropType<FiltroBase<string>[]>;
required: true;
};
modelo: {
type: PropType<Array<any>>;
type: PropType<Array<{
chave: string;
valor: unknown;
}>>;
required: true;
};
modeloFormula: {
type: StringConstructor;
default: string;
};
}>> & Readonly<{
onFechar?: (() => any) | undefined;
onSalvar?: ((_linhas: any[]) => any) | undefined;
onSalvar?: ((_linhas: {
chave: string;
valor: unknown;
}[], _formula?: string | undefined) => any) | undefined;
onLimpar?: (() => any) | undefined;
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}>, {
modeloFormula: string;
}, {}, {
CircleHelp: import("@lucide/vue").LucideIcon;
Filter: import("@lucide/vue").LucideIcon;
Plus: import("@lucide/vue").LucideIcon;
Trash2: import("@lucide/vue").LucideIcon;
X: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -4,11 +4,11 @@ declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
temColunasInvisiveis: {
@ -20,7 +20,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
linhas: {
type: PropType<Array<unknown>>;
type: PropType<unknown[]>;
required: true;
};
temAcoes: {
@ -60,11 +60,11 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
ChevronDown: import("@lucide/vue").LucideIcon;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
temColunasInvisiveis: {
@ -76,7 +76,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
linhas: {
type: PropType<Array<unknown>>;
type: PropType<unknown[]>;
required: true;
};
temAcoes: {
@ -156,7 +156,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
@ -165,7 +165,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>> & Readonly<{}>, {}, {}, {

View file

@ -82,6 +82,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
exibirBotaoColunas: boolean;
exibirBotaoFiltroAvancado: boolean;
}, {}, {
Columns3: import("@lucide/vue").LucideIcon;
EliTabelaCaixaDeBusca: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
modelo: {
type: StringConstructor;
@ -106,4 +107,5 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}, {}, {
Search: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
Filter: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -17,7 +17,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>;
required: true;
};
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, void, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
isDev: {
type: BooleanConstructor;
required: true;

View file

@ -8,7 +8,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
@ -17,7 +17,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunasInvisiveis: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>> & Readonly<{}>, {}, {}, {

View file

@ -32,4 +32,8 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>> & Readonly<{}>, {
erro: string | null;
mensagemVazio: string | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}, {}, {
AlertCircle: import("@lucide/vue").LucideIcon;
Inbox: import("@lucide/vue").LucideIcon;
LoaderCircle: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -4,7 +4,7 @@ declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
temAcoes: {
@ -38,14 +38,14 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>, {
ArrowUp: import("@lucide/vue").LucideIcon;
ArrowDown: import("@lucide/vue").LucideIcon;
isOrdenavel: (coluna: any) => boolean;
isOrdenavel: (coluna: tipoEliColuna<unknown>) => boolean;
emitAlternarOrdenacao: (chave: string) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
alternarOrdenacao(chave: string): boolean;
"alternar-selecao-todos": () => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
temAcoes: {

View file

@ -20,7 +20,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
acoes: {
type: PropType<Array<ItemAcao<any>>>;
type: PropType<ItemAcao<any>[]>;
required: true;
};
linha: {
@ -51,7 +51,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
acoes: {
type: PropType<Array<ItemAcao<any>>>;
type: PropType<ItemAcao<any>[]>;
required: true;
};
linha: {

View file

@ -1,7 +1,7 @@
import { type PropType } from "vue";
import "./EliTabelaModalColunas.css";
import type { EliTabelaColunasConfig } from "./colunasStorage";
import type { tipoEliColuna } from "./types-eli-tabela";
type OrigemLista = "visiveis" | "invisiveis";
declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
@ -18,19 +18,19 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>, {
emitFechar: () => void;
emitSalvar: () => void;
visiveisLocal: import("vue").Ref<string[], string[]>;
invisiveisLocal: import("vue").Ref<string[], string[]>;
itemSendoArrastado: import("vue").Ref<string | null, string | null>;
emitFechar: () => void;
emitSalvar: () => void;
onDragStart: (e: DragEvent, rotulo: string) => void;
onDragEnd: () => void;
onDragOverItem: (alvoRotulo: string, destino: OrigemLista) => void;
onDragOverLista: (destino: OrigemLista) => void;
onDragOverItem: (alvoRotulo: string, destino: "visiveis" | "invisiveis") => void;
onDragOverLista: (destino: "visiveis" | "invisiveis") => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
fechar(): true;
salvar(_config: EliTabelaColunasConfig): true;
@ -48,10 +48,17 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
colunas: {
type: PropType<Array<tipoEliColuna<any>>>;
type: PropType<tipoEliColuna<any>[]>;
required: true;
};
}>> & Readonly<{
onFechar?: (() => any) | undefined;
onSalvar?: ((_config: EliTabelaColunasConfig) => any) | undefined;
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}>, {}, {}, {
Columns2: import("@lucide/vue").LucideIcon;
Eye: import("@lucide/vue").LucideIcon;
EyeOff: import("@lucide/vue").LucideIcon;
GripVertical: import("@lucide/vue").LucideIcon;
X: import("@lucide/vue").LucideIcon;
Check: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -1,13 +1,7 @@
import { type PropType } from "vue";
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas";
import type { tipoEliTabelaConsulta } from "./types-eli-tabela";
type FiltroBase<T> = NonNullable<tipoEliTabelaConsulta<T>["filtroAvancado"]>[number];
type LinhaFiltro = {
chave: string;
entrada: ComponenteEntrada;
valor: any;
};
declare function rotuloDoFiltro(f: FiltroBase<any>): string;
import type { FiltroBase } from "./useFiltroAvancado";
import { rotuloDoFiltro } from "./useFiltroAvancado";
import "./EliTabelaModalFiltroAvancado.css";
declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
@ -16,13 +10,21 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: true;
};
filtrosBase: {
type: PropType<Array<FiltroBase<any>>>;
type: PropType<FiltroBase<string>[]>;
required: true;
};
modelo: {
type: PropType<Array<any>>;
/** Linhas persistidas do pai — normalizadas internamente via normalizarModelo() */
type: PropType<Array<{
chave: string;
valor: unknown;
}>>;
required: true;
};
modeloFormula: {
type: StringConstructor;
default: string;
};
}>, {
linhas: import("vue").Ref<{
chave: string;
@ -80,8 +82,8 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: any;
}[], LinhaFiltro[] | {
valor: unknown;
}[], import("./useFiltroAvancado").LinhaFiltro[] | {
chave: string;
entrada: readonly ["texto", {
rotulo: string;
@ -137,16 +139,18 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: any;
valor: unknown;
}[]>;
opcoesParaAdicionar: import("vue").ComputedRef<{
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltro26<any>;
entrada: ComponenteEntrada;
filtro: (valor: unknown) => import("p-comuns").tipoFiltroDeltaColunaTipada<"small" | "length" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "toString" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "search" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "codePointAt" | "endsWith" | "normalize" | "repeat" | "startsWith" | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "link" | "strike" | "sub" | "sup" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "valueOf"> | undefined;
entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada;
}[]>;
chaveParaAdicionar: import("vue").Ref<string, string>;
componenteEntrada: (entrada: ComponenteEntrada) => import("vue").DefineComponent<import("vue").ExtractPropTypes<{
formulaCustomizada: import("vue").Ref<string, string>;
formulaGerada: import("vue").ComputedRef<string>;
componenteEntrada: (entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada) => import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: PropType<string | null | undefined>;
default: undefined;
@ -485,32 +489,58 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
}>, {
value: string | null | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
opcoesEntrada: (entrada: ComponenteEntrada) => any;
opcoesEntrada: (entrada: import("../EliEntrada/tiposEntradas").ComponenteEntrada) => {
rotulo: string;
};
adicionar: () => void;
remover: (idx: number) => void;
emitFechar: () => void;
emitSalvar: () => void;
emitLimpar: () => void;
rotuloDoFiltro: typeof rotuloDoFiltro;
rotuloDoFiltroAtivo: (chave: string) => string;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
fechar: () => true;
limpar: () => true;
salvar: (_linhas: any[]) => true;
/** Dado serializável emitido pelo botão Aplicar */
salvar: (_linhas: Array<{
chave: string;
valor: unknown;
}>, _formula?: string) => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
aberto: {
type: BooleanConstructor;
required: true;
};
filtrosBase: {
type: PropType<Array<FiltroBase<any>>>;
type: PropType<FiltroBase<string>[]>;
required: true;
};
modelo: {
type: PropType<Array<any>>;
/** Linhas persistidas do pai — normalizadas internamente via normalizarModelo() */
type: PropType<Array<{
chave: string;
valor: unknown;
}>>;
required: true;
};
modeloFormula: {
type: StringConstructor;
default: string;
};
}>> & Readonly<{
onFechar?: (() => any) | undefined;
onSalvar?: ((_linhas: any[]) => any) | undefined;
onSalvar?: ((_linhas: {
chave: string;
valor: unknown;
}[], _formula?: string | undefined) => any) | undefined;
onLimpar?: (() => any) | undefined;
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
}>, {
modeloFormula: string;
}, {}, {
CircleHelp: import("@lucide/vue").LucideIcon;
Filter: import("@lucide/vue").LucideIcon;
Plus: import("@lucide/vue").LucideIcon;
Trash2: import("@lucide/vue").LucideIcon;
X: import("@lucide/vue").LucideIcon;
}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;

View file

@ -1,4 +1,5 @@
import { type PropType } from "vue";
import "./EliTabelaPaginacao.css";
declare const _default: typeof __VLS_export;
export default _default;
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{

View file

@ -1,3 +1,4 @@
import "dayjs/locale/pt-br";
import { type PropType } from "vue";
import type { tiposTabelaCelulas } from "./tiposTabelaCelulas";
declare const _default: typeof __VLS_export;
@ -8,11 +9,6 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
required: false;
};
}>, {
dados: {
valor: string;
formato: "data" | "data_hora" | "relativo";
acao?: () => void;
} | undefined;
textoData: import("vue").ComputedRef<string>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {

View file

@ -7,12 +7,6 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
type: PropType<tiposTabelaCelulas["numero"]>;
};
}>, {
dados: {
numero: number;
prefixo?: string;
sufixo?: string;
acao?: () => void;
} | undefined;
textoNumero: import("vue").ComputedRef<string>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {

View file

@ -7,16 +7,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
type: PropType<tiposTabelaCelulas["tags"]>;
required: false;
};
}>, {
dados: {
opcoes: {
rotulo: string;
cor?: string;
icone?: import("@lucide/vue").LucideIcon;
acao?: () => void;
}[];
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: PropType<tiposTabelaCelulas["tags"]>;
required: false;

View file

@ -6,12 +6,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
dados: {
type: PropType<tiposTabelaCelulas["textoSimples"]>;
};
}>, {
dados: {
texto: string;
acao?: () => void;
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: PropType<tiposTabelaCelulas["textoSimples"]>;
};

View file

@ -6,12 +6,7 @@ declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractP
dados: {
type: PropType<tiposTabelaCelulas["textoTruncado"]>;
};
}>, {
dados: {
texto: string;
acao?: () => void;
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: PropType<tiposTabelaCelulas["textoTruncado"]>;
};

View file

@ -1,14 +1,19 @@
/**
* Mapa (dispatch table) que associa cada `tipoTabelaCelula` ao seu componente Vue de renderização.
* Usado por `EliTabelaCelula.vue` para resolver dinamicamente qual componente renderizar
* a partir do tipo retornado por `tipoEliColuna.celula(linha)`.
*
* Adicionar um novo tipo de célula requer:
* 1. Criar o componente `EliTabelaCelula<Tipo>.vue`
* 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts)
* 3. Registrar aqui no mapa
*/
export declare const registryTabelaCelulas: {
readonly textoSimples: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
dados: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["textoSimples"]>;
};
}>, {
dados: {
texto: string;
acao?: () => void;
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["textoSimples"]>;
};
@ -17,12 +22,7 @@ export declare const registryTabelaCelulas: {
dados: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["textoTruncado"]>;
};
}>, {
dados: {
texto: string;
acao?: () => void;
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["textoTruncado"]>;
};
@ -32,12 +32,6 @@ export declare const registryTabelaCelulas: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["numero"]>;
};
}>, {
dados: {
numero: number;
prefixo?: string;
sufixo?: string;
acao?: () => void;
} | undefined;
textoNumero: import("vue").ComputedRef<string>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
@ -49,16 +43,7 @@ export declare const registryTabelaCelulas: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["tags"]>;
required: false;
};
}>, {
dados: {
opcoes: {
rotulo: string;
cor?: string;
icone?: import("@lucide/vue").LucideIcon;
acao?: () => void;
}[];
} | undefined;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {
type: import("vue").PropType<import("./tiposTabelaCelulas").tiposTabelaCelulas["tags"]>;
required: false;
@ -729,11 +714,6 @@ export declare const registryTabelaCelulas: {
required: false;
};
}>, {
dados: {
valor: string;
formato: "data" | "data_hora" | "relativo";
acao?: () => void;
} | undefined;
textoData: import("vue").ComputedRef<string>;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
dados: {

View file

@ -1,10 +1,36 @@
export type EliTabelaColunasConfig = {
/** Rotulos das colunas visiveis (em ordem). */
/** Rótulos das colunas visíveis (em ordem). */
visiveis: string[];
/** Rotulos das colunas invisiveis. */
/** Rótulos das colunas invisíveis. */
invisiveis: string[];
};
/**
* Retorna a chave de `localStorage` utilizada para persistir a configuração de colunas de uma tabela.
*
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
* @returns Chave no formato `eli:tabela:<nomeTabela>:colunas`
*/
export declare function storageKeyColunas(nomeTabela: string): string;
/**
* 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 declare function carregarConfigColunas(nomeTabela: string): EliTabelaColunasConfig;
/**
* Persiste no `localStorage` a configuração de visibilidade e ordem das colunas.
* Falhas de armazenamento (modo privado, storage cheio) são silenciadas.
*
* @param nomeTabela - Identificador único da tabela
* @param config - Configuração com listas de colunas visíveis e invisíveis
*/
export declare function salvarConfigColunas(nomeTabela: string, config: EliTabelaColunasConfig): void;
/**
* 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 declare function limparConfigColunas(nomeTabela: string): void;

View file

@ -1,7 +1,33 @@
export type EliTabelaFiltroAvancadoSalvo<T> = Array<{
coluna: keyof T;
valor: T[keyof T];
}>;
export type EliTabelaFiltroAvancadoSalvo<T> = {
linhas: Array<{
chave: keyof T;
/** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */
valor: unknown;
}>;
formula?: string;
};
/**
* 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 declare function carregarFiltroAvancado<T>(nomeTabela: string): EliTabelaFiltroAvancadoSalvo<T>;
/**
* 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 declare function salvarFiltroAvancado<T>(nomeTabela: string, filtros: EliTabelaFiltroAvancadoSalvo<T>): void;
/**
* 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 declare function limparFiltroAvancado(nomeTabela: string): void;

View file

@ -1,6 +1,5 @@
import type { LucideIcon } from "@lucide/vue";
import type { tipoFiltro26 } from "p-comuns";
export type tipoFiltro<T> = tipoFiltro26<T>;
import type { tipoFiltroDeltaColunaTipada } from "p-comuns";
import type { tipoResposta } from "p-respostas";
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas";
import type { tiposTabelaCelulas, tipoTabelaCelula } from "./celulas/tiposTabelaCelulas";
@ -11,6 +10,20 @@ export type tipoComponenteCelulaBase<T extends tipoTabelaCelula> = readonly [
export type tipoComponenteCelula = {
[K in tipoTabelaCelula]: tipoComponenteCelulaBase<K>;
}[tipoTabelaCelula];
/**
* Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela.
* Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`,
* garantindo que não seja possível passar dados incorretos para um tipo de célula.
*
* Uso esperado dentro de `tipoEliColuna.celula`:
* ```ts
* celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome })
* ```
*
* @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`)
* @param dados - Dados tipados para o tipo de célula escolhido
* @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue`
*/
export declare const celulaTabela: <T extends tipoTabelaCelula>(tipo: T, dados: tiposTabelaCelulas[T]) => tipoComponenteCelulaBase<T>;
export type { tiposTabelaCelulas, tipoTabelaCelula };
export type tipoEliColuna<T> = {
@ -52,7 +65,7 @@ export type tipoEliTabelaAcao<T> = {
exibir?: boolean | ((linha: T) => Promise<boolean> | boolean);
};
export type parametrosConsulta<T> = {
filtros?: tipoFiltro26<T>[];
filtros?: tipoFiltroDeltaColunaTipada<Extract<keyof T, string>>[];
coluna_ordem?: keyof T;
direcao_ordem?: "asc" | "desc";
offSet?: number;
@ -124,7 +137,7 @@ export type tipoEliTabelaConsulta<T> = {
/** Rótulo exibido no select de adicionar filtro */
rotulo: string;
/** vai gerar filtro e mescla com os filtros já existentes */
filtro: (valor: unknown) => tipoFiltro26<T>;
filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada<Extract<keyof T, string>> | undefined;
/** componente de entrada que vai receber o valor para o filtro */
entrada: ComponenteEntrada;
}[];

View file

@ -0,0 +1,42 @@
/**
* 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 } from "vue";
import type { EliTabelaColunasConfig } from "./colunasStorage";
import type { tipoEliColuna } from "./types-eli-tabela";
type OrigemLista = "visiveis" | "invisiveis";
interface UseColunasOrdenacaoOptions {
aberto: Ref<boolean>;
rotulosColunas: Ref<string[]>;
configInicial: Ref<EliTabelaColunasConfig>;
colunas: Ref<tipoEliColuna<any>[]>;
}
/**
* Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas.
* Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização)
* @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela
* @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis)
* @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão)
* @returns Estado reativo e handlers de drag-and-drop prontos para o template
*/
export declare function useColunasOrdenacao({ aberto, rotulosColunas, configInicial, colunas, }: UseColunasOrdenacaoOptions): {
visiveisLocal: Ref<string[], string[]>;
invisiveisLocal: Ref<string[], string[]>;
itemSendoArrastado: Ref<string | null, string | null>;
onDragStart: (e: DragEvent, rotulo: string) => void;
onDragEnd: () => void;
onDragOverItem: (alvoRotulo: string, destino: OrigemLista) => void;
onDragOverLista: (destino: OrigemLista) => void;
};
export {};

View file

@ -0,0 +1,132 @@
import type { tipoEliTabelaConsulta } from "./types-eli-tabela";
type TabelaProps = tipoEliTabelaConsulta<any>;
export declare function useEliTabela(props: {
tabela: TabelaProps;
selecionados: unknown[];
esconderDebug: boolean;
}, emit: (event: "update:selecionados", value: unknown[]) => void): {
menuAberto: import("vue").Ref<number | null, number | null>;
menuPopup: import("vue").Ref<{
menuEl: HTMLElement | null;
} | null, {
menuEl: HTMLElement | null;
} | {
menuEl: HTMLElement | null;
} | null>;
menuPopupPos: import("vue").Ref<{
top: number;
left: number;
}, {
top: number;
left: number;
} | {
top: number;
left: number;
}>;
possuiAcoes: (i: number) => boolean;
toggleMenu: (i: number, evento?: MouseEvent) => void;
acoesDisponiveisPorLinha: (i: number) => {
acao: import("./types-eli-tabela").tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}[];
paginaAtual: import("vue").Ref<number, number>;
valorBusca: import("vue").Ref<string, string>;
modalColunasAberto: import("vue").Ref<boolean, boolean>;
configColunas: import("vue").Ref<{
visiveis: string[];
invisiveis: string[];
}, import("./colunasStorage").EliTabelaColunasConfig | {
visiveis: string[];
invisiveis: string[];
}>;
linhasExpandidas: import("vue").Ref<Record<number, boolean>, Record<number, boolean>>;
rotulosColunas: import("vue").ComputedRef<string[]>;
colunasEfetivas: import("vue").ComputedRef<any[]>;
colunasInvisiveisEfetivas: import("vue").ComputedRef<import("./types-eli-tabela").tipoEliColuna<any>[]>;
temColunasInvisiveis: import("vue").ComputedRef<boolean>;
totalColunas: import("vue").ComputedRef<number>;
abrirModalColunas: () => void;
fecharModalColunas: () => void;
salvarModalColunas: (cfg: import("./colunasStorage").EliTabelaColunasConfig) => void;
alternarLinhaExpandida: (indice: number) => void;
mostrarSelecao: import("vue").ComputedRef<boolean>;
isSelecionado: (linha: unknown) => boolean;
alternarSelecao: (linha: unknown) => void;
todosSelecionados: import("vue").ComputedRef<boolean>;
algunsSelecionados: import("vue").ComputedRef<boolean>;
alternarSelecaoTodos: (forcarMarcar?: boolean) => void;
filtrosUi: import("vue").Ref<{
chave: string;
valor: unknown;
}[], {
chave: string;
valor: unknown;
}[] | {
chave: string;
valor: unknown;
}[]>;
filtrosFormula: import("vue").Ref<string, string>;
modalFiltroAberto: import("vue").Ref<boolean, boolean>;
exibirFiltroAvancado: import("vue").ComputedRef<boolean>;
filtrosAvancadosAtivos: import("vue").ComputedRef<import("p-comuns").tipoFiltroDeltaColunaTipada<any>[]>;
abrirModalFiltro: () => void;
fecharModalFiltro: () => void;
limparFiltrosAvancados: () => void;
salvarFiltrosAvancados: (novoLinhas: {
chave: string;
valor: unknown;
}[], novaFormula?: string) => void;
isDev: boolean;
tabela: import("vue").ComputedRef<tipoEliTabelaConsulta<any>>;
carregando: import("vue").Ref<boolean, boolean>;
erro: import("vue").Ref<string | null, string | null>;
linhas: import("vue").Ref<unknown[], unknown[]>;
linhasPaginadas: import("vue").ComputedRef<unknown[]>;
quantidade: import("vue").Ref<number, number>;
quantidadeFiltrada: import("vue").ComputedRef<number>;
acoesVisiveis: import("vue").Ref<boolean[][], boolean[][]>;
colunaOrdenacao: import("vue").Ref<string | null, string | null>;
direcaoOrdenacao: import("vue").Ref<"desc" | "asc", "desc" | "asc">;
registrosPorConsulta: import("vue").ComputedRef<number>;
totalPaginas: import("vue").ComputedRef<number>;
temAcoes: import("vue").ComputedRef<boolean>;
exibirBusca: import("vue").ComputedRef<boolean>;
acoesSuperiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: import("./types-eli-tabela").parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
acoesInferiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: import("./types-eli-tabela").parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
temAcoesSuperiores: import("vue").ComputedRef<boolean>;
temAcoesInferiores: import("vue").ComputedRef<boolean>;
parametrosConsultaAtuais: import("vue").ComputedRef<{
atualizarConsulta: () => Promise<void>;
editarLista: (novaLista: any[]) => Promise<any[]>;
filtros?: import("p-comuns").tipoFiltroDeltaColunaTipada<string>[] | undefined;
coluna_ordem?: string | number | symbol | undefined;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
texto_busca?: string;
}>;
carregar: () => Promise<void>;
alternarOrdenacao: (chave?: string) => void;
atualizarBusca: (texto: string) => void;
irParaPagina: (pagina: number) => void;
};
export {};

View file

@ -0,0 +1,38 @@
/**
* 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 { type Ref } from "vue";
import { type EliTabelaColunasConfig } from "./colunasStorage";
import type { tipoEliColuna } from "./types-eli-tabela";
type PropsColunas = {
tabela: {
colunas: tipoEliColuna<any>[];
nome: string;
};
};
export declare function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref<boolean>): {
modalColunasAberto: Ref<boolean, boolean>;
configColunas: Ref<{
visiveis: string[];
invisiveis: string[];
}, EliTabelaColunasConfig | {
visiveis: string[];
invisiveis: string[];
}>;
linhasExpandidas: Ref<Record<number, boolean>, Record<number, boolean>>;
rotulosColunas: import("vue").ComputedRef<string[]>;
colunasEfetivas: import("vue").ComputedRef<any[]>;
colunasInvisiveisEfetivas: import("vue").ComputedRef<tipoEliColuna<any>[]>;
temColunasInvisiveis: import("vue").ComputedRef<boolean>;
totalColunas: import("vue").ComputedRef<number>;
abrirModalColunas: () => void;
fecharModalColunas: () => void;
salvarModalColunas: (cfg: EliTabelaColunasConfig) => void;
alternarLinhaExpandida: (indice: number) => void;
};
export {};

View file

@ -0,0 +1,72 @@
/**
* 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 { type Ref } from "vue";
import type { parametrosConsulta, tipoEliTabelaConsulta } from "./types-eli-tabela";
type PropsConsulta = {
tabela: tipoEliTabelaConsulta<any>;
esconderDebug: boolean;
};
export declare function useEliTabelaConsulta(props: PropsConsulta, filtrosAvancadosAtivos: Ref<tipoFiltroDeltaColunaTipada<any>[]>, paginaAtual: Ref<number>, valorBusca: Ref<string>, linhasExpandidas: Ref<Record<number, boolean>>): {
isDev: boolean;
tabela: import("vue").ComputedRef<tipoEliTabelaConsulta<any>>;
carregando: Ref<boolean, boolean>;
erro: Ref<string | null, string | null>;
linhas: Ref<unknown[], unknown[]>;
linhasPaginadas: import("vue").ComputedRef<unknown[]>;
quantidade: Ref<number, number>;
quantidadeFiltrada: import("vue").ComputedRef<number>;
acoesVisiveis: Ref<boolean[][], boolean[][]>;
colunaOrdenacao: Ref<string | null, string | null>;
direcaoOrdenacao: Ref<"desc" | "asc", "desc" | "asc">;
registrosPorConsulta: import("vue").ComputedRef<number>;
totalPaginas: import("vue").ComputedRef<number>;
temAcoes: import("vue").ComputedRef<boolean>;
exibirBusca: import("vue").ComputedRef<boolean>;
acoesSuperiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
acoesInferiores: import("vue").ComputedRef<{
posicao: "superior" | "inferior";
icone?: import("@lucide/vue").LucideIcon;
cor?: string;
rotulo: string;
acao: (parametrosConsulta: parametrosConsulta<any> & {
atualizarConsulta: () => Promise<void>;
editarLista: (lista: any[]) => Promise<any[]>;
}) => void;
}[]>;
temAcoesSuperiores: import("vue").ComputedRef<boolean>;
temAcoesInferiores: import("vue").ComputedRef<boolean>;
parametrosConsultaAtuais: import("vue").ComputedRef<{
atualizarConsulta: () => Promise<void>;
editarLista: (novaLista: any[]) => Promise<any[]>;
filtros?: tipoFiltroDeltaColunaTipada<string>[] | undefined;
coluna_ordem?: string | number | symbol | undefined;
direcao_ordem?: "asc" | "desc";
offSet?: number;
limit?: number;
texto_busca?: string;
}>;
carregar: () => Promise<void>;
alternarOrdenacao: (chave?: string) => void;
atualizarBusca: (texto: string) => void;
irParaPagina: (pagina: number) => void;
};
export {};

View file

@ -0,0 +1,45 @@
/**
* 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 { tipoFiltroDeltaColunaTipada } from "p-comuns";
import { type Ref } from "vue";
type PropsFiltros = {
tabela: {
filtroAvancado?: any[];
nome: string;
};
};
type LinhaFiltroUI = {
chave: string;
valor: unknown;
};
export declare function useEliTabelaFiltrosAvancados(props: PropsFiltros, paginaAtual: Ref<number>, valorBusca: Ref<string>, carregar: () => Promise<void>): {
filtrosUi: Ref<{
chave: string;
valor: unknown;
}[], LinhaFiltroUI[] | {
chave: string;
valor: unknown;
}[]>;
filtrosFormula: Ref<string, string>;
modalFiltroAberto: Ref<boolean, boolean>;
exibirFiltroAvancado: import("vue").ComputedRef<boolean>;
filtrosAvancadosAtivos: import("vue").ComputedRef<tipoFiltroDeltaColunaTipada<any>[]>;
abrirModalFiltro: () => void;
fecharModalFiltro: () => void;
limparFiltrosAvancados: () => void;
salvarFiltrosAvancados: (novoLinhas: LinhaFiltroUI[], novaFormula?: string) => void;
};
export {};

View file

@ -0,0 +1,43 @@
/**
* 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 { type Ref } from "vue";
import type { tipoEliTabelaAcao } from "./types-eli-tabela";
type PropsMenu = {
tabela: {
acoesLinha?: tipoEliTabelaAcao<any>[];
};
};
export declare function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref<boolean[][]>): {
menuAberto: Ref<number | null, number | null>;
menuPopup: Ref<{
menuEl: HTMLElement | null;
} | null, {
menuEl: HTMLElement | null;
} | {
menuEl: HTMLElement | null;
} | null>;
menuPopupPos: Ref<{
top: number;
left: number;
}, {
top: number;
left: number;
} | {
top: number;
left: number;
}>;
possuiAcoes: (i: number) => boolean;
toggleMenu: (i: number, evento?: MouseEvent) => void;
acoesDisponiveisPorLinha: (i: number) => {
acao: tipoEliTabelaAcao<any>;
indice: number;
visivel: boolean;
}[];
};
export {};

View file

@ -0,0 +1,26 @@
/**
* 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 { type Ref } from "vue";
type PropsSelecao = {
selecionados: unknown[];
tabela: {
mostrar_selecao?: boolean;
chave_selecao?: string | number | symbol;
};
};
export declare function useEliTabelaSelecao(props: PropsSelecao, emit: (event: "update:selecionados", value: unknown[]) => void, linhasPaginadas: Ref<unknown[]>): {
mostrarSelecao: import("vue").ComputedRef<boolean>;
isSelecionado: (linha: unknown) => boolean;
alternarSelecao: (linha: unknown) => void;
todosSelecionados: import("vue").ComputedRef<boolean>;
algunsSelecionados: import("vue").ComputedRef<boolean>;
alternarSelecaoTodos: (forcarMarcar?: boolean) => void;
};
export {};

View file

@ -0,0 +1,534 @@
/**
* 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 Ref } from "vue";
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas";
import type { tipoEliTabelaConsulta } from "./types-eli-tabela";
export type FiltroBase<T> = NonNullable<tipoEliTabelaConsulta<T>["filtroAvancado"]>[number];
export type LinhaFiltro = {
chave: string;
entrada: ComponenteEntrada;
/** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */
valor: unknown;
};
/**
* Retorna o rótulo de exibição de uma definição de filtro.
* Utilizado no template do modal para exibir o nome do campo ao usuário.
*
* @param f - Definição de filtro da configuração da tabela
* @returns Rótulo textual do filtro
*/
export declare function rotuloDoFiltro(f: FiltroBase<string>): string;
interface UseFiltroAvancadoOptions {
aberto: Ref<boolean>;
filtrosBase: Ref<FiltroBase<string>[]>;
modelo: Ref<Array<{
chave: string;
valor: unknown;
}>>;
modeloFormula: Ref<string>;
emit: {
(event: "fechar"): void;
(event: "limpar"): void;
(event: "salvar", linhas: Array<{
chave: string;
valor: unknown;
}>, formula?: string): void;
};
}
/**
* Composable principal do modal de filtro avançado da EliTabela.
* Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto
* @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela
* @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai)
* @param options.modeloFormula - Ref com a fórmula booleana persistida no pai
* @param options.emit - Função de emit do componente Vue pai
* @returns Estado reativo e ações do modal de filtro
*/
export declare function useFiltroAvancado({ aberto, filtrosBase, modelo, modeloFormula, emit, }: UseFiltroAvancadoOptions): {
linhas: Ref<{
chave: string;
entrada: readonly ["texto", {
rotulo: string;
placeholder?: string | undefined;
limiteCaracteres?: number | undefined;
formato?: "texto" | "email" | "url" | "telefone" | "cpfCnpj" | "cep" | undefined;
}] | readonly ["dataHora", {
rotulo: string;
placeholder?: string | undefined;
modo?: "data" | "dataHora" | undefined;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
min?: string | undefined;
max?: string | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}] | readonly ["numero", {
rotulo: string;
placeholder?: string | undefined;
sufixo?: string | undefined;
prefixo?: string | undefined;
precisao?: number | undefined;
}] | readonly ["paragrafo", {
rotulo: string;
placeholder?: string | undefined;
linhas?: number | undefined;
limiteCaracteres?: number | undefined;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}] | readonly ["selecao", {
rotulo: string;
placeholder?: string | undefined;
itens: () => {
chave: string;
rotulo: string;
}[] | Promise<{
chave: string;
rotulo: string;
}[]>;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: unknown;
}[], LinhaFiltro[] | {
chave: string;
entrada: readonly ["texto", {
rotulo: string;
placeholder?: string | undefined;
limiteCaracteres?: number | undefined;
formato?: "texto" | "email" | "url" | "telefone" | "cpfCnpj" | "cep" | undefined;
}] | readonly ["dataHora", {
rotulo: string;
placeholder?: string | undefined;
modo?: "data" | "dataHora" | undefined;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
min?: string | undefined;
max?: string | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}] | readonly ["numero", {
rotulo: string;
placeholder?: string | undefined;
sufixo?: string | undefined;
prefixo?: string | undefined;
precisao?: number | undefined;
}] | readonly ["paragrafo", {
rotulo: string;
placeholder?: string | undefined;
linhas?: number | undefined;
limiteCaracteres?: number | undefined;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}] | readonly ["selecao", {
rotulo: string;
placeholder?: string | undefined;
itens: () => {
chave: string;
rotulo: string;
}[] | Promise<{
chave: string;
rotulo: string;
}[]>;
limpavel?: boolean | undefined;
erro?: boolean | undefined;
mensagensErro?: string | string[] | undefined;
dica?: string | undefined;
dicaPersistente?: boolean | undefined;
densidade?: import("../..").CampoDensidade | undefined;
variante?: import("../..").CampoVariante | undefined;
}];
valor: unknown;
}[]>;
opcoesParaAdicionar: import("vue").ComputedRef<{
chave: string;
rotulo: string;
filtro: (valor: unknown) => import("p-comuns").tipoFiltroDeltaColunaTipada<"small" | "length" | "concat" | "slice" | "indexOf" | "lastIndexOf" | "includes" | "toString" | "charAt" | "charCodeAt" | "localeCompare" | "match" | "replace" | "search" | "split" | "substring" | "toLowerCase" | "toLocaleLowerCase" | "toUpperCase" | "toLocaleUpperCase" | "trim" | "substr" | "codePointAt" | "endsWith" | "normalize" | "repeat" | "startsWith" | "anchor" | "big" | "blink" | "bold" | "fixed" | "fontcolor" | "fontsize" | "italics" | "link" | "strike" | "sub" | "sup" | "padStart" | "padEnd" | "trimEnd" | "trimStart" | "trimLeft" | "trimRight" | "matchAll" | "valueOf"> | undefined;
entrada: ComponenteEntrada;
}[]>;
chaveParaAdicionar: Ref<string, string>;
formulaCustomizada: Ref<string, string>;
formulaGerada: import("vue").ComputedRef<string>;
componenteEntrada: (entrada: ComponenteEntrada) => import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<string | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
modo?: "data" | "dataHora";
limpavel?: boolean;
erro?: boolean;
mensagensErro?: string | string[];
dica?: string;
dicaPersistente?: boolean;
min?: string;
max?: string;
densidade?: import("../..").CampoDensidade;
variante?: import("../..").CampoVariante;
}>;
required: false;
default: undefined;
};
modelValue: {
type: import("vue").PropType<string | null>;
default: null;
};
modo: {
type: import("vue").PropType<"data" | "dataHora" | undefined>;
default: undefined;
};
rotulo: {
type: StringConstructor;
default: undefined;
};
placeholder: {
type: StringConstructor;
default: undefined;
};
desabilitado: {
type: BooleanConstructor;
default: undefined;
};
limpavel: {
type: BooleanConstructor;
default: undefined;
};
erro: {
type: BooleanConstructor;
default: undefined;
};
mensagensErro: {
type: import("vue").PropType<string | string[]>;
default: undefined;
};
dica: {
type: StringConstructor;
default: undefined;
};
dicaPersistente: {
type: BooleanConstructor;
default: undefined;
};
densidade: {
type: import("vue").PropType<import("../..").CampoDensidade>;
default: undefined;
};
variante: {
type: import("vue").PropType<import("../..").CampoVariante>;
default: undefined;
};
min: {
type: import("vue").PropType<string | undefined>;
default: undefined;
};
max: {
type: import("vue").PropType<string | undefined>;
default: undefined;
};
}>, {
attrs: import("vue").Attrs;
valor: import("vue").WritableComputedRef<string, string>;
tipoInput: import("vue").ComputedRef<"date" | "datetime-local">;
minLocal: import("vue").ComputedRef<string | undefined>;
maxLocal: import("vue").ComputedRef<string | undefined>;
opcoesEfetivas: import("vue").ComputedRef<{
rotulo: string;
placeholder?: string;
} & {
modo?: "data" | "dataHora";
limpavel?: boolean;
erro?: boolean;
mensagensErro?: string | string[];
dica?: string;
dicaPersistente?: boolean;
min?: string;
max?: string;
densidade?: import("../..").CampoDensidade;
variante?: import("../..").CampoVariante;
}>;
desabilitadoEfetivo: import("vue").ComputedRef<boolean>;
emitCompatFocus: () => void;
emitCompatBlur: () => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
"update:value": (_v: string | null) => true;
input: (_v: string | null) => true;
change: (_v: string | null) => true;
"update:modelValue": (_v: string | null) => true;
alterar: (_v: string | null) => true;
foco: () => true;
desfoco: () => true;
focus: () => true;
blur: () => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<string | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
modo?: "data" | "dataHora";
limpavel?: boolean;
erro?: boolean;
mensagensErro?: string | string[];
dica?: string;
dicaPersistente?: boolean;
min?: string;
max?: string;
densidade?: import("../..").CampoDensidade;
variante?: import("../..").CampoVariante;
}>;
required: false;
default: undefined;
};
modelValue: {
type: import("vue").PropType<string | null>;
default: null;
};
modo: {
type: import("vue").PropType<"data" | "dataHora" | undefined>;
default: undefined;
};
rotulo: {
type: StringConstructor;
default: undefined;
};
placeholder: {
type: StringConstructor;
default: undefined;
};
desabilitado: {
type: BooleanConstructor;
default: undefined;
};
limpavel: {
type: BooleanConstructor;
default: undefined;
};
erro: {
type: BooleanConstructor;
default: undefined;
};
mensagensErro: {
type: import("vue").PropType<string | string[]>;
default: undefined;
};
dica: {
type: StringConstructor;
default: undefined;
};
dicaPersistente: {
type: BooleanConstructor;
default: undefined;
};
densidade: {
type: import("vue").PropType<import("../..").CampoDensidade>;
default: undefined;
};
variante: {
type: import("vue").PropType<import("../..").CampoVariante>;
default: undefined;
};
min: {
type: import("vue").PropType<string | undefined>;
default: undefined;
};
max: {
type: import("vue").PropType<string | undefined>;
default: undefined;
};
}>> & Readonly<{
onInput?: ((_v: string | null) => any) | undefined;
"onUpdate:value"?: ((_v: string | null) => any) | undefined;
onChange?: ((_v: string | null) => any) | undefined;
"onUpdate:modelValue"?: ((_v: string | null) => any) | undefined;
onAlterar?: ((_v: string | null) => any) | undefined;
onFoco?: (() => any) | undefined;
onDesfoco?: (() => any) | undefined;
onFocus?: (() => any) | undefined;
onBlur?: (() => any) | undefined;
}>, {
modo: "data" | "dataHora" | undefined;
limpavel: boolean;
erro: boolean;
mensagensErro: string | string[];
dica: string;
dicaPersistente: boolean;
min: string | undefined;
max: string | undefined;
densidade: import("../..").CampoDensidade;
variante: import("../..").CampoVariante;
opcoes: {
rotulo: string;
placeholder?: string;
} & {
modo?: "data" | "dataHora";
limpavel?: boolean;
erro?: boolean;
mensagensErro?: string | string[];
dica?: string;
dicaPersistente?: boolean;
min?: string;
max?: string;
densidade?: import("../..").CampoDensidade;
variante?: import("../..").CampoVariante;
};
value: string | null | undefined;
modelValue: string | null;
rotulo: string;
placeholder: string;
desabilitado: boolean;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any> | import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<number | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
sufixo?: string;
prefixo?: string;
precisao?: number;
}>;
required: true;
};
}>, {
attrs: import("vue").Attrs;
emit: ((event: "input", _v: number | null | undefined) => void) & ((event: "update:value", _v: number | null | undefined) => void) & ((event: "change", _v: number | null | undefined) => void) & ((event: "focus") => void) & ((event: "blur") => void);
displayValue: Ref<string, string>;
isInteiro: import("vue").ComputedRef<boolean>;
onUpdateModelValue: (texto: string) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
"update:value": (_v: number | null | undefined) => true;
input: (_v: number | null | undefined) => true;
change: (_v: number | null | undefined) => true;
focus: () => true;
blur: () => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<number | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
sufixo?: string;
prefixo?: string;
precisao?: number;
}>;
required: true;
};
}>> & Readonly<{
onInput?: ((_v: number | null | undefined) => any) | undefined;
"onUpdate:value"?: ((_v: number | null | undefined) => any) | undefined;
onChange?: ((_v: number | null | undefined) => any) | undefined;
onFocus?: (() => any) | undefined;
onBlur?: (() => any) | undefined;
}>, {
value: number | null | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any> | import("vue").DefineComponent<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<string | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
limiteCaracteres?: number;
formato?: "texto" | "email" | "url" | "telefone" | "cpfCnpj" | "cep";
}>;
required: true;
};
}>, {
attrs: import("vue").Attrs;
emit: ((event: "input", _v: string | null | undefined) => void) & ((event: "update:value", _v: string | null | undefined) => void) & ((event: "change", _v: string | null | undefined) => void) & ((event: "focus") => void) & ((event: "blur") => void);
localValue: import("vue").WritableComputedRef<string | null | undefined, string | null | undefined>;
inputHtmlType: import("vue").ComputedRef<"text" | "email" | "url">;
inputMode: import("vue").ComputedRef<string | undefined>;
onInput: (e: Event) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
"update:value": (_v: string | null | undefined) => true;
input: (_v: string | null | undefined) => true;
change: (_v: string | null | undefined) => true;
focus: () => true;
blur: () => true;
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
value: {
type: import("vue").PropType<string | null | undefined>;
default: undefined;
};
opcoes: {
type: import("vue").PropType<{
rotulo: string;
placeholder?: string;
} & {
limiteCaracteres?: number;
formato?: "texto" | "email" | "url" | "telefone" | "cpfCnpj" | "cep";
}>;
required: true;
};
}>> & Readonly<{
onInput?: ((_v: string | null | undefined) => any) | undefined;
"onUpdate:value"?: ((_v: string | null | undefined) => any) | undefined;
onChange?: ((_v: string | null | undefined) => any) | undefined;
onFocus?: (() => any) | undefined;
onBlur?: (() => any) | undefined;
}>, {
value: string | null | undefined;
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
opcoesEntrada: (entrada: ComponenteEntrada) => {
rotulo: string;
};
adicionar: () => void;
remover: (idx: number) => void;
emitFechar: () => void;
emitSalvar: () => void;
emitLimpar: () => void;
rotuloDoFiltro: typeof rotuloDoFiltro;
rotuloDoFiltroAtivo: (chave: string) => string;
};
export {};

View file

@ -1,10 +1,11 @@
import type { Plugin } from "vue";
import "./styles/eli-vue-fonts.css";
import "./design-tokens.css";
import { EliBotao } from "./componentes/botao";
import { EliCartao } from "./componentes/cartao";
import { EliBadge } from "./componentes/indicador";
import { EliOlaMundo } from "./componentes/ola_mundo";
export { criarFiltro26 } from "p-comuns";
export { iniciarFiltroDelta } from "p-comuns";
export * from "./componentes/EliEntrada";
export * from "./componentes/EliTabela";
export * from "./tipos";

View file

@ -1,6 +1,6 @@
{
"name": "eli-vue",
"version": "0.1.144",
"version": "0.1.183",
"private": false,
"main": "./dist/eli-vue.umd.js",
"module": "./dist/eli-vue.es.js",
@ -15,7 +15,7 @@
"scripts": {
"biome": "pnpm exec biome check --write",
"check": "pnpm run biome && npx tsc --noEmit",
"prebuild": "npm version patch --no-git-tag-version",
"prebuild": "npm version patch --no-git-tag-version && bash scripts/check-linhas.sh",
"build:types": "vue-tsc -p tsconfig.build.json --declaration --emitDeclarationOnly",
"build": "pnpm run biome && vite build && npm run build:types",
"dev": "pnpm run biome && pnpm run fechar && vite",
@ -41,6 +41,7 @@
},
"dependencies": {
"@lucide/vue": "^1.16.0",
"alasql": "^4.17.3",
"cross-fetch": "^4.1.0",
"dayjs": "^1.11.19",
"p-comuns": "git+https://git2.idz.one/publico/_comuns.git",

2728
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

49
scripts/check-linhas.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# check-linhas.sh
# Garante que nenhum arquivo *.vue, *.ts ou *.tsx ultrapasse o limite de linhas definido.
# Executado automaticamente no prebuild via package.json.
set -euo pipefail
LIMITE=250
# Arquivos isentos permanentes (gerados, dados de teste, etc.)
# Formato: caminho relativo a partir da raiz do projeto
ISENTOS=(
)
RAIZ="$(cd "$(dirname "$0")/.." && pwd)"
ERROS=0
while IFS= read -r arquivo; do
relativo="${arquivo#$RAIZ/}"
# Verifica se está na lista de isentos
isento=false
for i in "${ISENTOS[@]}"; do
if [ "$relativo" = "$i" ]; then
isento=true
break
fi
done
if $isento; then
continue
fi
linhas=$(wc -l < "$arquivo")
if [ "$linhas" -gt "$LIMITE" ]; then
echo "❌ $relativo ($linhas linhas) — limite: $LIMITE"
ERROS=$((ERROS + 1))
fi
done < <(find "$RAIZ/src" \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" \) -not -path "*/node_modules/*")
if [ "$ERROS" -gt 0 ]; then
echo ""
echo "⚠️ $ERROS arquivo(s) ultrapassam o limite de $LIMITE linhas."
echo " Estratégias: subcomponentes, composables use*.ts, ou separar CSS."
exit 1
fi
echo "✅ Todos os arquivos (*.vue, *.ts, *.tsx) estão dentro do limite de $LIMITE linhas."

59
scripts/check-vue-linhas.sh Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env bash
# check-vue-linhas.sh
# Garante que nenhum arquivo *.vue ultrapasse o limite de linhas definido.
#
# Arquivos listados em ISENTOS são legados em processo de refatoração —
# cada um deve ser quebrado em componentes menores antes de ser removido daqui.
set -euo pipefail
LIMITE=250
# Todos os arquivos *.vue devem respeitar o limite.
# Não existem isentos.
ISENTOS=()
RAIZ="$(cd "$(dirname "$0")/.." && pwd)"
ERROS=0
# Constrói padrão grep para os isentos
isentos_grep=""
for isento in "${ISENTOS[@]}"; do
if [ -n "$isentos_grep" ]; then
isentos_grep="${isentos_grep}\|${isento}"
else
isentos_grep="${isento}"
fi
done
while IFS= read -r arquivo; do
relativo="${arquivo#$RAIZ/}"
# Verifica se está na lista de isentos
isento=false
for i in "${ISENTOS[@]}"; do
if [ "$relativo" = "$i" ]; then
isento=true
break
fi
done
if $isento; then
continue
fi
linhas=$(wc -l < "$arquivo")
if [ "$linhas" -gt "$LIMITE" ]; then
echo "❌ $relativo ($linhas linhas) — limite: $LIMITE"
ERROS=$((ERROS + 1))
fi
done < <(find "$RAIZ/src" -name "*.vue" -not -path "*/node_modules/*")
if [ "$ERROS" -gt 0 ]; then
echo ""
echo "⚠️ $ERROS arquivo(s) Vue ultrapassam o limite de $LIMITE linhas."
echo " Quebre em componentes menores antes de fazer build."
exit 1
fi
echo "✅ Todos os arquivos Vue estão dentro do limite de $LIMITE linhas."

View file

@ -23,7 +23,7 @@
</template>
<script lang="ts">
import { dayjsbr } from "p-comuns"
import dayjs from "dayjs"
import { computed, defineComponent, type PropType } from "vue"
import type { CampoDensidade, CampoVariante } from "../../tipos"
import type { PadroesEntradas } from "./tiposEntradas"
@ -122,13 +122,9 @@ export default defineComponent({
}
})
const modoEfetivo = computed<"data" | "dataHora">(
() => opcoesEfetivas.value.modo ?? "dataHora",
)
const modoEfetivo = computed<"data" | "dataHora">(() => opcoesEfetivas.value.modo ?? "dataHora")
const desabilitadoEfetivo = computed<boolean>(() =>
Boolean(props.desabilitado),
)
const desabilitadoEfetivo = computed<boolean>(() => Boolean(props.desabilitado))
const tipoInput = computed<"date" | "datetime-local">(() =>
modoEfetivo.value === "data" ? "date" : "datetime-local",
@ -136,23 +132,21 @@ export default defineComponent({
function isoParaInputDatetime(valorIso: string): string {
if (modoEfetivo.value === "data") {
return dayjsbr(valorIso).format("YYYY-MM-DD")
return dayjs(valorIso).format("YYYY-MM-DD")
}
return dayjsbr(valorIso).format("YYYY-MM-DDTHH:mm")
return dayjs(valorIso).format("YYYY-MM-DDTHH:mm")
}
function inputDatetimeParaIsoLocal(valorInput: string): string {
if (modoEfetivo.value === "data") {
return dayjsbr(`${valorInput}T00:00`).format()
return dayjs(`${valorInput}T00:00`).format()
}
return dayjsbr(valorInput).format()
return dayjs(valorInput).format()
}
const effectiveModelValue = computed<string | null>(() => {
// Prioridade: value (novo) se vier definido; senão usa modelValue (antigo)
return props.value !== undefined
? (props.value ?? null)
: props.modelValue
return props.value !== undefined ? (props.value ?? null) : props.modelValue
})
const valor = computed<string>({

View file

@ -0,0 +1,14 @@
.eli-entrada__prefixo,
.eli-entrada__sufixo {
opacity: 0.75;
font-size: 0.9em;
white-space: nowrap;
}
.eli-entrada__prefixo {
margin-right: 6px;
}
.eli-entrada__sufixo {
margin-left: 6px;
}

View file

@ -0,0 +1,112 @@
/**
* Helpers de formatação e parsing numérico usados pelo EliEntradaNumero.
* Extraídos do componente Vue para manter o arquivo dentro de 250 linhas.
*/
export function casasDecimaisFromPrecisao(precisao: number): number {
if (!Number.isFinite(precisao) || precisao <= 0) return 0
if (precisao >= 1) return 0
const texto = precisao.toString()
if (texto.includes("e-")) {
const [, exp] = texto.split("e-")
const n = Number(exp)
return Number.isFinite(n) ? n : 0
}
const idx = texto.indexOf(".")
if (idx === -1) return 0
const dec = texto.slice(idx + 1).replace(/0+$/, "")
return dec.length
}
/**
* Analisa a entrada de texto e converte em um valor numérico válido (float).
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
*
* @param texto - O texto bruto digitado ou vindo do estado
* @returns O número processado ou `null` caso inválido ou vazio
*/
export function parseNumero(texto: string): number | null {
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
if (!normalizado) return null
const n = Number(normalizado)
if (Number.isNaN(n)) return null
return n
}
/**
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
*
* @param value - O valor numérico de fato emitido
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
* @returns String formatada amigável para exibição em pt-BR
*/
export function formatarNumero(value: number | null | undefined, casas: number | null) {
if (value === null || value === undefined) return ""
if (casas === null) return String(value).replace(/\./g, ",")
const fixed = Number(value).toFixed(Math.max(0, casas))
return fixed.replace(/\./g, ",")
}
/**
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
*
* @param texto - String de entrada
* @returns Apenas dígitos de 0 a 9
*/
export function somenteNumeros(texto: string) {
return (texto ?? "").replace(/\D+/g, "")
}
/**
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
*
* @param texto - Texto misto de entrada
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
*/
export function somenteNumerosESeparadorDecimal(texto: string) {
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
const matchSep = limpo.match(/[.,]/)
if (!matchSep) return limpo
const sep = matchSep[0]
const idx = limpo.indexOf(sep)
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
const antes = antesRaw.length ? antesRaw : "0"
return `${antes}${sep}${depois}`
}
/**
* Trunca a parte decimal de um texto numérico ao número máximo de casas permitidas.
* Aplicado durante a digitação para evitar que o usuário ultrapasse a precisão configurada.
*
* @param texto - Texto numérico já higienizado (com separador único)
* @param casas - Número máximo de casas decimais; `null` = sem limite
* @returns Texto com a parte decimal truncada no limite especificado
*/
export function limitarCasasDecimais(texto: string, casas: number | null) {
if (casas === null) return texto
if (casas <= 0) return texto.replace(/[.,]/g, "")
const matchSep = texto.match(/[.,]/)
if (!matchSep) return texto
const sep = matchSep[0]
const idx = texto.indexOf(sep)
const inteiro = texto.slice(0, idx)
const frac = texto.slice(idx + 1)
return `${inteiro}${sep}${frac.slice(0, casas)}`
}
/**
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
*
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
* @returns A conversão parcial estrita ou `null` caso falhe
*/
export function valorParcialDecimal(texto: string): number | null {
const m = texto.match(/^(\d+)[.,]$/)
if (!m) return null
const n = Number(m[1])
return Number.isNaN(n) ? null : n
}

View file

@ -27,126 +27,19 @@
<script lang="ts">
import { computed, defineComponent, type PropType, ref, watch } from "vue"
import type { PadroesEntradas } from "./tiposEntradas"
import "./EliEntradaNumero.css"
import {
casasDecimaisFromPrecisao,
formatarNumero,
limitarCasasDecimais,
parseNumero,
somenteNumeros,
somenteNumerosESeparadorDecimal,
valorParcialDecimal,
} from "./EliEntradaNumero.helpers"
type EntradaNumero = PadroesEntradas["numero"]
function casasDecimaisFromPrecisao(precisao: number): number {
if (!Number.isFinite(precisao) || precisao <= 0) return 0
if (precisao >= 1) return 0
// Preferência por contar casas decimais na representação (ex.: 0.01 -> 2)
const texto = precisao.toString()
if (texto.includes("e-")) {
const [, exp] = texto.split("e-")
const n = Number(exp)
return Number.isFinite(n) ? n : 0
}
const idx = texto.indexOf(".")
if (idx === -1) return 0
const dec = texto.slice(idx + 1).replace(/0+$/, "")
return dec.length
}
/**
* Analisa a entrada de texto e converte em um valor numérico válido (float).
* Troca a vírgula brasileira por ponto antes de passar para o parser nativo.
*
* @param texto - O texto bruto digitado ou vindo do estado
* @returns O número processado ou `null` caso inválido ou vazio
*/
function parseNumero(texto: string): number | null {
const normalizado = (texto ?? "").trim().replace(/,/g, ".")
if (!normalizado) return null
const n = Number(normalizado)
if (Number.isNaN(n)) return null
return n
}
/**
* Formata um valor numérico para a exibição na interface (usando vírgula como separador).
* Em caso de precisão nula (modo livre), preserva a semântica de exibição direta sem forçar o preenchimento de zeros (toFixed).
*
* @param value - O valor numérico de fato emitido
* @param casas - Opcionalmente, o número exato de casas decimais para fixar
* @returns String formatada amigável para exibição em pt-BR
*/
function formatarNumero(
value: number | null | undefined,
casas: number | null,
) {
if (value === null || value === undefined) return ""
if (casas === null) return String(value).replace(/\./g, ",")
// Garantimos que o texto visual corresponda ao value (fixed).
const fixed = Number(value).toFixed(Math.max(0, casas))
// Exibe com vírgula (pt-BR)
return fixed.replace(/\./g, ",")
}
/**
* Retira todo tipo de sujeira de uma string, devolvendo restritamente números (para inteiros).
*
* @param texto - String de entrada
* @returns Apenas dígitos de 0 a 9
*/
function somenteNumeros(texto: string) {
return (texto ?? "").replace(/\D+/g, "")
}
/**
* Higieniza o texto preservando apenas os dígitos e exatamente UM separador decimal.
* Colapsa múltiplos separadores e normaliza se o separador vier no início (",4" => "0,4").
*
* @param texto - Texto misto de entrada
* @returns String contendo número e (opcionalmente) uma vírgula ou ponto residual
*/
function somenteNumerosESeparadorDecimal(texto: string) {
// Mantém apenas números e UM separador decimal ("," ou "."), sem sinais.
// Implementação baseada em regex + colapso de separadores.
const limpo = (texto ?? "").replace(/[^0-9.,]+/g, "")
const matchSep = limpo.match(/[.,]/)
if (!matchSep) return limpo
const sep = matchSep[0]
const idx = limpo.indexOf(sep)
const antesRaw = limpo.slice(0, idx).replace(/[.,]/g, "")
const depois = limpo.slice(idx + 1).replace(/[.,]/g, "")
// Se o usuário começa pelo separador (",4"), normalizamos para "0,4".
const antes = antesRaw.length ? antesRaw : "0"
return `${antes}${sep}${depois}`
}
function limitarCasasDecimais(texto: string, casas: number | null) {
if (casas === null) return texto
if (casas <= 0) return texto.replace(/[.,]/g, "")
const matchSep = texto.match(/[.,]/)
if (!matchSep) return texto
const sep = matchSep[0]
const idx = texto.indexOf(sep)
const inteiro = texto.slice(0, idx)
const frac = texto.slice(idx + 1)
return `${inteiro}${sep}${frac.slice(0, casas)}`
}
/**
* Resolve o edge-case de digitação transitória parcial terminada em separador (ex: "1,").
* Identifica se a string tem este formato pendente e retorna temporariamente sua base inteira
* sem permitir que o valor se torne nulo.
*
* @param texto - O texto já higienizado, tipicamente terminando em vírgula ou ponto
* @returns A conversão parcial estrita ou `null` caso falhe
*/
function valorParcialDecimal(texto: string): number | null {
// Regra (B): se o usuário digitou "1," ou "1." emite 1.
const m = texto.match(/^(\d+)[.,]$/)
if (!m) return null
const n = Number(m[1])
return Number.isNaN(n) ? null : n
}
export default defineComponent({
name: "EliEntradaNumero",
inheritAttrs: false,
@ -229,12 +122,8 @@ export default defineComponent({
}
// Modo livre (sem precisao) ou inteiro: aceita números e 1 separador (no caso livre)
const base = isInteiro.value
? somenteNumeros(texto)
: somenteNumerosESeparadorDecimal(texto)
const textoFiltrado = isInteiro.value
? base
: limitarCasasDecimais(base, casasDecimais.value)
const base = isInteiro.value ? somenteNumeros(texto) : somenteNumerosESeparadorDecimal(texto)
const textoFiltrado = isInteiro.value ? base : limitarCasasDecimais(base, casasDecimais.value)
// Emissão:
// - vazio => null
@ -242,9 +131,7 @@ export default defineComponent({
// - caso geral => parse normal
let out: EntradaNumero["value"] = null
if (textoFiltrado) {
const parcial = isInteiro.value
? null
: valorParcialDecimal(textoFiltrado)
const parcial = isInteiro.value ? null : valorParcialDecimal(textoFiltrado)
const n = parcial ?? parseNumero(textoFiltrado)
out = (n === null ? null : n) as EntradaNumero["value"]
}
@ -269,19 +156,3 @@ export default defineComponent({
})
</script>
<style>
.eli-entrada__prefixo,
.eli-entrada__sufixo {
opacity: 0.75;
font-size: 0.9em;
white-space: nowrap;
}
.eli-entrada__prefixo {
margin-right: 6px;
}
.eli-entrada__sufixo {
margin-left: 6px;
}
</style>

View file

@ -23,14 +23,7 @@
</template>
<script lang="ts">
import {
computed,
defineComponent,
onMounted,
type PropType,
ref,
watch,
} from "vue"
import { computed, defineComponent, onMounted, type PropType, ref, watch } from "vue"
import { VSelect } from "vuetify/components"
import type { PadroesEntradas } from "./tiposEntradas"

View file

@ -65,8 +65,7 @@ export default defineComponent({
const inputMode = computed<string | undefined>(() => {
if (formato.value === "telefone") return "tel"
if (formato.value === "cpfCnpj" || formato.value === "cep")
return "numeric"
if (formato.value === "cpfCnpj" || formato.value === "cep") return "numeric"
return undefined
})

View file

@ -7,6 +7,16 @@ import EliEntradaTexto from "./EliEntradaTexto.vue"
import type { TipoEntrada } from "./tiposEntradas"
/**
* Mapa (dispatch table) que associa cada `TipoEntrada` ao seu componente Vue correspondente.
* Usado pelo sistema de filtro avançado para renderizar dinamicamente o campo correto
* a partir da configuração declarada em `tipoEliTabelaConsulta.filtroAvancado[].entrada`.
*
* Adicionar um novo tipo de entrada requer:
* 1. Criar o componente Vue
* 2. Adicionar a chave em `TipoEntrada` (tiposEntradas.ts)
* 3. Registrar aqui no mapa
*/
export const registryTabelaCelulas = {
texto: EliEntradaTexto,
numero: EliEntradaNumero,

View file

@ -2,6 +2,14 @@ function somenteNumeros(v: string): string {
return v.replace(/\D+/g, "")
}
/**
* Formata um CPF ou CNPJ aplicando a máscara brasileira padrão durante a digitação.
* Detecta automaticamente o tipo pelo comprimento (≤11 dígitos = CPF, >11 = CNPJ).
* Não valida o dígito verificador — apenas aplica a formatação visual.
*
* @param v - Valor bruto (pode conter letras, pontos, traços etc.)
* @returns String formatada no padrão `000.000.000-00` (CPF) ou `00.000.000/0000-00` (CNPJ)
*/
export function formatarCpfCnpj(v: string): string {
const d = somenteNumeros(v)

View file

@ -2,6 +2,20 @@
width: 100%;
}
.eli-tabela__table-wrapper {
width: 100%;
overflow-x: auto;
}
.eli-tabela__td--estados {
padding: 0;
}
.eli-tabela__estados-inline {
border: none;
border-radius: 0;
}
.eli-tabela__table {
width: 100%;
border-collapse: separate;
@ -29,21 +43,30 @@
overflow: visible;
}
.eli-tabela__tbody .eli-tabela__tr .eli-tabela__td {
background: var(--cor-eli-superficie, #ffffff);
}
.eli-tabela__tbody .eli-tabela__tr--zebra .eli-tabela__td {
background: rgba(15, 23, 42, 0.02);
background: var(--cor-eli-primaria-zebra, #F6F9E8);
}
.eli-tabela__th,
.eli-tabela__td {
padding: 10px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
border-bottom: 1px solid var(--cor-eli-borda);
vertical-align: top;
}
.eli-tabela__th {
text-align: left;
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
font-size: 0.82rem;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--cor-eli-neutro-escuro, #3E4436);
background: var(--cor-eli-primaria-suave, #BFC99C);
border-bottom: 2px solid var(--cor-eli-primaria-bg, #EEF3D0);
}
.eli-tabela__th--ordenavel {
@ -71,16 +94,16 @@
.eli-tabela__th-botao:hover,
.eli-tabela__th-botao:focus-visible {
color: rgba(15, 23, 42, 0.85);
color: var(--cor-eli-primaria-texto, #2E3F00);
}
.eli-tabela__th-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria, #688C00);
outline-offset: 2px;
}
.eli-tabela__th-botao--ativo {
color: rgba(15, 23, 42, 0.85);
color: var(--cor-eli-primaria-texto, #2E3F00);
}
.eli-tabela__th-texto {
@ -110,14 +133,19 @@
}
.eli-tabela--erro {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid rgba(220, 53, 69, 0.35);
border-radius: 12px;
padding: 12px;
}
.eli-tabela--carregando {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
@ -134,12 +162,33 @@
}
.eli-tabela--vazio {
display: flex;
align-items: center;
gap: 10px;
border: 1px dashed rgba(0, 0, 0, 0.25);
border-radius: 12px;
padding: 12px;
opacity: 0.8;
}
.eli-tabela__estado-icone {
flex-shrink: 0;
color: rgba(15, 23, 42, 0.45);
}
.eli-tabela__estado-icone--erro {
color: var(--cor-eli-erro);
}
.eli-tabela__estado-icone--spin {
animation: eli-tabela-spin 1s linear infinite;
}
@keyframes eli-tabela-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.eli-tabela__th--acoes {
text-align: right;
white-space: nowrap;
@ -222,8 +271,8 @@
padding: 0 14px;
border-radius: var(--eli-tabela-cabecalho-controle-radius);
border: none;
background: rgba(37, 99, 235, 0.12);
color: rgba(37, 99, 235, 0.95);
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria-escura);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
@ -233,11 +282,11 @@
.eli-tabela__acoes-cabecalho-botao:hover,
.eli-tabela__acoes-cabecalho-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
background: var(--cor-eli-primaria-clara);
}
.eli-tabela__acoes-cabecalho-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -274,7 +323,7 @@
}
.eli-tabela__acoes-toggle:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -289,7 +338,7 @@
padding: 6px 0;
margin: 0;
list-style: none;
background: #ffffff;
background: var(--cor-eli-superficie);
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
@ -365,7 +414,7 @@
}
.eli-tabela__expander-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
@ -445,8 +494,8 @@
.eli-tabela__checkbox:checked,
.eli-tabela__checkbox:indeterminate {
background-color: #1976d2;
border-color: #1976d2;
background-color: var(--cor-eli-primaria);
border-color: var(--cor-eli-primaria);
}
.eli-tabela__checkbox:checked::after {
@ -473,6 +522,6 @@
}
.eli-tabela__checkbox:focus-visible {
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.3);
box-shadow: 0 0 0 2px var(--cor-eli-primaria-clara);
}

View file

@ -31,57 +31,60 @@
<EliTabelaModalFiltroAvancado
:aberto="modalFiltroAberto"
:filtrosBase="tabela.filtroAvancado ?? []"
:filtrosBase="(tabela.filtroAvancado ?? []) as FiltroBaseModal[]"
:modelo="filtrosUi"
:modeloFormula="filtrosFormula"
@fechar="fecharModalFiltro"
@limpar="limparFiltrosAvancados"
@salvar="salvarFiltrosAvancados"
/>
<table class="eli-tabela__table">
<EliTabelaHead
:colunas="colunasEfetivas"
:temAcoes="temAcoes"
:temColunasInvisiveis="temColunasInvisiveis"
:colunaOrdenacao="colunaOrdenacao"
:direcaoOrdenacao="direcaoOrdenacao"
:mostrarSelecao="mostrarSelecao"
:todosSelecionados="todosSelecionados"
:algunsSelecionados="algunsSelecionados"
@alternar-ordenacao="alternarOrdenacao"
@alternar-selecao-todos="alternarSelecaoTodos"
/>
<div class="eli-tabela__table-wrapper">
<table class="eli-tabela__table">
<EliTabelaHead
:colunas="colunasEfetivas"
:temAcoes="temAcoes"
:temColunasInvisiveis="temColunasInvisiveis"
:colunaOrdenacao="colunaOrdenacao"
:direcaoOrdenacao="direcaoOrdenacao"
:mostrarSelecao="mostrarSelecao"
:todosSelecionados="todosSelecionados"
:algunsSelecionados="algunsSelecionados"
@alternar-ordenacao="alternarOrdenacao"
@alternar-selecao-todos="alternarSelecaoTodos"
/>
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
<tr>
<td :colspan="totalColunas" style="padding: 0;">
<EliTabelaEstados
:carregando="carregando"
:erro="erro"
:mensagemVazio="tabela.mensagemVazio"
style="border: none; border-radius: 0;"
/>
</td>
</tr>
</tbody>
<tbody v-if="carregando || Boolean(erro) || !linhas.length">
<tr>
<td :colspan="totalColunas" class="eli-tabela__td--estados">
<EliTabelaEstados
:carregando="carregando"
:erro="erro"
:mensagemVazio="tabela.mensagemVazio"
class="eli-tabela__estados-inline"
/>
</td>
</tr>
</tbody>
<EliTabelaBody
v-else
:colunas="colunasEfetivas"
:colunasInvisiveis="colunasInvisiveisEfetivas"
:temColunasInvisiveis="temColunasInvisiveis"
:linhasExpandidas="linhasExpandidas"
:linhas="linhasPaginadas"
:temAcoes="temAcoes"
:menuAberto="menuAberto"
:possuiAcoes="possuiAcoes"
:toggleMenu="toggleMenu"
:alternarLinhaExpandida="alternarLinhaExpandida"
:mostrarSelecao="mostrarSelecao"
:isSelecionado="isSelecionado"
:alternarSelecao="alternarSelecao"
/>
</table>
<EliTabelaBody
v-else
:colunas="colunasEfetivas"
:colunasInvisiveis="colunasInvisiveisEfetivas"
:temColunasInvisiveis="temColunasInvisiveis"
:linhasExpandidas="linhasExpandidas"
:linhas="linhasPaginadas"
:temAcoes="temAcoes"
:menuAberto="menuAberto"
:possuiAcoes="possuiAcoes"
:toggleMenu="toggleMenu"
:alternarLinhaExpandida="alternarLinhaExpandida"
:mostrarSelecao="mostrarSelecao"
:isSelecionado="isSelecionado"
:alternarSelecao="alternarSelecao"
/>
</table>
</div>
<EliTabelaMenuAcoes
ref="menuPopup"
@ -108,28 +111,16 @@
/**
* EliTabela
* Componente de tabela consultável com busca, paginação, ordenação e ações por linha.
* O setup() é orquestrado por `useEliTabela`, que compõe os seguintes sub-composables:
* - useEliTabelaConsulta — carregamento, paginação, ordenação, busca
* - useEliTabelaFiltrosAvancados — filtros avançados, modal e persistência
* - useEliTabelaSelecao — seleção múltipla de linhas
* - useEliTabelaColunas — visibilidade, ordem e modal de colunas
* - useEliTabelaMenu — menu de ações por linha e posicionamento de popup
*/
/** Enum de códigos de resposta utilizado na consulta */
import { codigosResposta } from "p-respostas"
/** Dependências do Vue (Composition API) */
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
type PropType,
ref,
watch,
} from "vue"
// operadores usados no tipo de configuração; o operador aplicado vem travado no filtroAvancado.
import {
carregarConfigColunas,
type EliTabelaColunasConfig,
salvarConfigColunas,
} from "./colunasStorage"
import { defineComponent, type PropType } from "vue"
import "./EliTabela.css"
import EliTabelaBody from "./EliTabelaBody.vue"
/** Componentes auxiliares */
import EliTabelaCabecalho from "./EliTabelaCabecalho.vue"
import EliTabelaDebug from "./EliTabelaDebug.vue"
import EliTabelaEstados from "./EliTabelaEstados.vue"
@ -138,18 +129,12 @@ import EliTabelaMenuAcoes from "./EliTabelaMenuAcoes.vue"
import EliTabelaModalColunas from "./EliTabelaModalColunas.vue"
import EliTabelaModalFiltroAvancado from "./EliTabelaModalFiltroAvancado.vue"
import EliTabelaPaginacao from "./EliTabelaPaginacao.vue"
import {
carregarFiltroAvancado,
limparFiltroAvancado,
salvarFiltroAvancado,
} from "./filtroAvancadoStorage"
/** Tipos da configuração/contrato da tabela */
import type {
parametrosConsulta,
tipoEliColuna,
tipoEliTabelaConsulta,
tipoFiltro,
} from "./types-eli-tabela"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
import { useEliTabela } from "./useEliTabela"
import type { FiltroBase } from "./useFiltroAvancado"
// biome-ignore lint/correctness/noUnusedVariables: tipo usado no template SFC no cast :filtrosBase
type FiltroBaseModal = FiltroBase<string>
export default defineComponent({
name: "EliTabela",
@ -170,821 +155,22 @@ export default defineComponent({
tabela: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic table
type: Object as PropType<tipoEliTabelaConsulta<any>>,
required: true,
required: true as const,
},
/** Array de itens selecionados (v-model:selecionados) */
selecionados: {
type: Array as PropType<unknown[]>,
default: () => [],
},
/** Ocultar painel de debug lateral (EliTabelaDebug) */
esconderDebug: {
type: Boolean,
default: false,
},
},
emits: ["update:selecionados"],
setup(props, { emit }) {
/** Flag para habilitar elementos de debug */
const isDev = import.meta.env.DEV
/** Estados de carregamento/erro e dados retornados */
const carregando = ref(false)
const erro = ref<string | null>(null)
const linhas = ref<unknown[]>([])
const quantidade = ref<number>(0)
/** Controle de visibilidade das ações por linha */
const acoesVisiveis = ref<boolean[][]>([])
/** Estado do menu de ações (aberto, elemento e posição) */
const menuAberto = ref<number | null>(null)
// O componente EliTabelaMenuAcoes expõe `menuEl` (ref do elemento <ul>)
const menuPopup = ref<{ menuEl: { value: HTMLElement | null } } | null>(
null,
)
const menuPopupPos = ref({ top: 0, left: 0 })
/** Filtros e ordenação */
const valorBusca = ref<string>("")
const paginaAtual = ref(1)
const colunaOrdenacao = ref<string | null>(null)
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
/** Filtro avançado (config + estado modal) */
const modalFiltroAberto = ref(false)
type LinhaFiltroUI = {
chave: string
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
valor: any
}
const filtrosUi = ref<Array<LinhaFiltroUI>>(
// biome-ignore lint/suspicious/noExplicitAny: dynamic load
carregarFiltroAvancado<any>(props.tabela.nome) as any,
)
function abrirModalFiltro() {
modalFiltroAberto.value = true
}
function fecharModalFiltro() {
modalFiltroAberto.value = false
}
function limparFiltrosAvancados() {
filtrosUi.value = []
limparFiltroAvancado(props.tabela.nome)
modalFiltroAberto.value = false
// Se o usuário estiver usando filtro avançado, a busca deixa de ter efeito.
// Mantemos a regra combinatória (busca tem prioridade), então limpamos a busca.
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
function salvarFiltrosAvancados(novo: any[]) {
// biome-ignore lint/suspicious/noExplicitAny: dynamic ui
filtrosUi.value = (novo ?? []) as any
// biome-ignore lint/suspicious/noExplicitAny: dynamic storage
salvarFiltroAvancado(props.tabela.nome, (novo ?? []) as any)
modalFiltroAberto.value = false
// Ao aplicar filtros, limpamos a busca para garantir que os filtros sejam efetivos.
// (busca tem prioridade sobre filtros)
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
// biome-ignore lint/suspicious/noExplicitAny: generic
const filtrosAvancadosAtivos = computed<tipoFiltro<any>[]>(() => {
const base = props.tabela.filtroAvancado ?? []
return (
(filtrosUi.value ?? [])
.filter((f) => f && f.chave !== undefined)
.map((f) => {
const b = base.find((x) => String(x.chave) === String(f.chave))
if (!b) return null
// O filtro já vem pronto da função definida na configuração
return b.filtro(f.valor)
})
// biome-ignore lint/suspicious/noExplicitAny: generic
.filter(Boolean) as tipoFiltro<any>[]
)
})
/** Alias reativo da prop tabela */
const tabela = computed(() => props.tabela)
/** Seleção Múltipla */
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
const chaveSelecao = computed(
() => props.tabela.chave_selecao as string | undefined,
)
function obterChaveLinha(linha: unknown): unknown {
if (chaveSelecao.value && linha && typeof linha === "object") {
return (linha as Record<string, unknown>)[chaveSelecao.value]
}
return linha
}
function isSelecionado(linha: unknown): boolean {
const chave = obterChaveLinha(linha)
if (chaveSelecao.value) {
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
}
return props.selecionados.includes(linha)
}
function alternarSelecao(linha: unknown) {
const atual = [...props.selecionados]
const chave = obterChaveLinha(linha)
let index = -1
if (chaveSelecao.value) {
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
} else {
index = atual.indexOf(linha)
}
if (index === -1) {
atual.push(linha)
} else {
atual.splice(index, 1)
}
emit("update:selecionados", atual)
}
const todosSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
})
const algunsSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return (
linhasPaginadas.value.some((linha) => isSelecionado(linha)) &&
!todosSelecionados.value
)
})
function alternarSelecaoTodos(forcarMarcar?: boolean) {
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
let atual = [...props.selecionados]
if (marcarTodos) {
// Adiciona os que faltam na página atual
for (const linha of linhasPaginadas.value) {
if (!isSelecionado(linha)) {
atual.push(linha)
}
}
} else {
// Remove os da página atual
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
if (chaveSelecao.value) {
atual = atual.filter(
(s) => !chavesPagina.includes(obterChaveLinha(s)),
)
} else {
atual = atual.filter((s) => !chavesPagina.includes(s))
}
}
emit("update:selecionados", atual)
}
/** Exibição da busca e ações do cabeçalho */
const exibirBusca = computed(() =>
Boolean(props.tabela.mostrarCaixaDeBusca),
)
const acoesSuperiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
)
const acoesInferiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
)
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
const parametrosConsultaAtuais = computed(() => {
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
const params: parametrosConsulta<any> = {
offSet: offset,
limit: limite,
}
const busca = (valorBusca.value ?? "").trim()
if (busca) {
params.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) params.filtros = filtros
}
if (colunaOrdenacao.value) {
params.coluna_ordem = colunaOrdenacao.value
params.direcao_ordem = direcaoOrdenacao.value
}
return {
...params,
atualizarConsulta: async () => {
await carregar()
},
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
editarLista: async (novaLista: any[]) => {
linhas.value = novaLista
return novaLista
},
}
})
/** Colunas: visibilidade/ordem com persistência */
const modalColunasAberto = ref(false)
const configColunas = ref<EliTabelaColunasConfig>(
carregarConfigColunas(props.tabela.nome),
)
/** Linhas expandidas (para exibir colunas invisíveis) */
const linhasExpandidas = ref<Record<number, boolean>>({})
const rotulosColunas = computed(() =>
props.tabela.colunas.map((c) => c.rotulo),
)
const colunasInvisiveisEfetivas = computed(() => {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const colunas = props.tabela.colunas as Array<tipoEliColuna<any>>
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
// ordenação: usa a lista (salva ou derivada do default) e adiciona novas ao final
const ordemSalva = invisiveisBaseRotulos
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, tipoEliColuna<any>>()
for (const c of base) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const ordenadas: Array<tipoEliColuna<any>> = []
for (const r of ordemSalva) {
const c = mapa.get(r)
if (c) ordenadas.push(c)
}
for (const c of base) {
if (!ordenadas.includes(c)) ordenadas.push(c)
}
return ordenadas
})
const temColunasInvisiveis = computed(
() => colunasInvisiveisEfetivas.value.length > 0,
)
const colunasEfetivas = computed(() => {
const colunas = props.tabela.colunas
const todosRotulos = rotulosColunas.value
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
(props.tabela.colunas as Array<tipoEliColuna<any>>)
.filter((c) => c.visivel === false)
.map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
// base visiveis: remove invisiveis
const visiveisBaseRotulos = todosRotulos.filter(
(r) => !invisiveisSet.has(r),
)
const visiveisSet = new Set(visiveisBaseRotulos)
// aplica ordem salva; novas (sem definicao) entram no fim, respeitando ordem original
const ordemSalva = configTemDados
? (configColunas.value.visiveis ?? [])
: []
const ordemFinal: string[] = []
for (const r of ordemSalva) {
if (visiveisSet.has(r)) ordemFinal.push(r)
}
for (const r of visiveisBaseRotulos) {
if (!ordemFinal.includes(r)) ordemFinal.push(r)
}
// mapeia rótulo -> coluna, preservando duplicatas (se existirem) pelo primeiro match.
// OBS: pressupoe rotulo unico; se repetir, comportamento fica indefinido.
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, any>()
for (const c of colunas) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
})
const totalColunas = computed(() => {
return (
colunasEfetivas.value.length +
(temAcoes.value ? 1 : 0) +
(temColunasInvisiveis.value ? 1 : 0)
)
})
function abrirModalColunas() {
modalColunasAberto.value = true
}
function fecharModalColunas() {
modalColunasAberto.value = false
}
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
configColunas.value = cfg
salvarConfigColunas(props.tabela.nome, cfg)
modalColunasAberto.value = false
// ao mudar colunas, fecha detalhes expandidos
linhasExpandidas.value = {}
}
function alternarLinhaExpandida(indice: number) {
const atual = Boolean(linhasExpandidas.value[indice])
linhasExpandidas.value = {
...linhasExpandidas.value,
[indice]: !atual,
}
}
/** Registros por consulta (normaliza para inteiro positivo) */
const registrosPorConsulta = computed(() => {
const valor = props.tabela.registros_por_consulta
if (typeof valor === "number" && valor > 0) {
return Math.floor(valor)
}
return 10
})
/** Total de páginas calculado com base no total retornado pela API */
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value
if (!limite || limite <= 0) return 1
const total = quantidade.value ?? 0
if (!total) return 1
return Math.max(1, Math.ceil(total / limite))
})
/** As linhas já vêm paginadas do backend */
const linhasPaginadas = computed(() => linhas.value ?? [])
/** Quantidade exibida é a quantidade total retornada pela consulta */
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
/** Indica se existem ações por linha */
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
const exibirFiltroAvancado = computed(
() => (props.tabela.filtroAvancado ?? []).length > 0,
)
/** Sequencial para evitar race conditions entre consultas */
let carregamentoSequencial = 0
/** Calcula a posição do menu de ações na viewport */
function atualizarPosicaoMenu(anchor: HTMLElement) {
const rect = anchor.getBoundingClientRect()
const gap = 8
// Alinha no canto inferior direito do botão.
// Se estourar a tela para baixo, abre para cima.
const alturaMenu = menuPopup.value?.menuEl?.value?.offsetHeight ?? 0
const larguraMenu = menuPopup.value?.menuEl?.value?.offsetWidth ?? 180
let top = rect.bottom + gap
const left = rect.right - larguraMenu
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
top = rect.top - gap - alturaMenu
}
menuPopupPos.value = {
top: Math.max(gap, Math.round(top)),
left: Math.max(gap, Math.round(left)),
}
}
/** Fecha o menu quando ocorre clique fora */
function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) return
const alvo = evento.target as Node
if (menuPopup.value?.menuEl?.value?.contains(alvo)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] click fora => fechar menu", {
menuAberto: menuAberto.value,
})
}
menuAberto.value = null
}
/** Alterna ordenação e recarrega os dados */
function alternarOrdenacao(chave?: string) {
if (!chave) return
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value =
direcaoOrdenacao.value === "asc" ? "desc" : "asc"
void carregar()
return
}
colunaOrdenacao.value = chave
direcaoOrdenacao.value = "asc"
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
/** Atualiza a busca e reinicia paginação, se necessário */
function atualizarBusca(texto: string) {
if (valorBusca.value === texto) return
valorBusca.value = texto
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
/** Navega para a página solicitada com limites */
function irParaPagina(pagina: number) {
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
if (alvo !== paginaAtual.value) {
paginaAtual.value = alvo
}
}
/**
* Lista ações visíveis por linha, respeitando regras sync/async de `exibir`.
*/
function acoesDisponiveisPorLinha(i: number) {
const acoesLinha = props.tabela.acoesLinha ?? []
const visibilidade = acoesVisiveis.value[i] ?? []
return acoesLinha
.map((acao, indice) => {
const fallbackVisivel =
acao.exibir === undefined
? true
: typeof acao.exibir === "boolean"
? acao.exibir
: false
return {
acao,
indice,
visivel: visibilidade[indice] ?? fallbackVisivel,
}
})
.filter((item) => item.visivel)
}
/** Informa se a linha possui ações disponíveis */
function possuiAcoes(i: number) {
return acoesDisponiveisPorLinha(i).length > 0
}
/** Abre/fecha o menu de ações da linha e posiciona o popup */
function toggleMenu(i: number, evento?: MouseEvent) {
if (!possuiAcoes(i)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu (antes)", {
i,
atual: menuAberto.value,
})
}
if (menuAberto.value === i) {
menuAberto.value = null
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => fechou", { i })
}
return
}
menuAberto.value = i
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => abriu", { i })
}
// posiciona assim que abrir
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
if (anchor) {
// primeiro posicionamento (antes do menu medir)
atualizarPosicaoMenu(anchor)
// reposiciona no próximo frame para pegar altura real
requestAnimationFrame(() => atualizarPosicaoMenu(anchor))
}
}
/**
* Executa consulta, aplica filtros e resolve visibilidade de ações.
* Usa o contador sequencial para ignorar respostas antigas.
*/
async function carregar() {
const idCarregamento = ++carregamentoSequencial
carregando.value = true
erro.value = null
try {
acoesVisiveis.value = []
menuAberto.value = null
linhasExpandidas.value = {}
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
const parametrosConsulta: {
// biome-ignore lint/suspicious/noExplicitAny: generic
filtros?: tipoFiltro<any>[]
coluna_ordem?: never
direcao_ordem?: "asc" | "desc"
offSet: number
limit: number
texto_busca?: string
} = {
offSet: offset,
limit: limite,
}
// Regra combinatória definida: busca tem prioridade.
const busca = (valorBusca.value ?? "").trim()
if (busca) {
parametrosConsulta.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) parametrosConsulta.filtros = filtros
}
if (colunaOrdenacao.value) {
parametrosConsulta.coluna_ordem = colunaOrdenacao.value as never
parametrosConsulta.direcao_ordem = direcaoOrdenacao.value
}
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] consulta(parametros)", parametrosConsulta)
}
const tabelaConfig = props.tabela
const res = await tabelaConfig.consulta(parametrosConsulta)
if (idCarregamento !== carregamentoSequencial) return
if (res.cod !== codigosResposta.sucesso) {
linhas.value = []
quantidade.value = 0
erro.value = res.mensagem
return
}
const valores = res.valor?.valores ?? []
// biome-ignore lint/suspicious/noExplicitAny: dynamic response
const total = (res.valor as any)?.quantidade ?? valores.length
linhas.value = valores
quantidade.value = Number(total) || 0
const totalPaginasRecalculado = Math.max(
1,
Math.ceil((quantidade.value || 0) / limite),
)
if (paginaAtual.value > totalPaginasRecalculado)
paginaAtual.value = totalPaginasRecalculado
const acoesLinhaConfiguradas = tabelaConfig.acoesLinha ?? []
if (!acoesLinhaConfiguradas.length) {
acoesVisiveis.value = []
return
}
const preResultado = valores.map(() =>
acoesLinhaConfiguradas.map((acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
return false
}),
)
acoesVisiveis.value = preResultado
const visibilidade = await Promise.all(
valores.map(async (linha) =>
Promise.all(
acoesLinhaConfiguradas.map(async (acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
try {
const resultado = acao.exibir(linha as never)
return Boolean(await Promise.resolve(resultado))
} catch {
return false
}
}),
),
),
)
if (idCarregamento === carregamentoSequencial) {
acoesVisiveis.value = visibilidade
}
} catch (e) {
console.error("[EliTabela] Erro capturado em carregar():", e)
if (idCarregamento !== carregamentoSequencial) return
linhas.value = []
quantidade.value = 0
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
} finally {
if (idCarregamento === carregamentoSequencial) {
carregando.value = false
}
}
}
/** Ciclo de vida: registra listener global e carrega dados iniciais */
onMounted(() => {
document.addEventListener("click", handleClickFora)
void carregar()
})
/** Ciclo de vida: remove listener global */
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickFora)
})
/** Watch: ao desabilitar busca, limpa termo e recarrega */
watch(
() => props.tabela.mostrarCaixaDeBusca,
(mostrar) => {
if (!mostrar && valorBusca.value) {
valorBusca.value = ""
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
}
},
)
/** Watch: mudança de página dispara nova consulta */
watch(paginaAtual, (nova, antiga) => {
if (nova !== antiga) void carregar()
})
/** Watch: troca de configuração reseta estados e recarrega */
watch(
() => props.tabela,
() => {
menuAberto.value = null
colunaOrdenacao.value = null
direcaoOrdenacao.value = "asc"
valorBusca.value = ""
modalColunasAberto.value = false
modalFiltroAberto.value = false
configColunas.value = carregarConfigColunas(props.tabela.nome)
// biome-ignore lint/suspicious/noExplicitAny: dynamic reload
filtrosUi.value = carregarFiltroAvancado<any>(props.tabela.nome) as any
linhasExpandidas.value = {}
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
},
)
/** Watch: alteração do limite de registros reinicia paginação */
watch(
() => props.tabela.registros_por_consulta,
() => {
if (paginaAtual.value !== 1) {
paginaAtual.value = 1
} else {
void carregar()
}
},
)
/** Watch: mudança nas linhas fecha o menu aberto */
watch(linhas, () => {
menuAberto.value = null
linhasExpandidas.value = {}
})
/** Exposição para o template (state, computed, helpers e actions) */
return {
// state
isDev,
tabela,
carregando,
erro,
linhas,
linhasPaginadas,
filtrosAvancadosAtivos,
quantidadeFiltrada,
quantidade,
menuAberto,
valorBusca,
paginaAtual,
colunaOrdenacao,
direcaoOrdenacao,
totalPaginas,
registrosPorConsulta,
// computed
exibirBusca,
exibirFiltroAvancado,
acoesSuperiores,
temAcoesSuperiores,
acoesInferiores,
temAcoesInferiores,
parametrosConsultaAtuais,
temAcoes,
totalColunas,
colunasEfetivas,
rotulosColunas,
modalColunasAberto,
configColunas,
temColunasInvisiveis,
colunasInvisiveisEfetivas,
linhasExpandidas,
// selecao
mostrarSelecao,
isSelecionado,
alternarSelecao,
todosSelecionados,
algunsSelecionados,
alternarSelecaoTodos,
abrirModalColunas,
abrirModalFiltro,
fecharModalColunas,
salvarModalColunas,
modalFiltroAberto,
filtrosUi,
salvarFiltrosAvancados,
limparFiltrosAvancados,
fecharModalFiltro,
alternarLinhaExpandida,
// actions
alternarOrdenacao,
atualizarBusca,
irParaPagina,
acoesDisponiveisPorLinha,
possuiAcoes,
toggleMenu,
// popup
menuPopup,
menuPopupPos,
}
return useEliTabela(props, emit)
},
})
</script>
<style src="./EliTabela.css"></style>

View file

@ -102,12 +102,12 @@ export default defineComponent({
props: {
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
colunasInvisiveis: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
temColunasInvisiveis: {
@ -119,7 +119,7 @@ export default defineComponent({
required: true,
},
linhas: {
type: Array as PropType<Array<unknown>>,
type: Array as PropType<unknown[]>,
required: true,
},
temAcoes: {

View file

@ -8,6 +8,7 @@
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas"
@click="emitColunas"
>
<Columns3 :size="15" :stroke-width="2" />
Colunas
</button>
@ -17,6 +18,7 @@
class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--filtro"
@click="emitFiltroAvancado"
>
<Filter :size="15" :stroke-width="2" />
Filtro
</button>
<EliTabelaCaixaDeBusca :modelo="valorBusca" @buscar="emitBuscar" />
@ -46,12 +48,13 @@
</template>
<script lang="ts">
import { Columns3, Filter } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import EliTabelaCaixaDeBusca from "./EliTabelaCaixaDeBusca.vue"
export default defineComponent({
name: "EliTabelaCabecalho",
components: { EliTabelaCaixaDeBusca },
components: { Columns3, EliTabelaCaixaDeBusca, Filter },
props: {
exibirBusca: {
type: Boolean,

View file

@ -82,7 +82,7 @@ export default defineComponent({
border-radius: var(--eli-tabela-cabecalho-controle-radius, 8px);
border: 1px solid rgba(15, 23, 42, 0.15);
overflow: hidden;
background: #fff;
background: var(--cor-eli-superficie);
/* Herda do container do cabeçalho (.eli-tabela__cabecalho) quando presente. */
height: var(--eli-tabela-cabecalho-controle-altura, 34px);
@ -104,7 +104,7 @@ export default defineComponent({
}
.eli-tabela__busca-input::placeholder {
color: rgba(107, 114, 128, 0.85);
color: var(--cor-eli-neutro);
}
.eli-tabela__busca-botao {
@ -112,8 +112,8 @@ export default defineComponent({
align-items: center;
justify-content: center;
border: none;
background: rgba(37, 99, 235, 0.12);
color: rgba(37, 99, 235, 0.95);
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria-escura);
height: 100%;
padding: 0 12px;
cursor: pointer;
@ -126,12 +126,12 @@ export default defineComponent({
.eli-tabela__busca-botao:hover,
.eli-tabela__busca-botao:focus-visible {
background: rgba(37, 99, 235, 0.2);
color: rgba(37, 99, 235, 1);
background: var(--cor-eli-primaria-clara);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__busca-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
</style>

View file

@ -2,17 +2,21 @@
<!-- Debug visual (somente DEV): expõe estados internos úteis para testes e diagnóstico -->
<div
v-if="isDev"
style="position: fixed; left: 8px; bottom: 8px; z-index: 999999; background: rgba(185,28,28,0.9); color: #fff; padding: 6px 10px; border-radius: 8px; font-size: 12px; max-width: 500px;"
style="position: fixed; right: 0; top: 0; bottom: 0; width: 360px; z-index: 999999; background: rgba(20, 20, 20, 0.95); color: #32CD32; padding: 20px 16px; font-family: 'Courier New', Courier, monospace; font-size: 12px; overflow-y: auto; border-left: 1px solid #333; display: flex; flex-direction: column; gap: 6px;"
>
<div><b>EliTabela debug</b></div>
<div>menuAberto: {{ menuAberto }}</div>
<div>menuPos: top={{ menuPopupPos.top }}, left={{ menuPopupPos.left }}</div>
<slot />
<div style="font-weight: bold; color: #fff; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #333; padding-bottom: 8px;">
EliTabela // Log Terminal
</div>
<div style="color: #bbb;">menuAberto: <span style="color: #fff;">{{ menuAberto }}</span></div>
<div style="color: #bbb;">menuPos: top=<span style="color: #fff;">{{ menuPopupPos.top }}</span>, left=<span style="color: #fff;">{{ menuPopupPos.left }}</span></div>
<div style="margin-top: 8px; white-space: pre-wrap; word-break: break-all;">
<slot />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue"
import { defineComponent, onMounted, onUnmounted, type PropType, watch } from "vue"
export default defineComponent({
name: "EliTabelaDebug",
@ -30,5 +34,39 @@ export default defineComponent({
required: true,
},
},
setup(props) {
const applyStyle = () => {
let styleTag = document.getElementById("eli-tabela-debug-style")
if (props.isDev) {
if (!styleTag) {
styleTag = document.createElement("style")
styleTag.id = "eli-tabela-debug-style"
styleTag.textContent = `
html, body {
overflow-x: auto !important;
}
#app, .v-application__wrap, .v-application {
padding-right: 360px !important;
box-sizing: border-box !important;
}
.v-container, .stack {
overflow-x: auto !important;
max-width: 100% !important;
}
`
document.head.appendChild(styleTag)
}
} else {
if (styleTag) styleTag.remove()
}
}
watch(() => props.isDev, applyStyle)
onMounted(applyStyle)
onUnmounted(() => {
const styleTag = document.getElementById("eli-tabela-debug-style")
if (styleTag) styleTag.remove()
})
},
})
</script>

View file

@ -24,7 +24,7 @@ export default defineComponent({
},
colunasInvisiveis: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
},

View file

@ -1,26 +1,33 @@
<template>
<!-- Estado de carregamento: consulta em andamento -->
<div v-if="carregando" class="eli-tabela eli-tabela--carregando" aria-busy="true">
Carregando...
<LoaderCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--spin" :size="22" :stroke-width="2" />
<span>Carregando…</span>
</div>
<!-- Estado de erro: mostra mensagem retornada pela consulta -->
<div v-else-if="erro" class="eli-tabela eli-tabela--erro" role="alert">
<div class="eli-tabela__erro-titulo">Erro</div>
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
<AlertCircle class="eli-tabela__estado-icone eli-tabela__estado-icone--erro" :size="18" :stroke-width="2" />
<div>
<div class="eli-tabela__erro-titulo">Erro</div>
<div class="eli-tabela__erro-mensagem">{{ erro }}</div>
</div>
</div>
<!-- Estado vazio: consulta sem registros -->
<div v-else class="eli-tabela eli-tabela--vazio">
{{ mensagemVazio ?? "Nenhum registro encontrado." }}
<Inbox class="eli-tabela__estado-icone" :size="22" :stroke-width="1.5" />
<span>{{ mensagemVazio ?? "Nenhum registro encontrado." }}</span>
</div>
</template>
<script lang="ts">
import { AlertCircle, Inbox, LoaderCircle } from "@lucide/vue"
import { defineComponent, type PropType } from "vue"
export default defineComponent({
name: "EliTabelaEstados",
components: { AlertCircle, Inbox, LoaderCircle },
props: {
carregando: {
type: Boolean,

View file

@ -70,7 +70,7 @@ export default defineComponent({
props: {
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
temAcoes: {
@ -109,9 +109,8 @@ export default defineComponent({
"alternar-selecao-todos": () => true,
},
setup(_props, { emit }) {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
function isOrdenavel(coluna: any) {
return coluna?.coluna_ordem !== undefined && coluna?.coluna_ordem !== null
function isOrdenavel(coluna: tipoEliColuna<unknown>): boolean {
return coluna.coluna_ordem !== undefined && coluna.coluna_ordem !== null
}
function emitAlternarOrdenacao(chave: string) {

View file

@ -67,7 +67,7 @@ export default defineComponent({
},
acoes: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
type: Array as PropType<Array<ItemAcao<any>>>,
type: Array as PropType<ItemAcao<any>[]>,
required: true,
},
linha: {

View file

@ -0,0 +1,342 @@
.eli-tabela-modal-colunas__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(2px);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-colunas__modal {
width: min(820px, 100%);
background: var(--cor-eli-superficie);
border-radius: 16px;
border: 1px solid var(--cor-eli-borda);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.eli-tabela-modal-colunas__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-primaria-header, #ADCC44);
}
.eli-tabela-modal-colunas__header-info {
display: flex;
align-items: center;
gap: 8px;
}
.eli-tabela-modal-colunas__header-icone {
color: var(--cor-eli-primaria-escura);
flex-shrink: 0;
}
.eli-tabela-modal-colunas__titulo {
font-size: 0.95rem;
font-weight: 600;
color: var(--cor-eli-primaria-texto);
margin: 0;
}
.eli-tabela-modal-colunas__fechar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
color: var(--cor-eli-primaria-escura);
transition: background 0.2s ease;
}
.eli-tabela-modal-colunas__fechar:hover,
.eli-tabela-modal-colunas__fechar:focus-visible {
background: rgba(15, 23, 42, 0.1);
}
.eli-tabela-modal-colunas__fechar:focus-visible {
outline: 2px solid var(--cor-eli-primaria);
outline-offset: 2px;
}
/* ── Instrução ── */
.eli-tabela-modal-colunas__instrucao {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--cor-eli-fundo);
border-bottom: 1px solid var(--cor-eli-borda);
font-size: 0.78rem;
color: var(--cor-eli-neutro);
line-height: 1.4;
}
.eli-tabela-modal-colunas__instrucao-icone {
flex-shrink: 0;
color: var(--cor-eli-borda-forte);
}
/* ── Conteúdo ── */
.eli-tabela-modal-colunas__conteudo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
flex: 1;
}
.eli-tabela-modal-colunas__coluna {
display: flex;
flex-direction: column;
padding: 12px;
}
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
border-left: 1px solid var(--cor-eli-borda);
}
/* ── Cabeçalho das colunas ── */
.eli-tabela-modal-colunas__coluna-cabecalho {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 8px;
margin-bottom: 8px;
}
.eli-tabela-modal-colunas__coluna-cabecalho--visiveis {
color: var(--cor-eli-primaria-escura);
background: var(--cor-eli-primaria-bg);
}
.eli-tabela-modal-colunas__coluna-cabecalho--invisiveis {
color: var(--cor-eli-neutro-escuro);
background: var(--cor-eli-fundo);
}
.eli-tabela-modal-colunas__coluna-contador {
margin-left: auto;
font-size: 0.72rem;
font-weight: 700;
padding: 1px 7px;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.08);
color: inherit;
}
/* ── Lista ── */
.eli-tabela-modal-colunas__lista {
flex: 1;
min-height: 220px;
border: 1.5px dashed var(--cor-eli-borda);
border-radius: 10px;
padding: 8px;
position: relative;
overflow: hidden;
transition: border-color 0.2s ease, background 0.2s ease;
}
.eli-tabela-modal-colunas__lista--visiveis {
background: rgba(104, 140, 0, 0.02);
}
.eli-tabela-modal-colunas__lista--invisiveis {
background: var(--cor-eli-fundo);
}
/* ── Item ── */
.eli-tabela-modal-colunas__item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-superficie);
cursor: grab;
user-select: none;
transition: box-shadow 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
margin-top: 6px;
}
.eli-tabela-modal-colunas__item:hover {
border-color: var(--cor-eli-borda-forte);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__item:active {
cursor: grabbing;
}
.eli-tabela-modal-colunas__item--invisivel {
opacity: 0.72;
}
.eli-tabela-modal-colunas__item--arrastando {
opacity: 0.15;
border-style: dashed;
border-color: var(--cor-eli-primaria-media);
}
/* ── Partes do item ── */
.eli-tabela-modal-colunas__item-handle {
color: var(--cor-eli-borda-forte);
flex-shrink: 0;
transition: color 0.15s ease;
}
.eli-tabela-modal-colunas__item:hover .eli-tabela-modal-colunas__item-handle {
color: var(--cor-eli-neutro);
}
.eli-tabela-modal-colunas__item-texto {
flex: 1;
min-width: 0;
font-size: 0.875rem;
color: var(--cor-eli-neutro-escuro);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eli-tabela-modal-colunas__item-badge-icone {
flex-shrink: 0;
}
.eli-tabela-modal-colunas__item-badge-icone--visivel {
color: var(--cor-eli-primaria-acao);
}
.eli-tabela-modal-colunas__item-badge-icone--invisivel {
color: var(--cor-eli-borda-forte);
}
/* ── Estado vazio ── */
.eli-tabela-modal-colunas__vazio {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 12px;
text-align: center;
font-size: 0.8rem;
color: var(--cor-eli-neutro);
line-height: 1.5;
}
.eli-tabela-modal-colunas__vazio-icone {
color: var(--cor-eli-borda-forte);
}
.eli-tabela-modal-colunas__vazio-icone--ok {
color: var(--cor-eli-primaria-acao);
}
/* ── Animações TransitionGroup ── */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.list-leave-active {
position: absolute;
width: calc(100% - 16px);
}
/* ── Footer ── */
.eli-tabela-modal-colunas__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-fundo);
}
.eli-tabela-modal-colunas__botao {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid var(--cor-eli-borda);
background: var(--cor-eli-superficie);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
.eli-tabela-modal-colunas__botao--sec {
color: var(--cor-eli-neutro-escuro);
}
.eli-tabela-modal-colunas__botao--sec:hover,
.eli-tabela-modal-colunas__botao--sec:focus-visible {
background: var(--cor-eli-fundo);
border-color: var(--cor-eli-borda-forte);
}
.eli-tabela-modal-colunas__botao--sec:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
.eli-tabela-modal-colunas__botao--prim {
border: none;
background: var(--cor-eli-primaria);
color: #fff;
}
.eli-tabela-modal-colunas__botao--prim:hover,
.eli-tabela-modal-colunas__botao--prim:focus-visible {
background: var(--cor-eli-primaria-escura);
}
.eli-tabela-modal-colunas__botao--prim:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
/* ── Responsivo ── */
@media (max-width: 720px) {
.eli-tabela-modal-colunas__conteudo {
grid-template-columns: 1fr;
}
.eli-tabela-modal-colunas__coluna + .eli-tabela-modal-colunas__coluna {
border-left: none;
border-top: 1px solid var(--cor-eli-borda);
}
}

View file

@ -5,78 +5,124 @@
class="eli-tabela-modal-colunas__modal"
role="dialog"
aria-modal="true"
aria-label="Configurar colunas"
aria-labelledby="eli-modal-colunas-titulo"
>
<!-- Header -->
<header class="eli-tabela-modal-colunas__header">
<h3 class="eli-tabela-modal-colunas__titulo">Colunas</h3>
<button type="button" class="eli-tabela-modal-colunas__fechar" aria-label="Fechar" @click="emitFechar">
×
<div class="eli-tabela-modal-colunas__header-info">
<Columns2 :size="18" :stroke-width="2" class="eli-tabela-modal-colunas__header-icone" />
<h3 id="eli-modal-colunas-titulo" class="eli-tabela-modal-colunas__titulo">Configurar colunas</h3>
</div>
<button
type="button"
class="eli-tabela-modal-colunas__fechar"
aria-label="Fechar modal de colunas"
@click="emitFechar"
>
<X :size="16" :stroke-width="2.5" />
</button>
</header>
<!-- Instrução contextual -->
<div class="eli-tabela-modal-colunas__instrucao">
<GripVertical :size="14" :stroke-width="2" class="eli-tabela-modal-colunas__instrucao-icone" />
Arraste para reordenar ou mover entre as listas
</div>
<!-- Conteúdo: duas colunas -->
<div class="eli-tabela-modal-colunas__conteudo">
<!-- Visíveis -->
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Visíveis</div>
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--visiveis">
<Eye :size="14" :stroke-width="2" />
<span>Visíveis</span>
<span class="eli-tabela-modal-colunas__coluna-contador">{{ visiveisLocal.length }}</span>
</div>
<TransitionGroup
name="list"
tag="div"
class="eli-tabela-modal-colunas__lista"
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--visiveis"
@dragover.prevent="onDragOverLista('visiveis')"
@drop="onDragEnd"
>
<div
v-for="rotulo in visiveisLocal"
:key="`vis-${rotulo}`"
class="eli-tabela-modal-colunas__item"
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--visivel"
:class="{
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
}"
draggable="true"
:title="`Arraste para mover '${rotulo}'`"
@dragstart="onDragStart($event, rotulo)"
@dragend="onDragEnd"
@dragover.stop.prevent="onDragOverItem(rotulo, 'visiveis')"
@drop.stop.prevent="onDragEnd"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
<Eye :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--visivel" aria-hidden="true" />
</div>
<!-- Estado vazio -->
<div v-if="visiveisLocal.length === 0" key="vazio-visiveis" class="eli-tabela-modal-colunas__vazio">
<EyeOff :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone" />
<span>Nenhuma coluna visível.<br>Arraste itens da lista ao lado.</span>
</div>
</TransitionGroup>
</div>
<!-- Invisíveis -->
<div class="eli-tabela-modal-colunas__coluna">
<div class="eli-tabela-modal-colunas__coluna-titulo">Invisíveis</div>
<div class="eli-tabela-modal-colunas__coluna-cabecalho eli-tabela-modal-colunas__coluna-cabecalho--invisiveis">
<EyeOff :size="14" :stroke-width="2" />
<span>Ocultas</span>
<span class="eli-tabela-modal-colunas__coluna-contador">{{ invisiveisLocal.length }}</span>
</div>
<TransitionGroup
name="list"
tag="div"
class="eli-tabela-modal-colunas__lista"
class="eli-tabela-modal-colunas__lista eli-tabela-modal-colunas__lista--invisiveis"
@dragover.prevent="onDragOverLista('invisiveis')"
@drop="onDragEnd"
>
<div
v-for="rotulo in invisiveisLocal"
:key="`inv-${rotulo}`"
class="eli-tabela-modal-colunas__item"
class="eli-tabela-modal-colunas__item eli-tabela-modal-colunas__item--invisivel"
:class="{
'eli-tabela-modal-colunas__item--arrastando': itemSendoArrastado === rotulo
}"
draggable="true"
:title="`Arraste para mover '${rotulo}'`"
@dragstart="onDragStart($event, rotulo)"
@dragend="onDragEnd"
@dragover.stop.prevent="onDragOverItem(rotulo, 'invisiveis')"
@drop.stop.prevent="onDragEnd"
>
<span class="eli-tabela-modal-colunas__item-handle" aria-hidden="true">⋮⋮</span>
<GripVertical :size="15" :stroke-width="2" class="eli-tabela-modal-colunas__item-handle" aria-hidden="true" />
<span class="eli-tabela-modal-colunas__item-texto">{{ rotulo }}</span>
<EyeOff :size="13" :stroke-width="2" class="eli-tabela-modal-colunas__item-badge-icone eli-tabela-modal-colunas__item-badge-icone--invisivel" aria-hidden="true" />
</div>
<!-- Estado vazio -->
<div v-if="invisiveisLocal.length === 0" key="vazio-invisiveis" class="eli-tabela-modal-colunas__vazio">
<Eye :size="28" :stroke-width="1.5" class="eli-tabela-modal-colunas__vazio-icone eli-tabela-modal-colunas__vazio-icone--ok" />
<span>Todas as colunas estão visíveis.</span>
</div>
</TransitionGroup>
</div>
</div>
<!-- Footer -->
<footer class="eli-tabela-modal-colunas__footer">
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--sec" @click="emitFechar">
<X :size="14" :stroke-width="2.5" />
Cancelar
</button>
<button type="button" class="eli-tabela-modal-colunas__botao eli-tabela-modal-colunas__botao--prim" @click="emitSalvar">
<Check :size="14" :stroke-width="2.5" />
Salvar
</button>
</footer>
@ -86,30 +132,23 @@
</template>
<script lang="ts">
import { defineComponent, type PropType, ref, watch } from "vue"
import { Check, Columns2, Eye, EyeOff, GripVertical, X } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import "./EliTabelaModalColunas.css"
import type { EliTabelaColunasConfig } from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
type OrigemLista = "visiveis" | "invisiveis"
import { useColunasOrdenacao } from "./useColunasOrdenacao"
export default defineComponent({
name: "EliTabelaModalColunas",
components: { Columns2, Eye, EyeOff, GripVertical, X, Check },
props: {
aberto: {
type: Boolean,
required: true,
},
rotulosColunas: {
type: Array as PropType<string[]>,
required: true,
},
configInicial: {
type: Object as PropType<EliTabelaColunasConfig>,
required: true,
},
aberto: { type: Boolean, required: true },
rotulosColunas: { type: Array as PropType<string[]>, required: true },
configInicial: { type: Object as PropType<EliTabelaColunasConfig>, required: true },
colunas: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
type: Array as PropType<Array<tipoEliColuna<any>>>,
type: Array as PropType<tipoEliColuna<any>[]>,
required: true,
},
},
@ -122,58 +161,12 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const visiveisLocal = ref<string[]>([])
const invisiveisLocal = ref<string[]>([])
const itemSendoArrastado = ref<string | null>(null)
function sincronizarEstado() {
const todos = props.rotulosColunas
const configTemDados =
(props.configInicial.visiveis?.length ?? 0) > 0 ||
(props.configInicial.invisiveis?.length ?? 0) > 0
const invisiveisPadraoSet = new Set(
props.colunas.filter((c) => c.visivel === false).map((c) => c.rotulo),
)
const invisiveisSet = configTemDados
? new Set(props.configInicial.invisiveis ?? [])
: invisiveisPadraoSet
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
// ordenação: aplica ordem salva (visiveis) e adiciona novas ao final
const ordemSalva = props.configInicial.visiveis ?? []
const setVis = new Set(baseVisiveis)
const ordenadas: string[] = []
for (const r of ordemSalva) {
if (setVis.has(r)) ordenadas.push(r)
}
for (const r of baseVisiveis) {
if (!ordenadas.includes(r)) ordenadas.push(r)
}
visiveisLocal.value = ordenadas
// invisíveis: somente as que existem na tabela
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
}
watch(
() =>
[
props.aberto,
props.rotulosColunas,
props.configInicial,
props.colunas,
] as const,
() => {
if (props.aberto) sincronizarEstado()
},
{ deep: true, immediate: true },
)
const ordenacao = useColunasOrdenacao({
aberto: computed(() => props.aberto),
rotulosColunas: computed(() => props.rotulosColunas),
configInicial: computed(() => props.configInicial),
colunas: computed(() => props.colunas),
})
function emitFechar() {
emit("fechar")
@ -181,242 +174,17 @@ export default defineComponent({
function emitSalvar() {
emit("salvar", {
visiveis: [...visiveisLocal.value],
invisiveis: [...invisiveisLocal.value],
visiveis: [...ordenacao.visiveisLocal.value],
invisiveis: [...ordenacao.invisiveisLocal.value],
})
}
function onDragStart(e: DragEvent, rotulo: string) {
itemSendoArrastado.value = rotulo
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", rotulo)
e.dataTransfer.effectAllowed = "move"
}
}
function onDragEnd() {
itemSendoArrastado.value = null
}
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
const origem =
origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
if (!origem) return
const origemArr =
origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const destinoArr =
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const dragIdx = origemArr.indexOf(draggedRotulo)
const alvoIdx = destinoArr.indexOf(alvoRotulo)
origemArr.splice(dragIdx, 1)
destinoArr.splice(alvoIdx, 0, draggedRotulo)
}
function onDragOverLista(destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo) return
const destinoArr =
destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
destinoArr.push(draggedRotulo)
}
return {
visiveisLocal,
invisiveisLocal,
itemSendoArrastado,
...ordenacao,
emitFechar,
emitSalvar,
onDragStart,
onDragEnd,
onDragOverItem,
onDragOverLista,
}
},
})
</script>
<style>
.eli-tabela-modal-colunas__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-colunas__modal {
width: min(860px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
.eli-tabela-modal-colunas__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__titulo {
font-size: 1rem;
margin: 0;
}
.eli-tabela-modal-colunas__fechar {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 22px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-colunas__fechar:hover,
.eli-tabela-modal-colunas__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-colunas__conteudo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 16px;
}
.eli-tabela-modal-colunas__coluna-titulo {
font-weight: 600;
margin-bottom: 8px;
}
.eli-tabela-modal-colunas__lista {
min-height: 260px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 12px;
padding: 10px;
background: rgba(15, 23, 42, 0.01);
position: relative;
overflow: hidden;
}
.eli-tabela-modal-colunas__item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 10px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: #fff;
cursor: grab;
user-select: none;
transition: box-shadow 0.15s, opacity 0.15s, transform 0.15s;
}
.eli-tabela-modal-colunas__item + .eli-tabela-modal-colunas__item {
margin-top: 8px;
}
.eli-tabela-modal-colunas__item:active {
cursor: grabbing;
}
.eli-tabela-modal-colunas__item--arrastando {
opacity: 0.1;
background: #f8fafc;
border-style: dashed;
}
/* Animações de reposicionamento e listas do Vue TransitionGroup */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.list-leave-active {
/* position absolute para suavizar a animação de saída de outras listas */
position: absolute;
width: calc(100% - 20px);
}
.eli-tabela-modal-colunas__item-handle {
color: rgba(15, 23, 42, 0.55);
font-size: 14px;
}
.eli-tabela-modal-colunas__item-texto {
flex: 1;
min-width: 0;
}
.eli-tabela-modal-colunas__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-colunas__botao {
height: 34px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.eli-tabela-modal-colunas__botao--sec:hover,
.eli-tabela-modal-colunas__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-colunas__botao--prim {
border: none;
background: rgba(37, 99, 235, 0.95);
color: #fff;
}
.eli-tabela-modal-colunas__botao--prim:hover,
.eli-tabela-modal-colunas__botao--prim:focus-visible {
background: rgba(37, 99, 235, 1);
}
@media (max-width: 720px) {
.eli-tabela-modal-colunas__conteudo {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,443 @@
.eli-tabela-modal-filtro__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-filtro__modal {
width: min(980px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
/* ── Header ──────────────────────────────────── */
.eli-tabela-modal-filtro__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__header-titulo {
display: flex;
align-items: center;
gap: 10px;
}
.eli-tabela-modal-filtro__titulo {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: rgba(15, 23, 42, 0.9);
}
.eli-tabela-modal-filtro__header-icone {
color: var(--cor-eli-primaria);
flex-shrink: 0;
}
.eli-tabela-modal-filtro__contador {
font-size: 0.75rem;
font-weight: 500;
color: var(--cor-eli-primaria);
background: var(--cor-eli-primaria-bg);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.eli-tabela-modal-filtro__fechar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
color: rgba(15, 23, 42, 0.55);
transition: background 0.15s, color 0.15s;
}
.eli-tabela-modal-filtro__fechar:hover,
.eli-tabela-modal-filtro__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
color: rgba(15, 23, 42, 0.9);
}
/* ── Conteúdo ────────────────────────────────── */
.eli-tabela-modal-filtro__conteudo {
padding: 20px;
}
.eli-tabela-modal-filtro__vazio {
opacity: 0.65;
font-size: 0.85rem;
padding: 12px 0 4px;
}
/* ── Estado vazio orientado ──────────────────── */
.eli-tabela-modal-filtro__estado-vazio {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 16px;
color: rgba(15, 23, 42, 0.35);
border: 1px dashed rgba(15, 23, 42, 0.12);
border-radius: 10px;
background: rgba(15, 23, 42, 0.015);
}
.eli-tabela-modal-filtro__estado-vazio span {
font-size: 0.85rem;
font-weight: 500;
}
/* ── Lista de filtros (2 colunas) ────────────── */
.eli-tabela-modal-filtro__lista {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.eli-tabela-modal-filtro__linha {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 10px;
background: rgba(15, 23, 42, 0.015);
transition: border-color 0.15s;
}
.eli-tabela-modal-filtro__linha:hover {
border-color: var(--cor-eli-primaria-clara);
}
.eli-tabela-modal-filtro__linha-header {
display: flex;
align-items: center;
gap: 8px;
}
.eli-tabela-modal-filtro__rotulo-filtro {
font-size: 0.82rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.65);
flex: 1;
min-width: 0;
}
.eli-tabela-modal-filtro__id-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 24px;
border-radius: 6px;
background: var(--cor-eli-primaria-bg);
color: var(--cor-eli-primaria);
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: default;
user-select: none;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__entrada {
min-width: 0;
}
.eli-tabela-modal-filtro__remover {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
color: rgba(15, 23, 42, 0.35);
transition: background 0.15s, color 0.15s;
margin-left: auto;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__remover:hover,
.eli-tabela-modal-filtro__remover:focus-visible {
background: rgba(239, 68, 68, 0.08);
color: rgba(220, 38, 38, 0.9);
border-color: rgba(239, 68, 68, 0.2);
}
/* ── Rodapé: select + fórmula lado a lado ────── */
.eli-tabela-modal-filtro__rodape-filtros {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(15, 23, 42, 0.07);
display: flex;
gap: 12px;
align-items: flex-end;
}
.eli-tabela-modal-filtro__acoes {
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__select {
height: 36px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
background: #fff;
font-size: 0.875rem;
color: rgba(15, 23, 42, 0.85);
transition: border-color 0.15s;
}
.eli-tabela-modal-filtro__select:focus {
outline: none;
border-color: var(--cor-eli-primaria);
}
/* ── Fórmula de combinação ───────────────────── */
.eli-tabela-modal-filtro__formula-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-left: auto;
}
.eli-tabela-modal-filtro__formula-label {
font-size: 0.82rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.55);
white-space: nowrap;
flex-shrink: 0;
}
.eli-tabela-modal-filtro__formula-ajuda {
position: relative;
display: inline-flex;
align-items: center;
color: rgba(15, 23, 42, 0.35);
cursor: help;
transition: color 0.15s;
}
.eli-tabela-modal-filtro__formula-ajuda:hover,
.eli-tabela-modal-filtro__formula-ajuda:focus-visible {
color: var(--cor-eli-primaria);
outline: none;
}
.eli-tabela-modal-filtro__formula-ajuda:hover .eli-tabela-modal-filtro__formula-tooltip,
.eli-tabela-modal-filtro__formula-ajuda:focus-visible .eli-tabela-modal-filtro__formula-tooltip {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.eli-tabela-modal-filtro__formula-tooltip {
position: absolute;
bottom: calc(100% + 10px);
right: 0;
width: 280px;
background: #0f172a;
color: #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
font-size: 0.8rem;
line-height: 1.55;
z-index: 100;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 0.18s ease, transform 0.18s ease;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
}
.eli-tabela-modal-filtro__formula-tooltip::after {
content: '';
position: absolute;
top: 100%;
right: 6px;
border: 6px solid transparent;
border-top-color: #0f172a;
}
.eli-tabela-modal-filtro__formula-tooltip strong {
font-size: 0.82rem;
color: var(--cor-eli-fundo);
display: block;
margin-bottom: 6px;
}
.eli-tabela-modal-filtro__formula-tooltip p {
margin: 6px 0 4px;
color: #94a3b8;
}
.eli-tabela-modal-filtro__formula-tooltip ul {
margin: 4px 0 8px 14px;
padding: 0;
}
.eli-tabela-modal-filtro__formula-tooltip li {
margin-bottom: 3px;
color: #cbd5e1;
}
.eli-tabela-modal-filtro__formula-tooltip code {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
color: #7dd3fc;
font-size: 0.78rem;
background: rgba(255, 255, 255, 0.07);
border-radius: 4px;
padding: 1px 4px;
}
.eli-tabela-modal-filtro__formula-input {
width: 220px;
height: 36px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.85rem;
background: var(--cor-eli-fundo);
transition: border-color 0.15s, background 0.15s;
box-sizing: border-box;
}
.eli-tabela-modal-filtro__formula-input:focus {
outline: none;
border-color: var(--cor-eli-primaria);
background: #fff;
}
.eli-tabela-modal-filtro__formula-tooltip-nota {
margin-top: 8px;
opacity: 0.75;
}
/* ── Footer ──────────────────────────────────── */
.eli-tabela-modal-filtro__footer {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__footer-spacer {
flex: 1;
}
/* ── Botões ──────────────────────────────────── */
.eli-tabela-modal-filtro__botao {
display: inline-flex;
align-items: center;
gap: 5px;
height: 36px;
padding: 0 16px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.eli-tabela-modal-filtro__botao:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eli-tabela-modal-filtro__botao--sec:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.05);
}
.eli-tabela-modal-filtro__botao--prim {
border: none;
background: var(--cor-eli-primaria);
color: #fff;
}
.eli-tabela-modal-filtro__botao--prim:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--prim:focus-visible {
background: var(--cor-eli-primaria-escura);
}
.eli-tabela-modal-filtro__botao--perigo {
color: rgba(220, 38, 38, 0.85);
border-color: rgba(220, 38, 38, 0.18);
background: #fff;
}
.eli-tabela-modal-filtro__botao--perigo:hover:not(:disabled),
.eli-tabela-modal-filtro__botao--perigo:focus-visible {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(220, 38, 38, 0.3);
color: rgba(220, 38, 38, 1);
}
/* ── Responsivo ──────────────────────────────── */
@media (max-width: 600px) {
.eli-tabela-modal-filtro__lista {
grid-template-columns: 1fr;
}
.eli-tabela-modal-filtro__rodape-filtros {
flex-direction: column;
align-items: stretch;
}
.eli-tabela-modal-filtro__acoes {
flex-wrap: wrap;
}
}
/* ── Transição de abertura/fechamento ────────── */
.eli-tabela-modal-filtro-enter-active,
.eli-tabela-modal-filtro-leave-active {
transition: opacity 0.2s ease;
}
.eli-tabela-modal-filtro-enter-active .eli-tabela-modal-filtro__modal,
.eli-tabela-modal-filtro-leave-active .eli-tabela-modal-filtro__modal {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.eli-tabela-modal-filtro-enter-from,
.eli-tabela-modal-filtro-leave-to {
opacity: 0;
}
.eli-tabela-modal-filtro-enter-from .eli-tabela-modal-filtro__modal,
.eli-tabela-modal-filtro-leave-to .eli-tabela-modal-filtro__modal {
transform: scale(0.96);
opacity: 0;
}

View file

@ -1,403 +1,182 @@
<template>
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
<header class="eli-tabela-modal-filtro__header">
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
×
</button>
</header>
<Transition name="eli-tabela-modal-filtro">
<div v-if="aberto" class="eli-tabela-modal-filtro__overlay" role="presentation" @click.self="emitFechar">
<div class="eli-tabela-modal-filtro__modal" role="dialog" aria-modal="true" aria-label="Filtro avançado">
<header class="eli-tabela-modal-filtro__header">
<div class="eli-tabela-modal-filtro__header-titulo">
<Filter class="eli-tabela-modal-filtro__header-icone" :size="18" :stroke-width="2" />
<h3 class="eli-tabela-modal-filtro__titulo">Filtro avançado</h3>
<span v-if="linhas.length" class="eli-tabela-modal-filtro__contador">
{{ linhas.length }} {{ linhas.length === 1 ? 'filtro' : 'filtros' }}
</span>
</div>
<button type="button" class="eli-tabela-modal-filtro__fechar" aria-label="Fechar" @click="emitFechar">
<X :size="18" :stroke-width="2" />
</button>
</header>
<div class="eli-tabela-modal-filtro__conteudo">
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
Nenhum filtro configurado na tabela.
</div>
<div class="eli-tabela-modal-filtro__conteudo">
<div v-if="!filtrosBase.length" class="eli-tabela-modal-filtro__vazio">
Nenhum filtro configurado na tabela.
</div>
<div v-else class="eli-tabela-modal-filtro__lista">
<div v-for="(linha, idx) in linhas" :key="String(linha.chave)" class="eli-tabela-modal-filtro__linha">
<div class="eli-tabela-modal-filtro__entrada">
<component
:is="componenteEntrada(linha.entrada)"
:value="linha.valor"
@update:value="(v: any) => (linha.valor = v)"
@input="(v: any) => (linha.valor = v)"
:opcoes="opcoesEntrada(linha.entrada)"
density="compact"
/>
<div v-else>
<!-- Lista de filtros ativos -->
<div v-if="linhas.length" class="eli-tabela-modal-filtro__lista">
<div
v-for="(linha, idx) in linhas"
:key="String(linha.chave)"
class="eli-tabela-modal-filtro__linha"
>
<div class="eli-tabela-modal-filtro__linha-header">
<span
class="eli-tabela-modal-filtro__id-badge"
:title="`Identificador na fórmula: {c${idx + 1}}`"
>
c{{ idx + 1 }}
</span>
<span class="eli-tabela-modal-filtro__rotulo-filtro">{{ rotuloDoFiltroAtivo(linha.chave) }}</span>
<button
type="button"
class="eli-tabela-modal-filtro__remover"
title="Remover filtro"
aria-label="Remover filtro"
@click="remover(idx)"
>
<Trash2 :size="14" :stroke-width="2" />
</button>
</div>
<div class="eli-tabela-modal-filtro__entrada">
<component
:is="componenteEntrada(linha.entrada)"
v-bind="({ value: linha.valor, opcoes: opcoesEntrada(linha.entrada), density: 'compact' } as Record<string, unknown>)"
@update:value="(v: unknown) => (linha.valor = v)"
@input="(v: unknown) => (linha.valor = v)"
/>
</div>
</div>
</div>
<button
type="button"
class="eli-tabela-modal-filtro__remover"
title="Remover"
aria-label="Remover"
@click="remover(idx)"
>
×
</button>
<!-- Estado vazio orientado -->
<div v-else class="eli-tabela-modal-filtro__estado-vazio">
<Filter :size="32" :stroke-width="1.5" />
<span>Selecione um filtro abaixo para começar</span>
</div>
<!-- Rodapé: adicionar filtro + fórmula lado a lado -->
<div class="eli-tabela-modal-filtro__rodape-filtros">
<div class="eli-tabela-modal-filtro__acoes">
<select
v-model="chaveParaAdicionar"
class="eli-tabela-modal-filtro__select"
:disabled="!opcoesParaAdicionar.length"
>
<option disabled value="">Selecione um filtro…</option>
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
{{ rotuloDoFiltro(o) }}
</option>
</select>
<button
type="button"
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim"
@click="adicionar"
:disabled="!chaveParaAdicionar"
>
<Plus :size="15" :stroke-width="2.5" />
Adicionar
</button>
</div>
<!-- Fórmula: só aparece com 2+ filtros (com 1 filtro, fórmula não faz sentido) -->
<div v-if="linhas.length >= 2" class="eli-tabela-modal-filtro__formula-container">
<label class="eli-tabela-modal-filtro__formula-label">Fórmula</label>
<input
v-model="formulaCustomizada"
type="text"
class="eli-tabela-modal-filtro__formula-input"
:placeholder="'Ex: ' + formulaGerada"
/>
<span class="eli-tabela-modal-filtro__formula-ajuda" tabindex="0" aria-label="Ajuda sobre fórmulas">
<CircleHelp :size="15" :stroke-width="2" />
<div class="eli-tabela-modal-filtro__formula-tooltip" role="tooltip">
<strong>Como usar a fórmula</strong>
<p>Use os identificadores <code>{c1}</code>, <code>{c2}</code>, <code>{c3}</code>… correspondentes a cada filtro acima:</p>
<ul>
<li><code>E</code> — ambos devem ser verdadeiros</li>
<li><code>OU</code> — pelo menos um deve ser verdadeiro</li>
<li><code>NAO</code> — negação de uma condição</li>
<li><code>( )</code> — agrupa condições</li>
</ul>
<p><strong>Exemplo:</strong></p>
<code>{c1} OU ({c2} E NAO {c3})</code>
<p class="eli-tabela-modal-filtro__formula-tooltip-nota">Deixe em branco para combinar todos com E.</p>
</div>
</span>
</div>
</div>
</div>
</div>
<div class="eli-tabela-modal-filtro__acoes">
<select
v-model="chaveParaAdicionar"
class="eli-tabela-modal-filtro__select"
:disabled="!opcoesParaAdicionar.length"
>
<option disabled value="">Selecione um filtro…</option>
<option v-for="o in opcoesParaAdicionar" :key="String(o.chave)" :value="String(o.chave)">
{{ rotuloDoFiltro(o) }}
</option>
</select>
<footer class="eli-tabela-modal-filtro__footer">
<button
type="button"
class="eli-tabela-modal-filtro__botao"
@click="adicionar"
:disabled="!chaveParaAdicionar"
class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--perigo"
:disabled="!linhas.length"
@click="emitLimpar"
>
Adicionar
Limpar
</button>
</div>
<div class="eli-tabela-modal-filtro__footer-spacer"></div>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
Cancelar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
Aplicar
</button>
</footer>
</div>
<footer class="eli-tabela-modal-filtro__footer">
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitLimpar">
Limpar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--sec" @click="emitFechar">
Cancelar
</button>
<button type="button" class="eli-tabela-modal-filtro__botao eli-tabela-modal-filtro__botao--prim" @click="emitSalvar">
Aplicar
</button>
</footer>
</div>
</div>
</Transition>
</template>
<script lang="ts">
import { computed, defineComponent, type PropType, ref, watch } from "vue"
import {
EliEntradaDataHora,
EliEntradaNumero,
EliEntradaTexto,
} from "../EliEntrada"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
type FiltroBase<T> = NonNullable<
tipoEliTabelaConsulta<T>["filtroAvancado"]
>[number]
type LinhaFiltro = {
chave: string
entrada: ComponenteEntrada
// biome-ignore lint/suspicious/noExplicitAny: dynamic value
valor: any
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic typing needed
function rotuloDoFiltro(f: FiltroBase<any>) {
return f.rotulo
}
import { CircleHelp, Filter, Plus, Trash2, X } from "@lucide/vue"
import { computed, defineComponent, type PropType } from "vue"
import type { FiltroBase } from "./useFiltroAvancado"
// biome-ignore lint/correctness/noUnusedImports: rotuloDoFiltro é usado no template Vue (invisível ao linter estático)
import { rotuloDoFiltro, useFiltroAvancado } from "./useFiltroAvancado"
import "./EliTabelaModalFiltroAvancado.css"
export default defineComponent({
name: "EliTabelaModalFiltroAvancado",
components: { CircleHelp, Filter, Plus, Trash2, X },
props: {
aberto: { type: Boolean, required: true },
filtrosBase: {
// biome-ignore lint/suspicious/noExplicitAny: generic component
type: Array as PropType<Array<FiltroBase<any>>>,
required: true,
},
filtrosBase: { type: Array as PropType<FiltroBase<string>[]>, required: true },
modelo: {
// biome-ignore lint/suspicious/noExplicitAny: dynamic model
type: Array as PropType<Array<any>>,
/** Linhas persistidas do pai — normalizadas internamente via normalizarModelo() */
type: Array as PropType<Array<{ chave: string; valor: unknown }>>,
required: true,
},
modeloFormula: { type: String, default: "" },
},
emits: {
fechar: () => true,
limpar: () => true,
// biome-ignore lint/suspicious/noExplicitAny: dynamic lines
salvar: (_linhas: any[]) => true,
/** Dado serializável emitido pelo botão Aplicar */
salvar: (_linhas: Array<{ chave: string; valor: unknown }>, _formula?: string) => true,
},
setup(props, { emit }) {
const linhas = ref<Array<LinhaFiltro>>([])
const chaveParaAdicionar = ref<string>("")
const chavesDisponiveis = computed(() =>
(props.filtrosBase ?? []).map((b) => String(b.chave)),
)
const opcoesParaAdicionar = computed(() => {
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
return (props.filtrosBase ?? []).filter(
(b) => !usadas.has(String(b.chave)),
)
return useFiltroAvancado({
aberto: computed(() => props.aberto),
filtrosBase: computed(() => props.filtrosBase),
modelo: computed(() => props.modelo),
modeloFormula: computed(() => props.modeloFormula),
emit,
})
function componenteEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return EliEntradaNumero
if (tipo === "dataHora") return EliEntradaDataHora
return EliEntradaTexto
}
function opcoesEntrada(entrada: ComponenteEntrada) {
// o rótulo vem do próprio componente (entrada[1].rotulo)
// biome-ignore lint/suspicious/noExplicitAny: dynamic options
return (entrada?.[1] as any) ?? { rotulo: "" }
}
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return null
return ""
}
function normalizarModelo() {
const base = props.filtrosBase ?? []
const modelo = Array.isArray(props.modelo) ? props.modelo : []
linhas.value = modelo
// biome-ignore lint/suspicious/noExplicitAny: dynamic model item
.map((m: any) => {
const baseItem =
base.find((b) => String(b.chave) === String(m.chave)) ?? base[0]
if (!baseItem) return null
const entrada = (baseItem.entrada ?? m.entrada) as ComponenteEntrada
const chave = String(baseItem.chave ?? m.chave)
const val = m.valor ?? valorInicialPorEntrada(entrada)
return {
chave: chave,
entrada,
valor: val,
} as LinhaFiltro
})
.filter(Boolean) as LinhaFiltro[]
// se algum filtro mudou a chave para valor inválido, ajusta
linhas.value = linhas.value.filter((l) =>
chavesDisponiveis.value.includes(String(l.chave)),
)
}
watch(
() => [props.aberto, props.filtrosBase, props.modelo] as const,
() => {
if (props.aberto) normalizarModelo()
},
{ deep: true, immediate: true },
)
function adicionar() {
if (!chaveParaAdicionar.value) return
const b0 = (props.filtrosBase ?? []).find(
(b) => String(b.chave) === String(chaveParaAdicionar.value),
)
if (!b0) return
// evita repetição
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
linhas.value.push({
chave: String(b0.chave),
entrada: b0.entrada,
valor: valorInicialPorEntrada(b0.entrada),
})
chaveParaAdicionar.value = ""
}
function remover(idx: number) {
linhas.value.splice(idx, 1)
}
function emitFechar() {
emit("fechar")
}
function emitLimpar() {
emit("limpar")
}
function emitSalvar() {
emit(
"salvar",
linhas.value.map((l) => ({
chave: l.chave,
valor: l.valor,
})),
)
}
return {
linhas,
opcoesParaAdicionar,
chaveParaAdicionar,
componenteEntrada,
opcoesEntrada,
adicionar,
remover,
emitFechar,
emitSalvar,
emitLimpar,
rotuloDoFiltro,
}
},
})
</script>
<style>
.eli-tabela-modal-filtro__overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 4000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.eli-tabela-modal-filtro__modal {
width: min(980px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 18px 60px rgba(15, 23, 42, 0.25);
overflow: hidden;
}
.eli-tabela-modal-filtro__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__titulo {
font-size: 1rem;
margin: 0;
}
.eli-tabela-modal-filtro__fechar {
width: 34px;
height: 34px;
border-radius: 10px;
border: none;
background: transparent;
cursor: pointer;
font-size: 22px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-filtro__fechar:hover,
.eli-tabela-modal-filtro__fechar:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__conteudo {
padding: 16px;
}
.eli-tabela-modal-filtro__vazio {
opacity: 0.75;
font-size: 0.9rem;
}
.eli-tabela-modal-filtro__lista {
display: grid;
gap: 10px;
}
.eli-tabela-modal-filtro__linha {
display: grid;
grid-template-columns: 1fr 34px;
gap: 10px;
align-items: center;
}
.eli-tabela-modal-filtro__select {
height: 34px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
padding: 0 10px;
background: #fff;
}
.eli-tabela-modal-filtro__entrada {
min-width: 0;
}
.eli-tabela-modal-filtro__remover {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 18px;
line-height: 1;
color: rgba(15, 23, 42, 0.8);
}
.eli-tabela-modal-filtro__remover:hover,
.eli-tabela-modal-filtro__remover:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__acoes {
margin-top: 12px;
}
.eli-tabela-modal-filtro__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.eli-tabela-modal-filtro__botao {
height: 34px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.eli-tabela-modal-filtro__botao:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.eli-tabela-modal-filtro__botao--sec:hover,
.eli-tabela-modal-filtro__botao--sec:focus-visible {
background: rgba(15, 23, 42, 0.06);
}
.eli-tabela-modal-filtro__botao--prim {
border: none;
background: rgba(37, 99, 235, 0.95);
color: #fff;
}
.eli-tabela-modal-filtro__botao--prim:hover,
.eli-tabela-modal-filtro__botao--prim:focus-visible {
background: rgba(37, 99, 235, 1);
}
@media (max-width: 880px) {
.eli-tabela-modal-filtro__linha {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,87 @@
.eli-tabela__paginacao {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.eli-tabela__pagina-botao {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: var(--cor-eli-superficie);
font-size: 0.875rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.82);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease;
}
.eli-tabela__pagina-botao:hover,
.eli-tabela__pagina-botao:focus-visible {
background-color: var(--cor-eli-primaria-bg);
border-color: var(--cor-eli-primaria-clara);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__pagina-botao:focus-visible {
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
}
.eli-tabela__pagina-botao:disabled {
cursor: default;
opacity: 0.5;
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.75);
}
.eli-tabela__pagina-botao--ativo {
background: var(--cor-eli-primaria-bg);
border-color: var(--cor-eli-primaria);
color: var(--cor-eli-primaria-escura);
}
.eli-tabela__pagina-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
color: var(--cor-eli-neutro);
font-size: 0.9rem;
}
.eli-tabela__acao-inferior {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--cor-eli-primaria);
background: var(--cor-eli-superficie);
color: var(--cor-eli-primaria);
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acao-inferior:hover,
.eli-tabela__acao-inferior:focus-visible {
background-color: var(--cor-eli-primaria-bg);
}
.eli-tabela__acao-inferior:focus-visible {
outline: 2px solid var(--cor-eli-primaria);
outline-offset: 2px;
}

View file

@ -77,7 +77,7 @@
<script lang="ts">
import { computed, defineComponent, type PropType } from "vue"
import "./EliTabelaPaginacao.css"
export default defineComponent({
name: "EliTabelaPaginacao",
props: {
@ -209,9 +209,7 @@ export default defineComponent({
}
const anteriorDesabilitado = computed(() => props.pagina <= 1)
const proximaDesabilitada = computed(
() => props.pagina >= props.totalPaginas,
)
const proximaDesabilitada = computed(() => props.pagina >= props.totalPaginas)
const paginaAtual = computed(() => props.pagina)
const totalPaginasExibidas = computed(() => props.totalPaginas)
@ -227,92 +225,3 @@ export default defineComponent({
})
</script>
<style>
.eli-tabela__paginacao {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.eli-tabela__pagina-botao {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #ffffff;
font-size: 0.875rem;
font-weight: 500;
color: rgba(15, 23, 42, 0.82);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease;
}
.eli-tabela__pagina-botao:hover,
.eli-tabela__pagina-botao:focus-visible {
background-color: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__pagina-botao:disabled {
cursor: default;
opacity: 0.5;
background: rgba(148, 163, 184, 0.08);
border-color: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.75);
}
.eli-tabela__pagina-botao--ativo {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.4);
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__pagina-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
color: rgba(107, 114, 128, 0.85);
font-size: 0.9rem;
}
.eli-tabela__acao-inferior {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid #16a34a; /* default green */
background: #ffffff;
color: #16a34a;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.eli-tabela__acao-inferior:hover,
.eli-tabela__acao-inferior:focus-visible {
background-color: #f0fdf4;
}
.eli-tabela__acao-inferior:focus-visible {
outline: 2px solid #16a34a;
outline-offset: 2px;
}
</style>

View file

@ -29,15 +29,11 @@ export default defineComponent({
},
setup(props) {
const tipo = computed(() => props.celula[0] as tipoTabelaCelula)
const dados = computed(
() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula],
)
const dados = computed(() => props.celula[1] as tiposTabelaCelulas[tipoTabelaCelula])
// Observação: mantemos o registry tipado, mas o TS do template não consegue
// fazer narrowing do componente com base em `tipo`, então tipamos como `Component`.
const Componente = computed(
() => registryTabelaCelulas[tipo.value] as unknown as Component,
)
const Componente = computed(() => registryTabelaCelulas[tipo.value] as unknown as Component)
const dadosParaComponente = computed(() => dados.value)

View file

@ -12,7 +12,13 @@
</template>
<script lang="ts">
import { dayjsbr } from "p-comuns"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import "dayjs/locale/pt-br"
dayjs.extend(relativeTime)
dayjs.locale("pt-br")
import { computed, defineComponent, type PropType } from "vue"
import type { tiposTabelaCelulas } from "./tiposTabelaCelulas"
@ -24,27 +30,27 @@ export default defineComponent({
required: false,
},
},
setup({ dados }) {
setup(props) {
const textoData = computed(() => {
const valorIso = dados?.valor
const valorIso = props.dados?.valor
if (!valorIso) return ""
const formato = dados?.formato ?? "data"
const formato = props.dados?.formato ?? "data"
if (formato === "relativo") {
return dayjsbr(valorIso).fromNow()
return dayjs(valorIso).fromNow()
}
if (formato === "data_hora") {
// Padrão pt-BR simples (sem depender de locale do dayjsbr)
return dayjsbr(valorIso).format("DD/MM/YYYY HH:mm")
return dayjs(valorIso).format("DD/MM/YYYY HH:mm")
}
// formato === "data"
return dayjsbr(valorIso).format("DD/MM/YYYY")
return dayjs(valorIso).format("DD/MM/YYYY")
})
return { dados, textoData }
return { textoData }
},
})
</script>
@ -56,7 +62,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -66,7 +72,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -35,12 +35,12 @@ export default defineComponent({
type: Object as PropType<tiposTabelaCelulas["numero"]>,
},
},
setup({ dados }) {
setup(props) {
const textoNumero = computed(() => {
// Mantemos o comportamento anterior (trocar "." por ","), mas agora suportamos prefixo/sufixo.
const numero = String(dados?.numero).replace(".", ",")
const prefixo = dados?.prefixo?.trim()
const sufixo = dados?.sufixo?.trim()
const numero = String(props.dados?.numero).replace(".", ",")
const prefixo = props.dados?.prefixo?.trim()
const sufixo = props.dados?.sufixo?.trim()
const inicio = prefixo ? `${prefixo} ` : ""
const fim = sufixo ? ` ${sufixo}` : ""
@ -48,7 +48,7 @@ export default defineComponent({
return `${inicio}${numero}${fim}`
})
return { dados, textoNumero }
return { textoNumero }
},
})
</script>
@ -60,7 +60,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -70,7 +70,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -37,9 +37,6 @@ export default defineComponent({
required: false,
},
},
setup({ dados }) {
return { dados }
},
})
</script>

View file

@ -26,9 +26,6 @@ export default defineComponent({
return {}
},
methods: {},
setup({ dados }) {
return { dados }
},
})
</script>
@ -39,7 +36,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -49,7 +46,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -23,9 +23,6 @@ export default defineComponent({
type: Object as PropType<tiposTabelaCelulas["textoTruncado"]>,
},
},
setup({ dados }) {
return { dados }
},
})
</script>
@ -45,7 +42,7 @@ export default defineComponent({
color: #2563eb;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(37, 99, 235, 0.55);
text-decoration-color: var(--cor-eli-primaria-clara);
text-underline-offset: 2px;
}
@ -55,7 +52,7 @@ export default defineComponent({
}
.eli-tabela__celula-link:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline: 2px solid var(--cor-eli-primaria-clara);
outline-offset: 2px;
border-radius: 4px;
}

View file

@ -6,6 +6,16 @@ import EliTabelaCelulaTextoSimples from "./EliTabelaCelulaTextoSimples.vue"
import EliTabelaCelulaTextoTruncado from "./EliTabelaCelulaTextoTruncado.vue"
import type { tipoTabelaCelula } from "./tiposTabelaCelulas"
/**
* Mapa (dispatch table) que associa cada `tipoTabelaCelula` ao seu componente Vue de renderização.
* Usado por `EliTabelaCelula.vue` para resolver dinamicamente qual componente renderizar
* a partir do tipo retornado por `tipoEliColuna.celula(linha)`.
*
* Adicionar um novo tipo de célula requer:
* 1. Criar o componente `EliTabelaCelula<Tipo>.vue`
* 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts)
* 3. Registrar aqui no mapa
*/
export const registryTabelaCelulas = {
textoSimples: EliTabelaCelulaTextoSimples,
textoTruncado: EliTabelaCelulaTextoTruncado,

View file

@ -1,12 +1,18 @@
export type EliTabelaColunasConfig = {
/** Rotulos das colunas visiveis (em ordem). */
/** Rótulos das colunas visíveis (em ordem). */
visiveis: string[]
/** Rotulos das colunas invisiveis. */
/** Rótulos das colunas invisíveis. */
invisiveis: string[]
}
const STORAGE_PREFIX = "eli:tabela"
/**
* Retorna a chave de `localStorage` utilizada para persistir a configuração de colunas de uma tabela.
*
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
* @returns Chave no formato `eli:tabela:<nomeTabela>:colunas`
*/
export function storageKeyColunas(nomeTabela: string) {
return `${STORAGE_PREFIX}:${nomeTabela}:colunas`
}
@ -26,9 +32,14 @@ function normalizarConfig(valor: unknown): EliTabelaColunasConfig {
return { visiveis, invisiveis }
}
export function carregarConfigColunas(
nomeTabela: string,
): EliTabelaColunasConfig {
/**
* Carrega do `localStorage` a configuração de visibilidade e ordem das colunas de uma tabela.
* Em caso de dado corrompido ou ausente, retorna configuração vazia (sem colunas fixadas).
*
* @param nomeTabela - Identificador único da tabela
* @returns Configuração salva ou `{ visiveis: [], invisiveis: [] }` como fallback seguro
*/
export function carregarConfigColunas(nomeTabela: string): EliTabelaColunasConfig {
try {
const raw = window.localStorage.getItem(storageKeyColunas(nomeTabela))
if (!raw) return { visiveis: [], invisiveis: [] }
@ -38,24 +49,34 @@ export function carregarConfigColunas(
}
}
export function salvarConfigColunas(
nomeTabela: string,
config: EliTabelaColunasConfig,
) {
/**
* Persiste no `localStorage` a configuração de visibilidade e ordem das colunas.
* Falhas de armazenamento (modo privado, storage cheio) são silenciadas.
*
* @param nomeTabela - Identificador único da tabela
* @param config - Configuração com listas de colunas visíveis e invisíveis
*/
export function salvarConfigColunas(nomeTabela: string, config: EliTabelaColunasConfig) {
try {
window.localStorage.setItem(
storageKeyColunas(nomeTabela),
JSON.stringify(normalizarConfig(config)),
)
} catch {
// ignore
// Silencia erros de storage (modo privado, quota excedida)
}
}
/**
* Remove do `localStorage` a configuração de colunas salva para uma tabela.
* Usado para restaurar o estado padrão de visibilidade definido nas props.
*
* @param nomeTabela - Identificador único da tabela
*/
export function limparConfigColunas(nomeTabela: string) {
try {
window.localStorage.removeItem(storageKeyColunas(nomeTabela))
} catch {
// ignore
// Silencia erros de storage
}
}

View file

@ -1,43 +1,77 @@
export type EliTabelaFiltroAvancadoSalvo<T> = Array<{
coluna: keyof T
valor: T[keyof T]
}>
export type EliTabelaFiltroAvancadoSalvo<T> = {
linhas: Array<{
chave: keyof T
/** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */
valor: unknown
}>
formula?: string
}
function key(nomeTabela: string) {
return `eli_tabela:${nomeTabela}:filtro_avancado`
}
export function carregarFiltroAvancado<T>(
nomeTabela: string,
): EliTabelaFiltroAvancadoSalvo<T> {
/**
* Carrega do `localStorage` as linhas de filtro avançado salvas para uma tabela.
* Trata dois formatos legados: array direto de filtros (v1) e objeto `{ linhas, formula }` (v2).
* Em caso de dado ausente, corrompido ou formato desconhecido, retorna estado vazio.
*
* @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`)
* @returns Estado de filtros salvo, ou `{ linhas: [], formula: '' }` como fallback seguro
*/
export function carregarFiltroAvancado<T>(nomeTabela: string): EliTabelaFiltroAvancadoSalvo<T> {
const vazio: EliTabelaFiltroAvancadoSalvo<T> = { linhas: [], formula: "" }
try {
const raw = localStorage.getItem(key(nomeTabela))
if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
if (!raw) return vazio
const parsed = JSON.parse(raw)
return Array.isArray(parsed)
? (parsed as EliTabelaFiltroAvancadoSalvo<T>)
: ([] as unknown as EliTabelaFiltroAvancadoSalvo<T>)
if (Array.isArray(parsed)) {
return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo<T>
}
if (parsed && typeof parsed === "object") {
return {
linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [],
formula: typeof parsed.formula === "string" ? parsed.formula : "",
} as EliTabelaFiltroAvancadoSalvo<T>
}
return vazio
} catch {
return [] as unknown as EliTabelaFiltroAvancadoSalvo<T>
return vazio
}
}
/**
* Persiste no `localStorage` o estado do filtro avançado de uma tabela.
* Salva tanto as linhas de filtro quanto a fórmula booleana customizada.
* Falhas de armazenamento são silenciadas para não interromper o fluxo do usuário.
*
* @param nomeTabela - Identificador único da tabela
* @param filtros - Estado completo dos filtros a persistir
*/
export function salvarFiltroAvancado<T>(
nomeTabela: string,
filtros: EliTabelaFiltroAvancadoSalvo<T>,
) {
try {
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? []))
localStorage.setItem(key(nomeTabela), JSON.stringify(filtros ?? { linhas: [] }))
} catch {
// ignore
// Silencia erros de storage (modo privado, quota excedida)
}
}
/**
* Remove do `localStorage` os filtros avançados salvos para uma tabela.
* Chamado ao executar "Limpar Filtros" no modal de filtro avançado.
*
* @param nomeTabela - Identificador único da tabela
*/
export function limparFiltroAvancado(nomeTabela: string) {
try {
localStorage.removeItem(key(nomeTabela))
} catch {
// ignore
// Silencia erros de storage
}
}

View file

@ -1,14 +1,9 @@
import type { LucideIcon } from "@lucide/vue"
import type { tipoFiltro26 } from "p-comuns"
export type tipoFiltro<T> = tipoFiltro26<T>
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
import type { tipoResposta } from "p-respostas"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type {
tiposTabelaCelulas,
tipoTabelaCelula,
} from "./celulas/tiposTabelaCelulas"
import type { tiposTabelaCelulas, tipoTabelaCelula } from "./celulas/tiposTabelaCelulas"
export type tipoComponenteCelulaBase<T extends tipoTabelaCelula> = readonly [
T,
@ -19,6 +14,20 @@ export type tipoComponenteCelula = {
[K in tipoTabelaCelula]: tipoComponenteCelulaBase<K>
}[tipoTabelaCelula]
/**
* Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela.
* Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`,
* garantindo que não seja possível passar dados incorretos para um tipo de célula.
*
* Uso esperado dentro de `tipoEliColuna.celula`:
* ```ts
* celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome })
* ```
*
* @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`)
* @param dados - Dados tipados para o tipo de célula escolhido
* @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue`
*/
export const celulaTabela = <T extends tipoTabelaCelula>(
tipo: T,
dados: tiposTabelaCelulas[T],
@ -72,7 +81,7 @@ export type tipoEliTabelaAcao<T> = {
}
export type parametrosConsulta<T> = {
filtros?: tipoFiltro26<T>[]
filtros?: tipoFiltroDeltaColunaTipada<Extract<keyof T, string>>[]
coluna_ordem?: keyof T
direcao_ordem?: "asc" | "desc"
offSet?: number
@ -152,7 +161,7 @@ export type tipoEliTabelaConsulta<T> = {
/** Rótulo exibido no select de adicionar filtro */
rotulo: string
/** vai gerar filtro e mescla com os filtros já existentes */
filtro: (valor: unknown) => tipoFiltro26<T>
filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada<Extract<keyof T, string>> | undefined
/** componente de entrada que vai receber o valor para o filtro */
entrada: ComponenteEntrada
}[]

View file

@ -0,0 +1,139 @@
/**
* useColunasOrdenacao
* Composable que encapsula a lógica de drag-and-drop e sincronização de estado
* do modal de configuração de colunas da EliTabela.
*
* Responsável por:
* - Sincronizar colunas visíveis/invisíveis com a configuração persistida
* - Aplicar ordem salva e adicionar novas colunas ao final
* - Gerenciar eventos de arrastar-e-soltar entre as duas listas
*
* Extraído de `EliTabelaModalColunas.vue` para manter o componente abaixo de 250 linhas.
*/
import { type Ref, ref, watch } from "vue"
import type { EliTabelaColunasConfig } from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
type OrigemLista = "visiveis" | "invisiveis"
interface UseColunasOrdenacaoOptions {
aberto: Ref<boolean>
rotulosColunas: Ref<string[]>
configInicial: Ref<EliTabelaColunasConfig>
// biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai
colunas: Ref<tipoEliColuna<any>[]>
}
/**
* Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas.
* Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização)
* @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela
* @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis)
* @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão)
* @returns Estado reativo e handlers de drag-and-drop prontos para o template
*/
export function useColunasOrdenacao({
aberto,
rotulosColunas,
configInicial,
colunas,
}: UseColunasOrdenacaoOptions) {
const visiveisLocal = ref<string[]>([])
const invisiveisLocal = ref<string[]>([])
const itemSendoArrastado = ref<string | null>(null)
function sincronizarEstado() {
const todos = rotulosColunas.value
const configTemDados =
(configInicial.value.visiveis?.length ?? 0) > 0 ||
(configInicial.value.invisiveis?.length ?? 0) > 0
const invisiveisPadraoSet = new Set(
colunas.value.filter((c) => c.visivel === false).map((c) => c.rotulo),
)
const invisiveisSet = configTemDados
? new Set(configInicial.value.invisiveis ?? [])
: invisiveisPadraoSet
const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r))
const ordemSalva = configInicial.value.visiveis ?? []
const setVis = new Set(baseVisiveis)
const ordenadas: string[] = []
for (const r of ordemSalva) {
if (setVis.has(r)) ordenadas.push(r)
}
for (const r of baseVisiveis) {
if (!ordenadas.includes(r)) ordenadas.push(r)
}
visiveisLocal.value = ordenadas
invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r))
}
watch(
() => [aberto.value, rotulosColunas.value, configInicial.value, colunas.value] as const,
() => {
if (aberto.value) sincronizarEstado()
},
{ deep: true, immediate: true },
)
function onDragStart(e: DragEvent, rotulo: string) {
itemSendoArrastado.value = rotulo
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", rotulo)
e.dataTransfer.effectAllowed = "move"
}
}
function onDragEnd() {
itemSendoArrastado.value = null
}
function onDragOverItem(alvoRotulo: string, destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo || draggedRotulo === alvoRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
const origem = origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null
if (!origem) return
const origemArr = origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
const dragIdx = origemArr.indexOf(draggedRotulo)
const alvoIdx = destinoArr.indexOf(alvoRotulo)
origemArr.splice(dragIdx, 1)
destinoArr.splice(alvoIdx, 0, draggedRotulo)
}
function onDragOverLista(destino: OrigemLista) {
const draggedRotulo = itemSendoArrastado.value
if (!draggedRotulo) return
const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value
if (destinoArr[destinoArr.length - 1] === draggedRotulo) return
const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo)
const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo)
if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1)
if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1)
destinoArr.push(draggedRotulo)
}
return {
visiveisLocal,
invisiveisLocal,
itemSendoArrastado,
onDragStart,
onDragEnd,
onDragOverItem,
onDragOverLista,
}
}

View file

@ -0,0 +1,105 @@
/**
* useEliTabela
* Orquestrador principal da EliTabela — compõe todos os sub-composables
* e retorna o estado unificado para o template do componente.
*
* Composables compostos:
* - `useEliTabelaConsulta` — carregamento, paginação, ordenação, busca
* - `useEliTabelaFiltrosAvancados` — filtros avançados, modal e persistência
* - `useEliTabelaSelecao` — seleção múltipla de linhas
* - `useEliTabelaColunas` — visibilidade, ordem e modal de colunas
* - `useEliTabelaMenu` — menu de ações por linha e posicionamento de popup
*
* @see EliTabela.vue — componente que consome este composable
*/
import { onMounted, ref, watch } from "vue"
import { carregarConfigColunas } from "./colunasStorage"
import { carregarFiltroAvancado } from "./filtroAvancadoStorage"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
import { useEliTabelaColunas } from "./useEliTabelaColunas"
import { useEliTabelaConsulta } from "./useEliTabelaConsulta"
import { useEliTabelaFiltrosAvancados } from "./useEliTabelaFiltrosAvancados"
import { useEliTabelaMenu } from "./useEliTabelaMenu"
import { useEliTabelaSelecao } from "./useEliTabelaSelecao"
// biome-ignore lint/suspicious/noExplicitAny: dynamic table — genérico passado por prop
type TabelaProps = tipoEliTabelaConsulta<any>
export function useEliTabela(
props: { tabela: TabelaProps; selecionados: unknown[]; esconderDebug: boolean },
emit: (event: "update:selecionados", value: unknown[]) => void,
) {
// Estado compartilhado entre sub-composables (passado por ref para reatividade)
const paginaAtual = ref(1)
const valorBusca = ref<string>("")
// Filtros avançados (precisa de carregar que ainda não existe; passa proxy que usará o da consulta)
// Usamos um ref de função para quebrar a dependência circular: filtros precisam de carregar,
// e consulta precisa de filtrosAvancadosAtivos.
let _carregarProxy: () => Promise<void> = async () => {
/* será atribuído após consulta ser criada */
}
const carregarProxy = () => _carregarProxy()
const filtros = useEliTabelaFiltrosAvancados(props, paginaAtual, valorBusca, carregarProxy)
// Colunas: temAcoes vem da consulta — usamos um ref intermediário
const _temAcoesRef = ref(false)
const colunas = useEliTabelaColunas(props, _temAcoesRef)
const consulta = useEliTabelaConsulta(
props,
filtros.filtrosAvancadosAtivos,
paginaAtual,
valorBusca,
colunas.linhasExpandidas,
)
// Conecta o proxy ao carregar real
_carregarProxy = consulta.carregar
// Sincroniza temAcoes para o composable de colunas
// biome-ignore lint/suspicious/noExplicitAny: ref sync interno
;(_temAcoesRef as any).value = consulta.temAcoes.value
watch(consulta.temAcoes, (v) => {
_temAcoesRef.value = v
})
const selecao = useEliTabelaSelecao(props, emit, consulta.linhasPaginadas)
const menu = useEliTabelaMenu(props, consulta.acoesVisiveis)
// Watch: troca de configuração de tabela reseta todos os estados e recarrega
watch(
() => props.tabela,
() => {
menu.menuAberto.value = null
consulta.colunaOrdenacao.value = null
consulta.direcaoOrdenacao.value = "asc"
valorBusca.value = ""
colunas.modalColunasAberto.value = false
filtros.modalFiltroAberto.value = false
colunas.configColunas.value = carregarConfigColunas(props.tabela.nome)
const filtrosRecarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
filtros.filtrosUi.value = filtrosRecarregados.linhas
colunas.linhasExpandidas.value = {}
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void consulta.carregar()
},
)
onMounted(() => void consulta.carregar())
return {
...consulta,
...filtros,
...selecao,
...colunas,
menuAberto: menu.menuAberto,
menuPopup: menu.menuPopup,
menuPopupPos: menu.menuPopupPos,
possuiAcoes: menu.possuiAcoes,
toggleMenu: menu.toggleMenu,
acoesDisponiveisPorLinha: menu.acoesDisponiveisPorLinha,
paginaAtual,
valorBusca,
}
}

View file

@ -0,0 +1,129 @@
/**
* useEliTabelaColunas
* Gerencia visibilidade, ordenação e persistência da configuração de colunas da EliTabela.
* Controla também o modal de colunas e o estado de linhas expandidas (detalhe de colunas invisíveis).
*
* @param props - Props do componente (tabela.colunas, tabela.nome)
* @param temAcoes - Computed que indica se existem ações por linha (para totalColunas)
*/
import { computed, type Ref, ref } from "vue"
import {
carregarConfigColunas,
type EliTabelaColunasConfig,
salvarConfigColunas,
} from "./colunasStorage"
import type { tipoEliColuna } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: colunas dinâmicas — genérico passado por prop
type PropsColunas = { tabela: { colunas: tipoEliColuna<any>[]; nome: string } }
export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref<boolean>) {
const modalColunasAberto = ref(false)
const configColunas = ref<EliTabelaColunasConfig>(carregarConfigColunas(props.tabela.nome))
const linhasExpandidas = ref<Record<number, boolean>>({})
const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo))
const colunasInvisiveisEfetivas = computed(() => {
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const colunas = props.tabela.colunas as tipoEliColuna<any>[]
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: colunas.filter((c) => c.visivel === false).map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const base = colunas.filter((c) => invisiveisSet.has(c.rotulo))
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, tipoEliColuna<any>>()
for (const c of base) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic column
const ordenadas: tipoEliColuna<any>[] = []
for (const r of invisiveisBaseRotulos) {
const c = mapa.get(r)
if (c) ordenadas.push(c)
}
for (const c of base) {
if (!ordenadas.includes(c)) ordenadas.push(c)
}
return ordenadas
})
const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0)
const colunasEfetivas = computed(() => {
const colunas = props.tabela.colunas
const todosRotulos = rotulosColunas.value
const configTemDados =
(configColunas.value.visiveis?.length ?? 0) > 0 ||
(configColunas.value.invisiveis?.length ?? 0) > 0
const invisiveisBaseRotulos = configTemDados
? (configColunas.value.invisiveis ?? [])
: // biome-ignore lint/suspicious/noExplicitAny: dynamic column
(props.tabela.colunas as tipoEliColuna<any>[])
.filter((c) => c.visivel === false)
.map((c) => c.rotulo)
const invisiveisSet = new Set(invisiveisBaseRotulos)
const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r))
const visiveisSet = new Set(visiveisBaseRotulos)
const ordemSalva = configTemDados ? (configColunas.value.visiveis ?? []) : []
const ordemFinal: string[] = []
for (const r of ordemSalva) {
if (visiveisSet.has(r)) ordemFinal.push(r)
}
for (const r of visiveisBaseRotulos) {
if (!ordemFinal.includes(r)) ordemFinal.push(r)
}
// biome-ignore lint/suspicious/noExplicitAny: dynamic map
const mapa = new Map<string, any>()
for (const c of colunas) {
if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c)
}
return ordemFinal.map((r) => mapa.get(r)).filter(Boolean)
})
const totalColunas = computed(
() =>
colunasEfetivas.value.length +
(temAcoes.value ? 1 : 0) +
(temColunasInvisiveis.value ? 1 : 0),
)
function abrirModalColunas() {
modalColunasAberto.value = true
}
function fecharModalColunas() {
modalColunasAberto.value = false
}
function salvarModalColunas(cfg: EliTabelaColunasConfig) {
configColunas.value = cfg
salvarConfigColunas(props.tabela.nome, cfg)
modalColunasAberto.value = false
linhasExpandidas.value = {}
}
function alternarLinhaExpandida(indice: number) {
const atual = Boolean(linhasExpandidas.value[indice])
linhasExpandidas.value = { ...linhasExpandidas.value, [indice]: !atual }
}
return {
modalColunasAberto,
configColunas,
linhasExpandidas,
rotulosColunas,
colunasEfetivas,
colunasInvisiveisEfetivas,
temColunasInvisiveis,
totalColunas,
abrirModalColunas,
fecharModalColunas,
salvarModalColunas,
alternarLinhaExpandida,
}
}

View file

@ -0,0 +1,247 @@
/**
* useEliTabelaConsulta
* Gerencia o ciclo de consultas da EliTabela: carregamento, paginação, ordenação e busca.
* Usa um contador sequencial para descartar respostas de consultas antigas (race condition).
*
* @param props - Props do componente (tabela, esconderDebug)
* @param filtrosAvancadosAtivos - Computed com filtros ativos para envio à API
* @param paginaAtual - Ref da página atual (compartilhada com filtros e busca)
* @param valorBusca - Ref da busca textual (compartilhada com filtros avançados)
* @param linhasExpandidas - Ref das linhas expandidas (resetada ao carregar)
*/
import type { tipoFiltroDeltaColunaTipada } from "p-comuns"
import { codigosResposta } from "p-respostas"
import { computed, type Ref, ref, watch } from "vue"
import type { parametrosConsulta, tipoEliTabelaConsulta } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: genérico passado por prop
type PropsConsulta = { tabela: tipoEliTabelaConsulta<any>; esconderDebug: boolean }
export function useEliTabelaConsulta(
props: PropsConsulta,
// biome-ignore lint/suspicious/noExplicitAny: genérico
filtrosAvancadosAtivos: Ref<tipoFiltroDeltaColunaTipada<any>[]>,
paginaAtual: Ref<number>,
valorBusca: Ref<string>,
linhasExpandidas: Ref<Record<number, boolean>>,
) {
const isDev = import.meta.env.DEV && !props.esconderDebug
const carregando = ref(false)
const erro = ref<string | null>(null)
const linhas = ref<unknown[]>([])
const quantidade = ref<number>(0)
const acoesVisiveis = ref<boolean[][]>([])
const colunaOrdenacao = ref<string | null>(null)
const direcaoOrdenacao = ref<"asc" | "desc">("asc")
const tabela = computed(() => props.tabela)
const registrosPorConsulta = computed(() => {
const valor = props.tabela.registros_por_consulta
return typeof valor === "number" && valor > 0 ? Math.floor(valor) : 10
})
const totalPaginas = computed(() => {
const limite = registrosPorConsulta.value
if (!limite || limite <= 0) return 1
const total = quantidade.value ?? 0
return total ? Math.max(1, Math.ceil(total / limite)) : 1
})
const linhasPaginadas = computed(() => linhas.value ?? [])
const quantidadeFiltrada = computed(() => quantidade.value ?? 0)
const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0)
const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca))
const acoesSuperiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"),
)
const acoesInferiores = computed(() =>
(props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"),
)
const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0)
const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0)
const parametrosConsultaAtuais = computed(() => {
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
// biome-ignore lint/suspicious/noExplicitAny: dynamic params
const params: parametrosConsulta<any> = { offSet: offset, limit: limite }
const busca = (valorBusca.value ?? "").trim()
if (busca) {
params.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) params.filtros = filtros
}
if (colunaOrdenacao.value) {
params.coluna_ordem = colunaOrdenacao.value
params.direcao_ordem = direcaoOrdenacao.value
}
return {
...params,
atualizarConsulta: async () => {
await carregar()
},
// biome-ignore lint/suspicious/noExplicitAny: dynamic list update
editarLista: async (novaLista: any[]) => {
linhas.value = novaLista
return novaLista
},
}
})
let carregamentoSequencial = 0
async function carregar() {
const idCarregamento = ++carregamentoSequencial
carregando.value = true
erro.value = null
try {
acoesVisiveis.value = []
linhasExpandidas.value = {}
const limite = Math.max(1, registrosPorConsulta.value)
const offset = (paginaAtual.value - 1) * limite
const parametros: {
// biome-ignore lint/suspicious/noExplicitAny: generic
filtros?: tipoFiltroDeltaColunaTipada<any>[]
coluna_ordem?: never
direcao_ordem?: "asc" | "desc"
offSet: number
limit: number
texto_busca?: string
} = { offSet: offset, limit: limite }
const busca = (valorBusca.value ?? "").trim()
if (busca) {
parametros.texto_busca = busca
} else {
const filtros = filtrosAvancadosAtivos.value
if (filtros.length) parametros.filtros = filtros
}
if (colunaOrdenacao.value) {
parametros.coluna_ordem = colunaOrdenacao.value as never
parametros.direcao_ordem = direcaoOrdenacao.value
}
if (isDev) console.log("[EliTabela] consulta(parametros)", parametros) // eslint-disable-line no-console
const res = await props.tabela.consulta(parametros)
if (idCarregamento !== carregamentoSequencial) return
if (res.cod !== codigosResposta.sucesso) {
linhas.value = []
quantidade.value = 0
erro.value = res.mensagem
return
}
const valores = res.valor?.valores ?? []
const total = res.valor?.quantidade ?? valores.length
linhas.value = valores
quantidade.value = Number(total) || 0
const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidade.value || 0) / limite))
if (paginaAtual.value > totalPaginasRecalculado) paginaAtual.value = totalPaginasRecalculado
const acoesConf = props.tabela.acoesLinha ?? []
if (!acoesConf.length) {
acoesVisiveis.value = []
return
}
acoesVisiveis.value = valores.map(() =>
acoesConf.map((a) =>
a.exibir === undefined ? true : typeof a.exibir === "boolean" ? a.exibir : false,
),
)
const visibilidade = await Promise.all(
valores.map((linha) =>
Promise.all(
acoesConf.map(async (acao) => {
if (acao.exibir === undefined) return true
if (typeof acao.exibir === "boolean") return acao.exibir
try {
return Boolean(await Promise.resolve(acao.exibir(linha as never)))
} catch {
return false
}
}),
),
),
)
if (idCarregamento === carregamentoSequencial) acoesVisiveis.value = visibilidade
} catch (e) {
console.error("[EliTabela] Erro capturado em carregar():", e)
if (idCarregamento !== carregamentoSequencial) return
linhas.value = []
quantidade.value = 0
erro.value = e instanceof Error ? e.message : "Erro ao carregar dados."
} finally {
if (idCarregamento === carregamentoSequencial) carregando.value = false
}
}
function alternarOrdenacao(chave?: string) {
if (!chave) return
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc"
void carregar()
return
}
colunaOrdenacao.value = chave
direcaoOrdenacao.value = "asc"
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function atualizarBusca(texto: string) {
if (valorBusca.value === texto) return
valorBusca.value = texto
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function irParaPagina(pagina: number) {
const alvo = Math.min(Math.max(1, pagina), totalPaginas.value)
if (alvo !== paginaAtual.value) paginaAtual.value = alvo
}
watch(paginaAtual, (nova, antiga) => {
if (nova !== antiga) void carregar()
})
watch(linhas, () => {
linhasExpandidas.value = {}
})
watch(
() => props.tabela.mostrarCaixaDeBusca,
(mostrar) => {
if (!mostrar && valorBusca.value) {
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
},
)
watch(
() => props.tabela.registros_por_consulta,
() => {
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
},
)
return {
isDev,
tabela,
carregando,
erro,
linhas,
linhasPaginadas,
quantidade,
quantidadeFiltrada,
acoesVisiveis,
colunaOrdenacao,
direcaoOrdenacao,
registrosPorConsulta,
totalPaginas,
temAcoes,
exibirBusca,
acoesSuperiores,
acoesInferiores,
temAcoesSuperiores,
temAcoesInferiores,
parametrosConsultaAtuais,
carregar,
alternarOrdenacao,
atualizarBusca,
irParaPagina,
}
}

View file

@ -0,0 +1,128 @@
/**
* useEliTabelaFiltrosAvancados
* Gerencia o estado de filtros avançados da EliTabela, incluindo:
* - Linhas de filtro ativas (`filtrosUi`) e fórmula booleana (`filtrosFormula`)
* - Computed `filtrosAvancadosAtivos` que converte as linhas em FiltroDelta para a API
* - Abertura/fechamento e limpeza do modal de filtro avançado
*
* A fórmula é armazenada no formato `{chave}` (ex: `{email} E {nome}`) e convertida
* para IDs globais `{c1} {c2}` durante a montagem do FiltroDelta final.
*
* @param props - Props do componente (tabela.filtroAvancado, tabela.nome)
* @param paginaAtual - Ref da página atual (para resetar ao aplicar filtros)
* @param valorBusca - Ref da busca textual (limpa ao aplicar filtros avançados)
* @param carregar - Função de recarregamento dos dados
*/
import type { tipoCondicaoFiltroDelta, tipoFiltroDeltaColunaTipada } from "p-comuns"
import { computed, type Ref, ref } from "vue"
import {
carregarFiltroAvancado,
limparFiltroAvancado,
salvarFiltroAvancado,
} from "./filtroAvancadoStorage"
// biome-ignore lint/suspicious/noExplicitAny: dinâmico — genérico passado por prop
type PropsFiltros = { tabela: { filtroAvancado?: any[]; nome: string } }
type LinhaFiltroUI = { chave: string; valor: unknown }
export function useEliTabelaFiltrosAvancados(
props: PropsFiltros,
paginaAtual: Ref<number>,
valorBusca: Ref<string>,
carregar: () => Promise<void>,
) {
const _filtrosCarregados = carregarFiltroAvancado<Record<string, unknown>>(props.tabela.nome)
const filtrosUi = ref<LinhaFiltroUI[]>(_filtrosCarregados.linhas)
const filtrosFormula = ref<string>(_filtrosCarregados.formula ?? "")
const modalFiltroAberto = ref(false)
const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0)
// biome-ignore lint/suspicious/noExplicitAny: generic
const filtrosAvancadosAtivos = computed<tipoFiltroDeltaColunaTipada<any>[]>(() => {
const base = props.tabela.filtroAvancado ?? []
const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined)
if (ativos.length === 0) return []
const filtrosIndividuais = ativos
.map((f) => {
const b = base.find((x) => String(x.chave) === String(f.chave))
if (!b) return null
return { chave: f.chave, filtro: b.filtro(f.valor) }
})
.filter(
(f): f is { chave: string; filtro: tipoFiltroDeltaColunaTipada<string> } =>
f !== null && f.filtro !== undefined && f.filtro !== null,
)
if (filtrosFormula.value && filtrosIndividuais.length > 1) {
const condicoesFinais: tipoCondicaoFiltroDelta[] = []
let formulaFinal = filtrosFormula.value
filtrosIndividuais.forEach(({ chave }, i) => {
formulaFinal = formulaFinal.replace(new RegExp(`\\{${chave}\\}`, "g"), `{c${i + 1}}`)
})
let globalIdx = 1
for (let i = 0; i < filtrosIndividuais.length; i++) {
const { filtro } = filtrosIndividuais[i]
const idMap: Record<string, string> = {}
for (const cond of filtro.condicoes) {
const novoId = `{c${globalIdx++}}`
idMap[cond.id] = novoId
condicoesFinais.push({ ...cond, id: novoId })
}
let formulaLocal = filtro.formula
for (const [oldId, newId] of Object.entries(idMap)) {
formulaLocal = formulaLocal.replace(new RegExp(`\\{${oldId.slice(1, -1)}\\}`, "g"), newId)
}
const subst = filtro.condicoes.length > 1 ? `(${formulaLocal})` : formulaLocal
formulaFinal = formulaFinal.replace(new RegExp(`\\{c${i + 1}\\}`, "g"), subst)
}
return [{ condicoes: condicoesFinais, formula: formulaFinal }]
}
return filtrosIndividuais.map((f) => f.filtro)
})
function abrirModalFiltro() {
modalFiltroAberto.value = true
}
function fecharModalFiltro() {
modalFiltroAberto.value = false
}
function limparFiltrosAvancados() {
filtrosUi.value = []
filtrosFormula.value = ""
limparFiltroAvancado(props.tabela.nome)
modalFiltroAberto.value = false
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
function salvarFiltrosAvancados(novoLinhas: LinhaFiltroUI[], novaFormula?: string) {
filtrosUi.value = novoLinhas ?? []
filtrosFormula.value = novaFormula ?? ""
salvarFiltroAvancado<Record<string, unknown>>(props.tabela.nome, {
linhas: filtrosUi.value,
formula: filtrosFormula.value,
})
modalFiltroAberto.value = false
valorBusca.value = ""
if (paginaAtual.value !== 1) paginaAtual.value = 1
else void carregar()
}
return {
filtrosUi,
filtrosFormula,
modalFiltroAberto,
exibirFiltroAvancado,
filtrosAvancadosAtivos,
abrirModalFiltro,
fecharModalFiltro,
limparFiltrosAvancados,
salvarFiltrosAvancados,
}
}

View file

@ -0,0 +1,91 @@
/**
* useEliTabelaMenu
* Gerencia o menu de ações por linha da EliTabela.
* Controla abertura/fechamento, posicionamento do popup e detecção de clique externo.
*
* @param props - Props do componente (tabela.acoesLinha)
* @param acoesVisiveis - Ref com matriz de visibilidade das ações por linha
*/
import { nextTick, onBeforeUnmount, onMounted, type Ref, ref } from "vue"
import type { tipoEliTabelaAcao } from "./types-eli-tabela"
// biome-ignore lint/suspicious/noExplicitAny: genérico de linha passado por prop
type PropsMenu = { tabela: { acoesLinha?: tipoEliTabelaAcao<any>[] } }
export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref<boolean[][]>) {
const menuAberto = ref<number | null>(null)
const menuPopup = ref<{ menuEl: HTMLElement | null } | null>(null)
const menuPopupPos = ref({ top: 0, left: 0 })
function atualizarPosicaoMenu(anchor: HTMLElement) {
const rect = anchor.getBoundingClientRect()
const gap = 8
const alturaMenu = menuPopup.value?.menuEl?.offsetHeight ?? 0
const larguraMenu = menuPopup.value?.menuEl?.offsetWidth ?? 180
let top = rect.bottom + gap
const left = rect.right - larguraMenu
if (alturaMenu && top + alturaMenu > window.innerHeight - gap) {
top = rect.top - gap - alturaMenu
}
menuPopupPos.value = {
top: Math.max(gap, Math.round(top)),
left: Math.max(gap, Math.round(left)),
}
}
function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) return
const alvo = evento.target as Node
if (menuPopup.value?.menuEl?.contains(alvo)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value })
}
menuAberto.value = null
}
function acoesDisponiveisPorLinha(i: number) {
const acoesLinha = props.tabela.acoesLinha ?? []
const visibilidade = acoesVisiveis.value[i] ?? []
return acoesLinha
.map((acao, indice) => {
const fallbackVisivel =
acao.exibir === undefined ? true : typeof acao.exibir === "boolean" ? acao.exibir : false
return { acao, indice, visivel: visibilidade[indice] ?? fallbackVisivel }
})
.filter((item) => item.visivel)
}
function possuiAcoes(i: number) {
return acoesDisponiveisPorLinha(i).length > 0
}
function toggleMenu(i: number, evento?: MouseEvent) {
if (!possuiAcoes(i)) return
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value })
}
if (menuAberto.value === i) {
menuAberto.value = null
return
}
menuAberto.value = i
if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log("[EliTabela] toggleMenu => abriu", { i })
}
const anchor = (evento?.currentTarget as HTMLElement | null) ?? null
if (anchor) {
atualizarPosicaoMenu(anchor)
nextTick(() => {
if (menuAberto.value !== null) atualizarPosicaoMenu(anchor)
})
}
}
onMounted(() => document.addEventListener("click", handleClickFora))
onBeforeUnmount(() => document.removeEventListener("click", handleClickFora))
return { menuAberto, menuPopup, menuPopupPos, possuiAcoes, toggleMenu, acoesDisponiveisPorLinha }
}

View file

@ -0,0 +1,96 @@
/**
* useEliTabelaSelecao
* Gerencia a seleção múltipla de linhas na EliTabela.
* Suporta seleção por objeto completo ou por chave configurável (`chave_selecao`).
*
* @param props - Props do componente (selecionados, tabela.chave_selecao, tabela.mostrar_selecao)
* @param emit - Função de emit para `update:selecionados`
* @param linhasPaginadas - Ref com as linhas da página atual (para todosSelecionados/algunsSelecionados)
*/
import { computed, type Ref } from "vue"
type PropsSelecao = {
selecionados: unknown[]
tabela: { mostrar_selecao?: boolean; chave_selecao?: string | number | symbol }
}
export function useEliTabelaSelecao(
props: PropsSelecao,
emit: (event: "update:selecionados", value: unknown[]) => void,
linhasPaginadas: Ref<unknown[]>,
) {
const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao))
const chaveSelecao = computed(() => props.tabela.chave_selecao as string | undefined)
function obterChaveLinha(linha: unknown): unknown {
if (chaveSelecao.value && linha && typeof linha === "object") {
return (linha as Record<string, unknown>)[chaveSelecao.value]
}
return linha
}
function isSelecionado(linha: unknown): boolean {
const chave = obterChaveLinha(linha)
if (chaveSelecao.value) {
return props.selecionados.some((s) => obterChaveLinha(s) === chave)
}
return props.selecionados.includes(linha)
}
function alternarSelecao(linha: unknown) {
const atual = [...props.selecionados]
const chave = obterChaveLinha(linha)
let index = -1
if (chaveSelecao.value) {
index = atual.findIndex((s) => obterChaveLinha(s) === chave)
} else {
index = atual.indexOf(linha)
}
if (index === -1) {
atual.push(linha)
} else {
atual.splice(index, 1)
}
emit("update:selecionados", atual)
}
const todosSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.every((linha) => isSelecionado(linha))
})
const algunsSelecionados = computed(() => {
if (linhasPaginadas.value.length === 0) return false
return linhasPaginadas.value.some((linha) => isSelecionado(linha)) && !todosSelecionados.value
})
function alternarSelecaoTodos(forcarMarcar?: boolean) {
const marcarTodos = forcarMarcar ?? !todosSelecionados.value
let atual = [...props.selecionados]
if (marcarTodos) {
for (const linha of linhasPaginadas.value) {
if (!isSelecionado(linha)) atual.push(linha)
}
} else {
const chavesPagina = linhasPaginadas.value.map(obterChaveLinha)
if (chaveSelecao.value) {
atual = atual.filter((s) => !chavesPagina.includes(obterChaveLinha(s)))
} else {
atual = atual.filter((s) => !chavesPagina.includes(s))
}
}
emit("update:selecionados", atual)
}
return {
mostrarSelecao,
isSelecionado,
alternarSelecao,
todosSelecionados,
algunsSelecionados,
alternarSelecaoTodos,
}
}

View file

@ -0,0 +1,211 @@
/**
* useFiltroAvancado
* Composable que encapsula a lógica do modal de filtro avançado da EliTabela.
*
* Responsável por:
* - Gerenciar a lista de linhas de filtro ativas
* - Converter fórmulas booleanas entre notação de exibição (`{c1}`) e persistência (`{chave}`)
* - Normalizar filtros carregados do localStorage (compatibilidade com formatos legados)
* - Resolver o componente de entrada correto por tipo (`texto`, `numero`, `dataHora`)
*
* Extraído de `EliTabelaModalFiltroAvancado.vue` para manter o componente abaixo de 250 linhas.
*/
import { computed, type Ref, ref, watch } from "vue"
import { EliEntradaDataHora, EliEntradaNumero, EliEntradaTexto } from "../EliEntrada"
import type { ComponenteEntrada } from "../EliEntrada/tiposEntradas"
import type { tipoEliTabelaConsulta } from "./types-eli-tabela"
export type FiltroBase<T> = NonNullable<tipoEliTabelaConsulta<T>["filtroAvancado"]>[number]
export type LinhaFiltro = {
chave: string
entrada: ComponenteEntrada
/** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */
valor: unknown
}
/**
* Retorna o rótulo de exibição de uma definição de filtro.
* Utilizado no template do modal para exibir o nome do campo ao usuário.
*
* @param f - Definição de filtro da configuração da tabela
* @returns Rótulo textual do filtro
*/
export function rotuloDoFiltro(f: FiltroBase<string>) {
return f.rotulo
}
interface UseFiltroAvancadoOptions {
aberto: Ref<boolean>
filtrosBase: Ref<FiltroBase<string>[]>
modelo: Ref<Array<{ chave: string; valor: unknown }>>
modeloFormula: Ref<string>
emit: {
(event: "fechar"): void
(event: "limpar"): void
(event: "salvar", linhas: Array<{ chave: string; valor: unknown }>, formula?: string): void
}
}
/**
* Composable principal do modal de filtro avançado da EliTabela.
* Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam.
*
* @param options.aberto - Ref que controla se o modal está aberto
* @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela
* @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai)
* @param options.modeloFormula - Ref com a fórmula booleana persistida no pai
* @param options.emit - Função de emit do componente Vue pai
* @returns Estado reativo e ações do modal de filtro
*/
export function useFiltroAvancado({
aberto,
filtrosBase,
modelo,
modeloFormula,
emit,
}: UseFiltroAvancadoOptions) {
const linhas = ref<LinhaFiltro[]>([])
const chaveParaAdicionar = ref<string>("")
const formulaCustomizada = ref("")
const formulaGerada = computed(() => linhas.value.map((_, i) => `{c${i + 1}}`).join(" E "))
function formulaParaExibicao(formula: string): string {
let resultado = formula
linhas.value.forEach((l, i) => {
resultado = resultado.replace(new RegExp(`\\{${l.chave}\\}`, "g"), `{c${i + 1}}`)
})
return resultado
}
function formulaParaPersistencia(formula: string): string {
let resultado = formula
for (let i = linhas.value.length; i >= 1; i--) {
const chave = linhas.value[i - 1]?.chave
if (chave) {
resultado = resultado.replace(new RegExp(`\\{c${i}\\}`, "g"), `{${chave}}`)
}
}
return resultado
}
const chavesDisponiveis = computed(() => (filtrosBase.value ?? []).map((b) => String(b.chave)))
const opcoesParaAdicionar = computed(() => {
const usadas = new Set(linhas.value.map((l) => String(l.chave)))
return (filtrosBase.value ?? []).filter((b) => !usadas.has(String(b.chave)))
})
function componenteEntrada(entrada: ComponenteEntrada) {
const tipo = entrada?.[0]
if (tipo === "numero") return EliEntradaNumero
if (tipo === "dataHora") return EliEntradaDataHora
return EliEntradaTexto
}
function opcoesEntrada(entrada: ComponenteEntrada) {
const opcoes: Record<string, unknown> =
typeof entrada?.[1] === "object" && entrada[1] !== null
? { ...(entrada[1] as Record<string, unknown>) }
: {}
return { ...opcoes, rotulo: "" }
}
function valorInicialPorEntrada(entrada: ComponenteEntrada) {
if (entrada?.[0] === "numero") return null
return ""
}
function normalizarModelo() {
const base = filtrosBase.value ?? []
const modeloArr = Array.isArray(modelo.value) ? modelo.value : []
linhas.value = modeloArr
.map((m: unknown) => {
if (!m || typeof m !== "object") return null
const item = m as Record<string, unknown>
const baseItem = base.find((b) => String(b.chave) === String(item["chave"])) ?? base[0]
if (!baseItem) return null
const entrada = (baseItem.entrada ?? item["entrada"]) as ComponenteEntrada
const chave = String(baseItem.chave ?? item["chave"])
const val = item["valor"] ?? valorInicialPorEntrada(entrada)
return { chave, entrada, valor: val } as LinhaFiltro
})
.filter(Boolean) as LinhaFiltro[]
linhas.value = linhas.value.filter((l) => chavesDisponiveis.value.includes(String(l.chave)))
if (modeloFormula.value) {
formulaCustomizada.value = formulaParaExibicao(modeloFormula.value)
} else {
formulaCustomizada.value = formulaGerada.value
}
}
watch(
() => [aberto.value, filtrosBase.value, modelo.value] as const,
() => {
if (aberto.value) normalizarModelo()
},
{ deep: true, immediate: true },
)
function adicionar() {
if (!chaveParaAdicionar.value) return
const b0 = (filtrosBase.value ?? []).find(
(b) => String(b.chave) === String(chaveParaAdicionar.value),
)
if (!b0) return
if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return
linhas.value.push({
chave: String(b0.chave),
entrada: b0.entrada,
valor: valorInicialPorEntrada(b0.entrada),
})
formulaCustomizada.value = formulaGerada.value
chaveParaAdicionar.value = ""
}
function remover(idx: number) {
linhas.value.splice(idx, 1)
formulaCustomizada.value = formulaGerada.value
}
function emitFechar() {
emit("fechar")
}
function emitLimpar() {
emit("limpar")
}
function emitSalvar() {
const formulaPersistida = formulaParaPersistencia(formulaCustomizada.value)
emit(
"salvar",
linhas.value.map((l) => ({ chave: l.chave, valor: l.valor })),
formulaPersistida,
)
}
function rotuloDoFiltroAtivo(chave: string): string {
const base = (filtrosBase.value ?? []).find((b) => String(b.chave) === chave)
return base ? rotuloDoFiltro(base) : chave
}
return {
linhas,
opcoesParaAdicionar,
chaveParaAdicionar,
formulaCustomizada,
formulaGerada,
componenteEntrada,
opcoesEntrada,
adicionar,
remover,
emitFechar,
emitSalvar,
emitLimpar,
rotuloDoFiltro,
rotuloDoFiltroAtivo,
}
}

46
src/design-tokens.css Normal file
View file

@ -0,0 +1,46 @@
/**
* Design Tokens — Paleta Institucional Azteca
*
* Arquivo centralizado de variáveis CSS usado por todos os componentes Eli.
* Importado no ponto de entrada do projeto (main.ts ou App.vue).
*
* Escala primária: Verde floresta (#688C00)
* Escala de apoio: Âmbar terroso
* Escala neutra: Cinza musgo
*/
:root {
/* ── Escala primária — Verde floresta ── */
--cor-eli-primaria-bg: #EEF3D0;
--cor-eli-primaria-zebra: #F6F9E8;
--cor-eli-primaria-suave: #BFC99C;
--cor-eli-primaria-header: #ADCC44;
--cor-eli-primaria-clara: #D4E290;
--cor-eli-primaria-media: #ADCC44;
--cor-eli-primaria-acao: #8AAF00;
--cor-eli-primaria: #688C00;
--cor-eli-primaria-escura: #4A6500;
--cor-eli-primaria-texto: #2E3F00;
/* ── Escala de apoio — Âmbar terroso ── */
--cor-eli-alerta-bg: #FAF3DF;
--cor-eli-alerta-clara: #F5DC8A;
--cor-eli-alerta: #DDB847;
--cor-eli-alerta-forte: #8C6A00;
/* ── Escala neutra — Cinza musgo ── */
--cor-eli-fundo: #F2F3EE;
--cor-eli-borda: #D5D8CC;
--cor-eli-borda-forte: #B5BAA8;
--cor-eli-neutro: #5E6553;
--cor-eli-neutro-escuro: #3E4436;
--cor-eli-superficie: #FFFFFF;
/* ── Semânticas ── */
--cor-eli-sucesso: #2E7D32;
--cor-eli-sucesso-bg: #E8F5E9;
--cor-eli-erro: #C62828;
--cor-eli-erro-bg: #FFEBEE;
--cor-eli-info: #1565C0;
--cor-eli-info-bg: #E3F2FD;
}

View file

@ -1,5 +1,6 @@
import type { App, Plugin } from "vue"
import "./styles/eli-vue-fonts.css"
import "./design-tokens.css"
import { EliBotao } from "./componentes/botao"
import { EliCartao } from "./componentes/cartao"
import {
@ -13,7 +14,7 @@ import { EliTabela } from "./componentes/EliTabela"
import { EliBadge } from "./componentes/indicador"
import { EliOlaMundo } from "./componentes/ola_mundo"
export { criarFiltro26 } from "p-comuns"
export { iniciarFiltroDelta } from "p-comuns"
export * from "./componentes/EliEntrada"
// Exportar tudo (componentes + types + helpers) de Tabela e Entrada
export * from "./componentes/EliTabela"

View file

@ -1,33 +1,78 @@
<template>
<v-container class="py-6">
<h1 class="text-h5 mb-4">Playground — eli-vue</h1>
<div style="display: flex; flex-direction: row; min-height: 100vh;">
<!-- Seção de Componentes -->
<div style="flex: 1; min-width: 0; overflow-y: auto;">
<v-container class="py-6">
<h1 class="text-h5 mb-4">Playground — eli-vue</h1>
<v-tabs v-model="aba" color="primary" density="comfortable">
<v-tab value="botao">Botão</v-tab>
<v-tab value="indicador">Indicador</v-tab>
<v-tab value="cartao">Cartão</v-tab>
<v-tab value="entradas">Entradas</v-tab>
<v-tab value="data_hora">Data e hora</v-tab>
<v-tab value="tabela">Tabela</v-tab>
<v-tab value="ola_mundo">Demo</v-tab>
</v-tabs>
<v-divider class="my-4" />
<v-tabs v-model="aba" color="primary" density="comfortable">
<v-tab value="botao">Botão</v-tab>
<v-tab value="indicador">Indicador</v-tab>
<v-tab value="cartao">Cartão</v-tab>
<v-tab value="entradas">Entradas</v-tab>
<v-tab value="data_hora">Data e hora</v-tab>
<v-tab value="tabela">Tabela</v-tab>
<v-tab value="ola_mundo">Demo</v-tab>
</v-tabs>
<BotaoPlayground v-if="aba === 'botao'" />
<IndicadorPlayground v-else-if="aba === 'indicador'" />
<CartaoPlayground v-else-if="aba === 'cartao'" />
<EntradasPlayground v-else-if="aba === 'entradas'" />
<DataHoraPlayground v-else-if="aba === 'data_hora'" />
<TabelaPlayground v-else-if="aba === 'tabela'" />
<OlaMundoPlayground v-else />
</v-container>
</div>
<v-divider class="my-4" />
<!-- Console Global do Playground -->
<div
class="playground-console"
style="width: 400px; flex-shrink: 0; background: #121212; color: #a3e635; padding: 24px 20px; border-left: 1px solid #333; display: flex; flex-direction: column; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px;"
>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #333; padding-bottom: 12px; margin-bottom: 16px;">
<span style="font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 1.5px; font-size: 11px; display: flex; align-items: center; gap: 8px;">
Console
<div style="width: 6px; height: 6px; border-radius: 50%; background-color: #22c55e; box-shadow: 0 0 8px #22c55e;"></div>
</span>
<button
@click="logsConsole = []"
style="font-size: 10px; cursor: pointer; color: #ef4444; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); border-radius: 4px; padding: 2px 8px; font-family: inherit;"
>LIMPAR</button>
</div>
<BotaoPlayground v-if="aba === 'botao'" />
<IndicadorPlayground v-else-if="aba === 'indicador'" />
<CartaoPlayground v-else-if="aba === 'cartao'" />
<EntradasPlayground v-else-if="aba === 'entradas'" />
<DataHoraPlayground v-else-if="aba === 'data_hora'" />
<TabelaPlayground v-else-if="aba === 'tabela'" />
<OlaMundoPlayground v-else />
</v-container>
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;" class="playground-console-scroll">
<div v-if="logsConsole.length === 0" style="color: #52525b; font-size: 11px;">Nenhum log registrado ainda...</div>
<pre
v-for="(log, i) in logsConsole"
:key="i"
style="font-size: 11px; white-space: pre-wrap; word-break: break-all; color: #a1a1aa; border-left: 2px solid #52525b; padding-left: 8px; margin: 0;"
>{{ log }}</pre>
</div>
</div>
</div>
</template>
<style scoped>
.playground-console-scroll::-webkit-scrollbar {
width: 6px;
}
.playground-console-scroll::-webkit-scrollbar-track {
background: transparent;
}
.playground-console-scroll::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 3px;
}
.playground-console-scroll::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
</style>
<script lang="ts">
import { defineComponent } from "vue"
import { defineComponent, provide, ref } from "vue"
import BotaoPlayground from "./botao.playground.vue"
import CartaoPlayground from "./cartao.playground.vue"
import DataHoraPlayground from "./data_hora.playground.vue"
@ -65,6 +110,15 @@ const mapaAbaParaHash: Record<AbaPlayground, string> = {
ola_mundo: "demo",
}
/** Chave de injeção para o console do playground */
export const PLAYGROUND_CONSOLE_KEY = "playgroundConsole"
/** Tipagem do console injetado */
export interface PlaygroundConsole {
adicionarLog: (acao: string, req: unknown, res: unknown) => void
logMensagem: (mensagem: string) => void
}
export default defineComponent({
name: "App",
components: {
@ -76,43 +130,34 @@ export default defineComponent({
TabelaPlayground,
OlaMundoPlayground,
},
data() {
setup() {
const logsConsole = ref<string[]>([])
/** Adiciona um log estruturado ao console global do playground */
function adicionarLog(acao: string, req: unknown, res: unknown) {
const time = new Date().toLocaleTimeString()
logsConsole.value.unshift(
`[${time}] 🌐 ${acao}\nReq: ${JSON.stringify(req, null, 2)}\nRes: ${JSON.stringify(res, null, 2)}`,
)
}
/** Adiciona uma mensagem simples ao console */
function logMensagem(mensagem: string) {
const time = new Date().toLocaleTimeString()
logsConsole.value.unshift(`[${time}] ${mensagem}`)
}
const consoleApi: PlaygroundConsole = { adicionarLog, logMensagem }
provide(PLAYGROUND_CONSOLE_KEY, consoleApi)
return {
aba: "botao" as AbaPlayground,
fontVars: {
eli: "",
v: "",
body: "",
},
googleSansCheck: "(checando...)" as string,
logsConsole,
aba: ref<AbaPlayground>("botao"),
}
},
mounted() {
this.sincronizarAbaComHash()
window.addEventListener("hashchange", this.sincronizarAbaComHash)
// Coleta variáveis de fonte e confirma carregamento via FontFaceSet.
this.fontVars.eli = getComputedStyle(document.documentElement)
.getPropertyValue("--eli-font-family")
.trim()
this.fontVars.v = getComputedStyle(document.documentElement)
.getPropertyValue("--v-font-family")
.trim()
this.fontVars.body = getComputedStyle(document.body).fontFamily
// Espera fonts carregarem e então checa se o navegador considera Google Sans disponível
// (isso não garante 100% que TODOS pesos/estilos foram baixados, mas confirma que a face existe/foi carregada)
document.fonts.ready
.then(() => {
this.googleSansCheck = String(
document.fonts.check('12px "Google Sans"'),
)
// atualiza body depois do load, pois pode mudar após o font-face carregar
this.fontVars.body = getComputedStyle(document.body).fontFamily
})
.catch(() => {
this.googleSansCheck = "erro"
})
},
beforeUnmount() {
window.removeEventListener("hashchange", this.sincronizarAbaComHash)
@ -129,19 +174,14 @@ export default defineComponent({
},
sincronizarAbaComHash() {
const hash = this.normalizarHash()
const aba = mapaHashParaAba[hash] ?? "botao"
if (this.aba !== aba) {
this.aba = aba
}
// Se a URL estiver vazia/fora do padrão, normalizamos.
this.sincronizarHashComAba()
},
sincronizarHashComAba() {
const destino = `#/${mapaAbaParaHash[this.aba]}`
if (window.location.hash !== destino) {
window.location.hash = destino
}

View file

@ -1,445 +0,0 @@
import { respostaComuns, type tipoResposta } from "p-respostas"
import type { tipoFiltro } from "@/componentes/EliTabela/types-eli-tabela"
export type Linha = {
codigo: string
empreendedor: string
empreendimento: string
documento: string
email: string
telefone: string
faturamento: number
peso_kg: number
criado_em: string
atualizado_em: string
}
// == SIMULAÇÃO DE API (CRUD) ==
// Dados isolados (simulando um banco de dados no servidor)
const _bancoDeDados: Linha[] = [
{
codigo: String(Math.random()),
empreendedor: "Maria Silva",
empreendimento: "Doces da Maria",
documento: "12.345.678/0001-90",
email:
"contato.comercial.super.longo@doces-da-maria-exemplo-muito-grande.com.br",
telefone: "(11) 91234-5678",
faturamento: 12500.5,
peso_kg: 12.4,
criado_em: "2026-01-09T16:15:00Z",
atualizado_em: "2026-01-29T15:30:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "João Pereira",
empreendimento: "Café Peregrino",
documento: "98.765.432/0001-12",
email: "contato@cafeperegrino.com",
telefone: "(11) 93456-7810",
faturamento: 8000,
peso_kg: 6.2,
criado_em: "2026-01-02T10:00:00-03:00",
atualizado_em: "2026-01-20T08:10:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Ana Costa",
empreendimento: "Flor de Sal Gastronomia",
documento: "45.678.912/0001-55",
email: "ana@flordesal.com",
telefone: "(21) 99876-5432",
faturamento: 3200.75,
peso_kg: 4.8,
criado_em: "2025-12-10T09:30:00-03:00",
atualizado_em: "2026-01-15T19:20:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Rafael Lima",
empreendimento: "Tech Agro Solutions",
documento: "07.654.321/0001-00",
email: "rafael@techagro.com",
telefone: "(31) 94567-8899",
faturamento: 15999.9,
peso_kg: 9.1,
criado_em: "2026-01-12T12:00:00-03:00",
atualizado_em: "2026-01-28T11:00:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Juliana Torres",
empreendimento: "Torres Moda Sustentável",
documento: "33.210.987/0001-44",
email: "juliana@torresmoda.com",
telefone: "(71) 97766-5544",
faturamento: 4500,
peso_kg: 3.3,
criado_em: "2026-01-01T00:00:00Z",
atualizado_em: "2026-01-10T13:45:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Marcos Vieira",
empreendimento: "Padaria Pão Quentinho",
documento: "21.109.876/0001-32",
email: "marcos@paoquentinho.com",
telefone: "(48) 99654-3210",
faturamento: 2200.4,
peso_kg: 15.7,
criado_em: "2026-01-05T07:25:00-03:00",
atualizado_em: "2026-01-23T20:15:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Bianca Rocha",
empreendimento: "Studio Beleza em Casa",
documento: "54.321.098/0001-21",
email: "contato@belezaemcasa.com",
telefone: "(85) 98877-1122",
faturamento: 990,
peso_kg: 1.9,
criado_em: "2026-01-03T14:00:00-03:00",
atualizado_em: "2026-01-29T09:00:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Caio Albuquerque",
empreendimento: "Albuquerque Engenharia Verde",
documento: "65.432.109/0001-09",
email: "caio@engenhariaverde.com",
telefone: "(61) 98123-4567",
faturamento: 100000,
peso_kg: 21.3,
criado_em: "2025-11-18T16:10:00-03:00",
atualizado_em: "2026-01-25T10:40:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Fernanda Almeida",
empreendimento: "Faz Bem Produtos Naturais",
documento: "87.654.210/0001-98",
email: "fernanda@fazbem.com",
telefone: "(41) 99777-6655",
faturamento: 5600.2,
peso_kg: 8.6,
criado_em: "2026-01-08T12:12:00-03:00",
atualizado_em: "2026-01-21T17:55:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Gabriel Martins",
empreendimento: "Martins Tech Repair",
documento: "19.876.543/0001-76",
email: "suporte@martinstech.com",
telefone: "(19) 98888-9090",
faturamento: 7550,
peso_kg: 7.7,
criado_em: "2026-01-11T11:00:00-03:00",
atualizado_em: "2026-01-18T11:30:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Helena Duarte",
empreendimento: "Ateliê Costura Criativa",
documento: "23.456.789/0001-65",
email: "helena@costuracriativa.com",
telefone: "(51) 98765-4433",
faturamento: 2700,
peso_kg: 2.5,
criado_em: "2026-01-07T08:00:00-03:00",
atualizado_em: "2026-01-16T14:10:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Igor Santos",
empreendimento: "Santos Consultoria Financeira",
documento: "43.219.876/0001-54",
email: "igor@santosconsultoria.com",
telefone: "(31) 99332-1100",
faturamento: 12000,
peso_kg: 5.4,
criado_em: "2026-01-06T10:30:00-03:00",
atualizado_em: "2026-01-22T18:30:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Jéssica Nunes",
empreendimento: "Nunes Eventos & Decoração",
documento: "09.876.543/0001-33",
email: "jessica@nunesdecora.com",
telefone: "(62) 99922-3344",
faturamento: 3300.1,
peso_kg: 4.1,
criado_em: "2026-01-04T13:30:00-03:00",
atualizado_em: "2026-01-26T09:20:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Leonardo Prado",
empreendimento: "Prado Bike Courier",
documento: "72.345.098/0001-27",
email: "contato@pradobike.com",
telefone: "(47) 98444-6677",
faturamento: 4100,
peso_kg: 6.8,
criado_em: "2026-01-02T18:40:00-03:00",
atualizado_em: "2026-01-27T12:00:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Marina Lopes",
empreendimento: "Lopes Design Digital",
documento: "56.789.012/0001-11",
email: "marina@lopesdesign.com",
telefone: "(27) 99911-2233",
faturamento: 2100,
peso_kg: 3.7,
criado_em: "2026-01-09T09:00:00-03:00",
atualizado_em: "2026-01-19T15:35:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Nicolas Teixeira",
empreendimento: "QualiVida Fitness",
documento: "34.567.890/0001-02",
email: "nicolas@qualivida.com",
telefone: "(92) 99456-7788",
faturamento: 8600.9,
peso_kg: 11.2,
criado_em: "2026-01-12T19:15:00-03:00",
atualizado_em: "2026-01-29T10:50:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Olívia Azevedo",
empreendimento: "Azevedo Pet Care",
documento: "88.901.234/0001-45",
email: "olivia@petcare.com",
telefone: "(16) 99788-6655",
faturamento: 1900.3,
peso_kg: 2.2,
criado_em: "2026-01-10T15:00:00-03:00",
atualizado_em: "2026-01-14T16:00:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Paulo Henrique",
empreendimento: "Henrique Soluções Elétricas",
documento: "44.556.778/0001-90",
email: "paulo@solucoeseletricas.com",
telefone: "(13) 98810-2020",
faturamento: 50000,
peso_kg: 17.9,
criado_em: "2026-01-13T10:10:00-03:00",
atualizado_em: "2026-01-24T21:00:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Renata Souza",
empreendimento: "Souza Traduções Especializadas",
documento: "11.223.344/0001-08",
email: "renata@souzatraducoes.com",
telefone: "(24) 99661-7788",
faturamento: 6400,
peso_kg: 9.9,
criado_em: "2026-01-08T07:00:00-03:00",
atualizado_em: "2026-01-28T09:05:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Tiago Moura",
empreendimento: "Moura Agro Sustentável",
documento: "78.912.345/0001-67",
email: "tiago@mouraagro.com",
telefone: "(68) 99999-4545",
faturamento: 7200,
peso_kg: 13.5,
criado_em: "2026-01-03T12:00:00-03:00",
atualizado_em: "2026-01-23T12:45:00-03:00",
},
{
codigo: String(Math.random()),
empreendedor: "Viviane Castro",
empreendimento: "Castro Arte & Cerâmica",
documento: "32.165.498/0001-87",
email: "viviane@castroarte.com",
telefone: "(81) 98787-1212",
faturamento: 2800,
peso_kg: 4.6,
criado_em: "2026-01-01T09:00:00-03:00",
atualizado_em: "2026-01-17T09:30:00-03:00",
},
]
// Helpers internos da API (não expostos)
const _filtrarPorBusca = (lista: Linha[], texto?: string) => {
const termo = texto?.trim().toLowerCase()
if (!termo) return [...lista]
return lista.filter((linha) =>
[linha.empreendedor, linha.empreendimento].some((v) =>
v.toLowerCase().includes(termo),
),
)
}
const _comparar = (
op: string,
valLine: unknown,
valFiltro: unknown,
): boolean => {
switch (op) {
case "=":
return valLine == valFiltro
case "!=":
return valLine != valFiltro
case ">":
return Number(valLine) > Number(valFiltro)
case ">=":
return Number(valLine) >= Number(valFiltro)
case "<":
return Number(valLine) < Number(valFiltro)
case "<=":
return Number(valLine) <= Number(valFiltro)
case "like":
return String(valLine ?? "")
.toLowerCase()
.includes(String(valFiltro ?? "").toLowerCase())
case "in": {
const arr = Array.isArray(valFiltro)
? valFiltro
: String(valFiltro ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
return arr.includes(String(valLine))
}
case "isNull":
return valLine === null || valLine === undefined || valLine === ""
default:
return true
}
}
const _filtrarAvancado = (lista: Linha[], filtros?: tipoFiltro<Linha>[]) => {
if (!filtros?.length) return [...lista]
return lista.filter((linha) =>
filtros.every((f) => {
// f é um objeto { [coluna]: { [op]: valor } }
// Percorremos as colunas (chaves)
return Object.entries(f).every(([coluna, condicoes]) => {
// Ignorar chaves de lógica complexa por enquanto no playground
if (coluna === "AND" || coluna === "OR") return true
const valLinha = linha[coluna as keyof Linha]
// Se condicoes for objeto { op: val }
if (typeof condicoes === "object" && condicoes !== null) {
return Object.entries(condicoes).every(([op, val]) =>
_comparar(op, valLinha, val),
)
}
// Se for valor direto (igualdade implícita? ou erro?)
// Vamos assumir igualdade se não for objeto
return _comparar("=", valLinha, condicoes)
})
}),
)
}
const _ordenar = (
lista: Linha[],
p?: { coluna_ordem?: keyof Linha; direcao_ordem?: "asc" | "desc" },
) => {
if (!p?.coluna_ordem) return [...lista]
const dir = p.direcao_ordem === "desc" ? -1 : 1
const key = p.coluna_ordem
return [...lista].sort((a, b) => {
const va = String(a[key] ?? "")
const vb = String(b[key] ?? "")
return dir * va.localeCompare(vb, "pt-BR", { sensitivity: "base" })
})
}
const delay = (ms: number) =>
new Promise((r) => setTimeout(r, ms * Math.random() + 2000))
// -- API PÚBLICA SIMULADA --
export async function api_ler(params: {
texto_busca?: string
filtros?: tipoFiltro<Linha>[]
coluna_ordem?: keyof Linha
direcao_ordem?: "asc" | "desc"
limit?: number
offSet?: number
}) {
await delay(600) // simula latência de rede
// 1. Filtro
let resultado = params.texto_busca
? _filtrarPorBusca(_bancoDeDados, params.texto_busca)
: _filtrarAvancado(_bancoDeDados, params.filtros)
// 2. Ordenação
resultado = _ordenar(resultado, params)
// 3. Paginação
const limit = Math.max(1, Number(params.limit ?? 10))
const offset = Math.max(0, Number(params.offSet ?? 0))
const paginado = resultado.slice(offset, offset + limit)
return {
total: resultado.length,
dados: paginado,
}
}
export async function api_criar(
dados: Partial<Linha>,
): Promise<tipoResposta<Linha>> {
await delay(1500) // simula tempo de salvar
if (!dados.empreendedor) {
return respostaComuns.erro("Empreendedor é obrigatório")
}
const novo: Linha = {
codigo: Math.random().toString(),
empreendedor: dados.empreendedor || "Novo",
empreendimento: dados.empreendimento || "Novo Emp",
documento: dados.documento || "00.000.000/0000-00",
email: dados.email || "novo@email.com",
telefone: dados.telefone || "(00) 0000-0000",
faturamento: dados.faturamento || 0,
peso_kg: dados.peso_kg || 0,
criado_em: new Date().toISOString(),
atualizado_em: new Date().toISOString(),
}
_bancoDeDados.unshift(novo)
return respostaComuns.valor(novo)
}
export async function api_editar(
codigo: string,
novosDados: Partial<Linha>,
): Promise<tipoResposta<Linha>> {
await delay(1000)
const idx = _bancoDeDados.find((l) => l.codigo === codigo)
if (!idx) return respostaComuns.erro("Registro não encontrado")
Object.assign(idx, novosDados)
return respostaComuns.valor(idx)
}
export async function api_deletar(
codigo: string,
): Promise<tipoResposta<string>> {
await delay(800)
const idx = _bancoDeDados.findIndex((l) => l.codigo === codigo)
if (idx !== -1) {
_bancoDeDados.splice(idx, 1)
return respostaComuns.valor("Registro deletado com sucesso")
}
return respostaComuns.erro("Registro não encontrado")
}

View file

@ -0,0 +1,195 @@
import alasql from "alasql"
import type { tipoCondicaoFiltroDelta, zFiltroDelta } from "p-comuns"
import type z from "zod"
import { _dados_empreendedores, type tipoEmpreendedores } from "./dados"
const zCondicaoFiltroDeltaParaQuery = ({
condicao,
}: {
condicao: tipoCondicaoFiltroDelta
}): { sql: string; parametro: Record<string, unknown> } => {
const coluna = condicao.coluna
const retorno: { sql: string; parametro: Record<string, unknown> } = {
sql: "",
parametro: {},
}
if (condicao.operador === "!=") {
retorno.sql = `${coluna} != ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === "=") {
retorno.sql = `${coluna} = ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === ">") {
retorno.sql = `${coluna} > ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === "<") {
retorno.sql = `${coluna} < ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === ">=") {
retorno.sql = `${coluna} >= ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === "<=") {
retorno.sql = `${coluna} <= ${condicao.id}`
retorno.parametro[condicao.id] = condicao.valor
} else if (condicao.operador === "like") {
retorno.sql = `LOWER(${coluna}) LIKE LOWER(${condicao.id})`
retorno.parametro[condicao.id] = condicao.valor.includes("%")
? condicao.valor
: `%${condicao.valor}%`
} else if (condicao.operador === "in") {
// Alasql usa @(?) para arrays. Porém, o passo 2 substitui {id} por ?.
// O que resultará em @(?).
retorno.sql = `${coluna} IN @(${condicao.id})`
retorno.parametro[condicao.id] = Array.isArray(condicao.valor)
? condicao.valor
: String(condicao.valor)
.split(",")
.map((s) => s.trim())
} else {
throw new Error(`Operador inválido: ${condicao.operador as string}`)
}
return retorno
}
/**
* Converte um `FiltroDelta` em um fragmento SQL com parâmetros posicionais para o Alasql.
* Realiza dois passes na fórmula:
* 1. Substitui cada `{id}` pelo sub-SQL da condição correspondente
* 2. Extrai os parâmetros em ordem e troca `{id}` por `?`
* Por fim, traduz os operadores lógicos pt-BR (E, OU, NAO) para SQL (AND, OR, NOT).
*
* @param filtros - Um FiltroDelta (fórmula + condições) ou `undefined` para sem filtro
* @returns Fragmento SQL e array de parâmetros posicionais
*/
export const zFiltroDeltaParaQuery = ({
filtros,
}: {
filtros: z.infer<typeof zFiltroDelta> | undefined
}): { sql: string; parametros: unknown[] } => {
if (!filtros) return { sql: "", parametros: [] }
const parametros: unknown[] = []
const sqlParametros: Record<string, unknown> = {}
// 1. Substituir `{id}` pelo sub-sql de cada condição e extrair parâmetros
let sql = filtros.formula.replace(/\{[\w:]+\}/g, (match) => {
const id = match.slice(1, -1)
const condicao = filtros.condicoes.find((c) => c.id === match)
if (!condicao) return match
const { sql: sqlCondicao, parametro } = zCondicaoFiltroDeltaParaQuery({ condicao })
sqlParametros[id] = parametro[match]
return ` (${sqlCondicao}) `
})
// 2. Extrair parâmetros na ordem correta, trocando os {id} restantes por ?
sql = sql.replace(/\{[\w:]+\}/g, (match) => {
const id = match.slice(1, -1)
parametros.push(sqlParametros[id])
return "?"
})
// 3. Traduzir operadores lógicos do filtroDelta
sql = sql
.replace(/\bNAO\b/g, " NOT ")
.replace(/\bE\b/g, " AND ")
.replace(/\bOU\b/g, " OR ")
return { sql, parametros }
}
/**
* Combina um array de FiltroDelta em uma cláusula WHERE única para o Alasql.
* Cada filtro é convertido individualmente e combinado com AND.
* Retorna `"1=1"` (sem filtro) quando o array é vazio.
*
* @param filtrosArray - Lista de FiltroDelta a combinar; `undefined` ou vazio = sem filtro
* @returns Fragmento SQL e array de parâmetros posicionais combinados
*/
export const zFiltrosArrayParaAlasql = (
filtrosArray?: z.infer<typeof zFiltroDelta>[],
): { sql: string; parametros: unknown[] } => {
if (!filtrosArray?.length) return { sql: "1=1", parametros: [] }
const queryStrings: string[] = []
const todosParametros: unknown[] = []
for (const filtro of filtrosArray) {
const { sql, parametros } = zFiltroDeltaParaQuery({ filtros: filtro })
if (sql) {
queryStrings.push(`(${sql})`)
todosParametros.push(...parametros)
}
}
if (queryStrings.length === 0) return { sql: "1=1", parametros: [] }
return { sql: queryStrings.join(" AND "), parametros: todosParametros }
}
/**
* Executa uma consulta no banco em memória Alasql com suporte a filtros, ordenação e paginação.
* Simula o comportamento de uma API REST real para fins de demonstração no playground.
* Os filtros são compilados pela `zFiltroDeltaParaQuery`; a contagem total é obtida antes da paginação.
*
* @param params.filtros - FiltroDelta opcional (filtros avançados do usuário)
* @param params.coluna_ordem - Nome da coluna para ordenar
* @param params.direcao_ordem - Direção da ordenação (`"asc"` | `"desc"`)
* @param params.limit - Número de registros por página
* @param params.offSet - Deslocamento (página × limit)
* @returns Objeto `{ total, dados }` com o total geral e a página de registros
*/
export const executarConsultaAlasql = (params: {
filtros?: z.infer<typeof zFiltroDelta>
coluna_ordem?: string
direcao_ordem?: "asc" | "desc"
limit?: number
offSet?: number
}): { total: number; dados: tipoEmpreendedores[] } => {
let whereSql = "1=1"
const parametros: unknown[] = []
// 2. Filtros Avançados
if (params.filtros) {
const parsed = zFiltroDeltaParaQuery({ filtros: params.filtros })
if (parsed.sql && parsed.sql !== "1=1") {
whereSql += ` AND (${parsed.sql})`
parametros.push(...parsed.parametros)
}
}
// Montagem da Query base
let sql = `SELECT * FROM ? WHERE ${whereSql}`
// 3. Ordenação
if (params.coluna_ordem) {
const dir = params.direcao_ordem === "desc" ? "DESC" : "ASC"
sql += ` ORDER BY ${params.coluna_ordem} ${dir}`
}
// Obter Total antes da paginação
const sqlCount = `SELECT COUNT(*) as t FROM ? WHERE ${whereSql}`
const countResult = alasql(sqlCount, [_dados_empreendedores, ...parametros]) as {
t: number
}[]
const total = countResult[0].t
// 4. Paginação
const limit = Math.max(1, Number(params.limit ?? 10))
const offset = Math.max(0, Number(params.offSet ?? 0))
sql += ` LIMIT ${limit} OFFSET ${offset}`
console.groupCollapsed("🔍 [Alasql] Consulta executada")
console.log("SQL:", sql)
console.log("Parâmetros:", parametros)
console.log("Total Encontrado:", total)
console.groupEnd()
const dados = alasql(sql, [_dados_empreendedores, ...parametros]) as tipoEmpreendedores[]
return { total, dados }
}

View file

@ -0,0 +1,250 @@
import z from "zod"
let _id = 0
/** Gera um ID incremental único para uso no seed de dados do playground. Não usar em produção. */
export const gerarId = () => `item-${_id++}`
/** Schema Zod do registro de empreendedor — usado para validação e inferência de tipo no playground. */
export const zEmpreendedores = z.object({
codigo: z.string(),
empreendedor: z.string(),
empreendimento: z.string(),
documento: z.string(),
email: z.string(),
telefone: z.string(),
faturamento: z.number(),
peso_kg: z.number(),
areas_atuacao: z.array(
z.enum(["mineracao", "agro", "turismo", "tecnologia", "educacao", "saude"]),
),
criado_em: z.string(),
atualizado_em: z.string(),
})
export type tipoEmpreendedores = z.infer<typeof zEmpreendedores>
// == SIMULAÇÃO DE API (CRUD) ==
// Dados isolados (simulando um banco de dados no servidor)
export const _dados_empreendedores: tipoEmpreendedores[] = [
{
codigo: gerarId(),
empreendedor: "Maria Silva",
empreendimento: "Doces da Maria",
documento: "12.345.678/0001-90",
email: "contato.comercial.super.longo@doces-da-maria-exemplo-muito-grande.com.br",
telefone: "(11) 91234-5678",
faturamento: 12500.5,
peso_kg: 12.4,
areas_atuacao: ["agro", "turismo"],
criado_em: "2026-01-09T16:15:00Z",
atualizado_em: "2026-01-29T15:30:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "João Pereira",
empreendimento: "Café Peregrino",
documento: "98.765.432/0001-12",
email: "contato@cafeperegrino.com",
telefone: "(11) 93456-7810",
faturamento: 8000,
peso_kg: 6.2,
areas_atuacao: ["tecnologia", "agro"],
criado_em: "2026-01-02T10:00:00-03:00",
atualizado_em: "2026-01-20T08:10:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Ana Costa",
empreendimento: "Flor de Sal Gastronomia",
documento: "45.678.912/0001-55",
email: "ana@flordesal.com",
telefone: "(21) 99876-5432",
faturamento: 3200.75,
peso_kg: 4.8,
areas_atuacao: ["educacao"],
criado_em: "2025-12-10T09:30:00-03:00",
atualizado_em: "2026-01-15T19:20:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Rafael Lima",
empreendimento: "Tech Agro Solutions",
documento: "07.654.321/0001-00",
email: "rafael@techagro.com",
telefone: "(31) 94567-8899",
faturamento: 15999.9,
peso_kg: 9.1,
areas_atuacao: ["tecnologia"],
criado_em: "2026-01-12T12:00:00-03:00",
atualizado_em: "2026-01-28T11:00:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Juliana Torres",
empreendimento: "Torres Moda Sustentável",
documento: "33.210.987/0001-44",
email: "juliana@torresmoda.com",
telefone: "(71) 97766-5544",
faturamento: 4500,
peso_kg: 3.3,
areas_atuacao: ["turismo", "agro"],
criado_em: "2026-01-01T00:00:00Z",
atualizado_em: "2026-01-10T13:45:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Marcos Vieira",
empreendimento: "Padaria Pão Quentinho",
documento: "21.109.876/0001-32",
email: "marcos@paoquentinho.com",
telefone: "(48) 99654-3210",
faturamento: 2200.4,
peso_kg: 15.7,
areas_atuacao: ["saude"],
criado_em: "2026-01-05T07:25:00-03:00",
atualizado_em: "2026-01-23T20:15:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Bianca Rocha",
empreendimento: "Studio Beleza em Casa",
documento: "54.321.098/0001-21",
email: "contato@belezaemcasa.com",
telefone: "(85) 98877-1122",
faturamento: 990,
peso_kg: 1.9,
areas_atuacao: ["agro", "tecnologia"],
criado_em: "2026-01-03T14:00:00-03:00",
atualizado_em: "2026-01-29T09:00:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Caio Albuquerque",
empreendimento: "Albuquerque Engenharia Verde",
documento: "65.432.109/0001-09",
email: "caio@engenhariaverde.com",
telefone: "(61) 98123-4567",
faturamento: 100000,
peso_kg: 21.3,
areas_atuacao: ["mineracao"],
criado_em: "2025-11-18T16:10:00-03:00",
atualizado_em: "2026-01-25T10:40:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Fernanda Almeida",
empreendimento: "Faz Bem Produtos Naturais",
documento: "87.654.210/0001-98",
email: "fernanda@fazbem.com",
telefone: "(41) 99777-6655",
faturamento: 5600.2,
peso_kg: 8.6,
areas_atuacao: ["mineracao", "turismo"],
criado_em: "2026-01-08T12:12:00-03:00",
atualizado_em: "2026-01-21T17:55:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Gabriel Martins",
empreendimento: "Martins Tech Repair",
documento: "19.876.543/0001-76",
email: "suporte@martinstech.com",
telefone: "(19) 98888-9090",
faturamento: 7550,
peso_kg: 7.7,
areas_atuacao: ["mineracao", "turismo"],
criado_em: "2026-01-11T11:00:00-03:00",
atualizado_em: "2026-01-18T11:30:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Helena Duarte",
empreendimento: "Ateliê Costura Criativa",
documento: "23.456.789/0001-65",
email: "helena@costuracriativa.com",
telefone: "(51) 98765-4433",
faturamento: 2700,
peso_kg: 2.5,
areas_atuacao: ["turismo", "mineracao"],
criado_em: "2026-01-07T08:00:00-03:00",
atualizado_em: "2026-01-16T14:10:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Igor Santos",
empreendimento: "Santos Consultoria Financeira",
documento: "43.219.876/0001-54",
email: "igor@santosconsultoria.com",
telefone: "(31) 99332-1100",
faturamento: 12000,
peso_kg: 5.4,
areas_atuacao: ["saude"],
criado_em: "2026-01-06T10:30:00-03:00",
atualizado_em: "2026-01-22T18:30:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Jéssica Nunes",
empreendimento: "Nunes Eventos & Decoração",
documento: "09.876.543/0001-33",
email: "jessica@nunesdecora.com",
telefone: "(62) 99922-3344",
faturamento: 3300.1,
peso_kg: 4.1,
areas_atuacao: ["turismo"],
criado_em: "2026-01-04T13:30:00-03:00",
atualizado_em: "2026-01-26T09:20:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Leonardo Prado",
empreendimento: "Prado Bike Courier",
documento: "72.345.098/0001-27",
email: "contato@pradobike.com",
telefone: "(47) 98444-6677",
faturamento: 4100,
peso_kg: 6.8,
areas_atuacao: ["mineracao"],
criado_em: "2026-01-02T18:40:00-03:00",
atualizado_em: "2026-01-27T12:00:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Marina Lopes",
empreendimento: "Lopes Design Digital",
documento: "56.789.012/0001-11",
email: "marina@lopesdesign.com",
telefone: "(27) 99911-2233",
faturamento: 2100,
peso_kg: 3.7,
areas_atuacao: ["agro"],
criado_em: "2026-01-09T09:00:00-03:00",
atualizado_em: "2026-01-19T15:35:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Nicolas Teixeira",
empreendimento: "QualiVida Fitness",
documento: "34.567.890/0001-02",
email: "nicolas@qualivida.com",
telefone: "(92) 99456-7788",
faturamento: 8600.9,
peso_kg: 11.2,
areas_atuacao: ["agro", "saude"],
criado_em: "2026-01-12T19:15:00-03:00",
atualizado_em: "2026-01-29T10:50:00-03:00",
},
{
codigo: gerarId(),
empreendedor: "Olívia Azevedo",
empreendimento: "Azevedo Pet Care",
documento: "88.901.234/0001-45",
email: "olivia@petcare.com",
telefone: "(16) 99788-6655",
faturamento: 1900.3,
peso_kg: 2.2,
areas_atuacao: ["agro"],
criado_em: "2026-01-10T15:00:00-03:00",
atualizado_em: "2026-01-14T16:00:00-03:00",
},
]

View file

@ -0,0 +1,114 @@
import { respostaComuns, type tipoResposta } from "p-respostas"
import type z from "zod"
import { executarConsultaAlasql } from "./banco"
import { _dados_empreendedores, type tipoEmpreendedores } from "./dados"
import { type tipoFuncaoApiSimulacao, zApiSimulacao } from "./tipagem"
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms * Math.random() + 2000))
// -- API PÚBLICA SIMULADA --
const api_ler: tipoFuncaoApiSimulacao<"listar"> = async (params) => {
// simula latência de rede
const { total, dados } = executarConsultaAlasql({
filtros: params.filtros,
coluna_ordem: params.coluna_ordem as string | undefined,
direcao_ordem: params.direcao_ordem,
limit: params.limit,
offSet: params.offSet,
})
return respostaComuns.valor({
total,
dados,
})
}
const api_criar: tipoFuncaoApiSimulacao<"criar"> = async (dados) => {
if (!dados.empreendedor) {
return respostaComuns.erro("Empreendedor é obrigatório")
}
const novo: tipoEmpreendedores = {
codigo: Math.random().toString(),
empreendedor: dados.empreendedor || "Novo",
empreendimento: dados.empreendimento || "Novo Emp",
documento: dados.documento || "00.000.000/0000-00",
email: dados.email || "novo@email.com",
telefone: dados.telefone || "(00) 0000-0000",
faturamento: dados.faturamento || 0,
peso_kg: dados.peso_kg || 0,
areas_atuacao: dados.areas_atuacao || [],
criado_em: new Date().toISOString(),
atualizado_em: new Date().toISOString(),
}
_dados_empreendedores.unshift(novo)
return respostaComuns.valor(novo)
}
const api_editar: tipoFuncaoApiSimulacao<"editar"> = async (dados) => {
const idx = _dados_empreendedores.find((l) => l.codigo === dados.codigo)
if (!idx) return respostaComuns.erro("Registro não encontrado")
Object.assign(idx, dados.novosDados)
return respostaComuns.valor(idx)
}
const api_deletar: tipoFuncaoApiSimulacao<"deletar"> = async (dados) => {
const idx = _dados_empreendedores.findIndex((l) => l.codigo === dados.codigo)
if (idx !== -1) {
_dados_empreendedores.splice(idx, 1)
return respostaComuns.valor("Registro deletado com sucesso")
}
return respostaComuns.erro("Registro não encontrado")
}
const ApiSimulacao = async <tTipo extends keyof z.infer<typeof zApiSimulacao>>(
tIPO: tTipo,
req: z.infer<typeof zApiSimulacao>[tTipo]["req"],
Callback?: (
req: z.infer<typeof zApiSimulacao>[tTipo]["req"],
res: tipoResposta<z.infer<typeof zApiSimulacao>[tTipo]["res"]>,
) => void,
): Promise<tipoResposta<z.infer<typeof zApiSimulacao>[tTipo]["res"]>> => {
await delay((2000 * Math.random() + 200) | 0)
let retorno: tipoResposta<z.infer<typeof zApiSimulacao>[tTipo]["res"]> =
respostaComuns.erro("Não entrou!")
const zSchema = zApiSimulacao.shape[tIPO]
try {
await zSchema.shape.req.parseAsync(req)
switch (tIPO) {
case "listar":
retorno = await api_ler(req as z.infer<typeof zApiSimulacao>["listar"]["req"])
break
case "criar":
retorno = await api_criar(req as z.infer<typeof zApiSimulacao>["criar"]["req"])
break
case "editar":
retorno = await api_editar(req as z.infer<typeof zApiSimulacao>["editar"]["req"])
break
case "deletar":
retorno = await api_deletar(req as z.infer<typeof zApiSimulacao>["deletar"]["req"])
break
default:
retorno = respostaComuns.erro("Tipo de API não encontrado!")
}
if (retorno.eCerto) {
await zSchema.shape.res.parseAsync(retorno.valor)
}
} catch (error) {
retorno = respostaComuns.erro((error as Error).message)
}
if (Callback) {
Callback(req, retorno)
}
return retorno
}
export { ApiSimulacao }

View file

@ -0,0 +1,52 @@
import { zFiltroDelta } from "p-comuns"
import type { tipoResposta } from "p-respostas"
import z from "zod"
import { type tipoEmpreendedores, zEmpreendedores } from "./dados"
const baseApi = <tTipo extends string, tReq extends z.ZodTypeAny, tRes extends z.ZodTypeAny>(
tipo: tTipo,
req: tReq,
res: tRes,
) =>
z.object({
tipo: z.literal(tipo),
req: req,
res: res,
})
export const zApiSimulacao = z.object({
listar: baseApi(
"listar",
z.object({
filtros: zFiltroDelta.optional(),
coluna_ordem: z.custom<keyof tipoEmpreendedores>().optional(),
direcao_ordem: z.enum(["asc", "desc"]).optional(),
limit: z.number().optional(),
offSet: z.number().optional(),
}),
z.object({
total: z.number(),
dados: z.array(zEmpreendedores),
}),
),
criar: baseApi("criar", zEmpreendedores, zEmpreendedores),
editar: baseApi(
"editar",
z.object({
codigo: z.string(),
novosDados: zEmpreendedores.partial(),
}),
zEmpreendedores,
),
deletar: baseApi(
"deletar",
z.object({
codigo: z.string(),
}),
z.string(),
),
})
export type tipoFuncaoApiSimulacao<tTipo extends keyof z.infer<typeof zApiSimulacao>> = (
dados: z.infer<typeof zApiSimulacao>[tTipo]["req"],
) => Promise<tipoResposta<z.infer<typeof zApiSimulacao>[tTipo]["res"]>>

View file

@ -121,9 +121,7 @@ export default defineComponent({
function itensFiltrados(itens: Card[]) {
const q = filtro.value.trim().toLowerCase()
if (!q) return itens
return itens.filter((i) =>
`${i.titulo} ${i.cliente} ${i.valor}`.toLowerCase().includes(q),
)
return itens.filter((i) => `${i.titulo} ${i.cliente} ${i.valor}`.toLowerCase().includes(q))
}
function criar() {

View file

@ -125,12 +125,7 @@
<script lang="ts">
import { defineComponent } from "vue"
import {
EliEntradaNumero,
EliEntradaParagrafo,
EliEntradaSelecao,
EliEntradaTexto,
} from "../index"
import { EliEntradaNumero, EliEntradaParagrafo, EliEntradaSelecao, EliEntradaTexto } from "../index"
export default defineComponent({
name: "EntradasPlayground",

View file

@ -11,6 +11,7 @@ import * as directives from "vuetify/directives"
// Estilos globais da lib (font-face + variáveis de fonte)
// Importamos DEPOIS do Vuetify para garantir que `--v-font-family` seja sobrescrito.
import "../styles/eli-vue-fonts.css"
import "../design-tokens.css"
const themeDefault = {
dark: false,
@ -173,22 +174,15 @@ if (import.meta.env.DEV) {
// eslint-disable-next-line no-console
console.log(
"[eli-vue] --v-font-family:",
getComputedStyle(document.documentElement).getPropertyValue(
"--v-font-family",
),
getComputedStyle(document.documentElement).getPropertyValue("--v-font-family"),
)
// eslint-disable-next-line no-console
console.log(
"[eli-vue] --eli-font-family:",
getComputedStyle(document.documentElement).getPropertyValue(
"--eli-font-family",
),
getComputedStyle(document.documentElement).getPropertyValue("--eli-font-family"),
)
// eslint-disable-next-line no-console
console.log(
"[eli-vue] body font-family:",
getComputedStyle(document.body).fontFamily,
)
console.log("[eli-vue] body font-family:", getComputedStyle(document.body).fontFamily)
}
app.use(vuetify).mount("#app")

View file

@ -2,15 +2,14 @@
<section class="stack">
<h2>EliTabela</h2>
<!-- Exemplo: botão de ação "Novo" no cabeçalho que adiciona uma linha -->
<div style="margin-bottom: 12px">
<strong>Selecionados:</strong> {{ selecionados.map(s => s.empreendedor).join(', ') || 'Nenhum' }}
</div>
<EliTabela :tabela="tabelaOk" :key="versaoTabelaOk" v-model:selecionados="selecionados" />
<EliTabela :tabela="tabelaOk" :key="versaoTabelaOk" v-model:selecionados="selecionados" esconderDebug />
<EliTabela :tabela="tabelaVazia" />
<EliTabela :tabela="tabelaVazia" esconderDebug />
<EliTabela :tabela="tabelaErro" />
<EliTabela :tabela="tabelaErro" esconderDebug />
<!-- Modal de simulação de cadastro -->
<v-dialog v-model="mostrarModalCadastro" max-width="500px">
@ -65,359 +64,15 @@
</template>
<script lang="ts">
import { BadgeCheck, Eye, Pencil, Plus, Trash2 } from "@lucide/vue"
import { criarFiltro26, type tipoFiltro26 } from "p-comuns"
import { codigosResposta } from "p-respostas"
import { defineComponent, ref } from "vue"
import type { ComponenteEntrada } from "@/componentes/EliEntrada/tiposEntradas"
import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela"
import { celulaTabela, EliTabela } from "@/componentes/EliTabela"
import { api_criar, api_deletar, api_ler, type Linha } from "./api_simulador"
import { defineComponent } from "vue"
import { EliTabela } from "@/componentes/EliTabela"
import { useTabelaPlayground } from "./useTabelaPlayground"
export default defineComponent({
name: "TabelaPlayground",
components: { EliTabela },
setup() {
const acoesLinha: tipoEliTabelaConsulta<Linha>["acoesLinha"] = [
{
icone: Eye,
cor: "#2563eb",
rotulo: "Detalhes",
acao: (linha) => {
console.log("Visualizar detalhes de", linha.empreendedor)
},
},
{
icone: Trash2,
cor: "#dc2626",
rotulo: "Remover",
acao: (linha) => {
removerCadastro(linha)
},
exibir: (linha) => !linha.empreendimento.toLowerCase().includes("café"),
},
]
const versaoTabelaOk = ref(0)
const selecionados = ref<Linha[]>([])
const mostrarModalCadastro = ref(false)
const salvando = ref(false)
const novoCadastro = ref({
empreendedor: "",
empreendimento: "",
documento: "",
email: "",
telefone: "",
faturamento: 0,
peso_kg: 0,
})
const erroCadastro = ref<string | undefined>(undefined)
// Armazena a função de atualização da tabela atual (vinda da ação do cabeçalho)
let atualizarTabelaCallback: (() => Promise<void>) | undefined
// A ação do cabeçalho recebe { atualizarConsulta, editarLista, ... }
// biome-ignore lint/suspicious/noExplicitAny: playground
function abrirModalCadastro(params?: any) {
if (params?.atualizarConsulta) {
atualizarTabelaCallback = params.atualizarConsulta
}
novoCadastro.value = {
empreendedor: "",
empreendimento: "",
documento: "",
email: "",
telefone: "",
faturamento: 0,
peso_kg: 0,
}
erroCadastro.value = undefined
mostrarModalCadastro.value = true
}
async function salvarNovoCadastro() {
salvando.value = true
erroCadastro.value = undefined
try {
const resposta = await api_criar(novoCadastro.value)
if (resposta.eErro) {
erroCadastro.value = resposta.mensagem
return
}
// PADRÃO RECOMENDADO:
// Se temos o callback de atualização, usamos ele para recarregar os dados na tabela
// sem precisar remontar o componente (sem alterar key).
if (atualizarTabelaCallback) {
await atualizarTabelaCallback()
} else {
// Fallback (caso não tenha vindo pela ação da tabela): força re-render
versaoTabelaOk.value++
}
mostrarModalCadastro.value = false
} catch (e) {
erroCadastro.value = String(e)
} finally {
salvando.value = false
}
}
function removerCadastro(linha: Linha) {
// Exemplo de uso do api_deletar
// Não temos um ID único confiável além das propriedades, vamos usar empreendedor como chave neste exemplo simples
if (confirm(`Tem certeza que deseja remover ${linha.empreendedor}?`)) {
api_deletar("empreendedor", linha.empreendedor).then(() => {
versaoTabelaOk.value++
})
}
}
const tabelaOk: tipoEliTabelaConsulta<Linha> = {
nome: "ExemploPlaygroundData",
registros_por_consulta: 10,
mostrarCaixaDeBusca: true,
mostrar_selecao: true,
chave_selecao: "empreendedor",
acoesTabela: [
{
posicao: "superior",
icone: Plus,
cor: "#16a34a",
rotulo: "Novo (Superior)",
acao: abrirModalCadastro,
},
{
posicao: "inferior",
icone: Plus,
cor: "#2563eb",
rotulo: "Novo (Inferior)",
acao: abrirModalCadastro,
},
],
colunas: [
{
rotulo: "Empreendedor",
celula: (l) =>
celulaTabela("textoSimples", { texto: l.empreendedor }),
coluna_ordem: "empreendedor",
visivel: true,
},
{
rotulo: "Empreendimento",
celula: (l) =>
celulaTabela("textoSimples", { texto: l.empreendimento }),
coluna_ordem: "empreendimento",
visivel: true,
},
{
rotulo: "Documento",
celula: (l) => celulaTabela("textoSimples", { texto: l.documento }),
coluna_ordem: "documento",
visivel: false,
},
{
rotulo: "E-mail",
celula: (l) =>
celulaTabela("textoTruncado", {
texto: l.email,
acao: () => {
// Exemplo de ação: poderia abrir detalhes
alert(`Clicou em ${l.email}`)
},
}),
coluna_ordem: "email",
visivel: true,
},
{
rotulo: "Telefone",
celula: (l) => celulaTabela("textoSimples", { texto: l.telefone }),
coluna_ordem: "telefone",
visivel: true,
},
{
rotulo: "Faturamento",
celula: (l) =>
celulaTabela("numero", {
numero: l.faturamento,
prefixo: "R$",
// Exemplo de ação (clicável)
acao: () =>
alert(`Faturamento de ${l.empreendedor}: R$ ${l.faturamento}`),
}),
visivel: true,
},
{
rotulo: "Peso",
celula: (l) =>
celulaTabela("numero", {
numero: l.peso_kg,
sufixo: "kg",
}),
visivel: false,
},
{
rotulo: "Status",
celula: (l) =>
celulaTabela("tags", {
opcoes: [
{
rotulo: "Ativo",
cor: "success",
icone: BadgeCheck,
},
{
rotulo: "Editar",
cor: "primary",
icone: Pencil,
acao: () => {
alert(`Editar ${l.empreendedor}`)
},
},
],
}),
visivel: true,
},
{
rotulo: "Criado em",
celula: (l) =>
celulaTabela("data", {
valor: l.criado_em,
formato: "data",
}),
visivel: false,
},
{
rotulo: "Atualizado",
celula: (l) =>
celulaTabela("data", {
valor: l.atualizado_em,
formato: "relativo",
acao: () => alert(`Atualizado em: ${l.atualizado_em}`),
}),
visivel: true,
},
],
acoesLinha: acoesLinha,
filtroAvancado: [
{
chave: "empreendedor",
rotulo: "Empreendedor",
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
filtro: (valor: unknown) =>
criarFiltro26({
// biome-ignore lint/suspicious/noExplicitAny: playground
empreendedor: { like: valor as string } as any,
}) as tipoFiltro26<Linha>,
entrada: ["texto", { rotulo: "Empreendedor" }] as ComponenteEntrada,
},
{
chave: "documento",
rotulo: "Documento",
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
filtro: (valor: string) =>
criarFiltro26({
// biome-ignore lint/suspicious/noExplicitAny: playground
documento: { like: valor as string } as any,
}) as tipoFiltro26<Linha>,
entrada: [
"texto",
{ rotulo: "Documento", formato: "cpfCnpj" },
] as ComponenteEntrada,
},
{
chave: "email",
rotulo: "E-mail",
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
filtro: (valor: string) =>
criarFiltro26({
// biome-ignore lint/suspicious/noExplicitAny: playground
email: { like: valor as string } as any,
}) as tipoFiltro26<Linha>,
entrada: [
"texto",
{ rotulo: "E-mail", formato: "email" },
] as ComponenteEntrada,
},
],
consulta: async (parametrosConsulta) => {
try {
const resposta = await api_ler(parametrosConsulta)
return {
cod: codigosResposta.sucesso,
eCerto: true,
eErro: false,
mensagem: undefined,
valor: {
quantidade: resposta.total,
valores: resposta.dados,
},
}
} catch (error) {
return {
cod: codigosResposta.erroConhecido,
eCerto: false,
eErro: true,
mensagem: String(error),
valor: undefined,
}
}
},
}
const tabelaVazia: tipoEliTabelaConsulta<Linha> = {
nome: "Exemplo Vazio",
registros_por_consulta: 10,
mostrarCaixaDeBusca: true,
colunas: tabelaOk.colunas,
consulta: async (_parametrosConsulta) => {
return {
cod: codigosResposta.sucesso,
eCerto: true,
eErro: false,
mensagem: undefined,
valor: {
quantidade: 0,
valores: [],
},
}
},
mensagemVazio: "Nada para mostrar aqui.",
acoesLinha: acoesLinha,
}
const tabelaErro: tipoEliTabelaConsulta<Linha> = {
nome: "Exemplo",
registros_por_consulta: tabelaOk.registros_por_consulta,
mostrarCaixaDeBusca: tabelaOk.mostrarCaixaDeBusca,
colunas: tabelaOk.colunas,
acoesLinha: acoesLinha,
consulta: async (_parametrosConsulta) => {
return {
cod: codigosResposta.erroConhecido,
eCerto: false,
eErro: true,
mensagem: "Falha ao buscar dados",
valor: undefined,
}
},
}
return {
tabelaOk,
tabelaVazia,
tabelaErro,
versaoTabelaOk,
mostrarModalCadastro,
salvando,
novoCadastro,
salvarNovoCadastro,
erroCadastro,
selecionados,
}
return useTabelaPlayground()
},
})
</script>
@ -429,6 +84,15 @@ export default defineComponent({
}
.stack :deep(.eli-tabela) {
max-width: 900px;
max-width: 100%;
}
.debug {
padding: 12px;
background: rgba(0, 0, 0, 0.04);
border-radius: 8px;
overflow: auto;
font-size: 12px;
margin-bottom: 8px;
}
</style>

View file

@ -0,0 +1,171 @@
/**
* useTabelaPlayground
* Composable principal do playground da EliTabela.
* Gerencia estado de CRUD (modal, formulário, erros) e compõe as 3 instâncias de tabela de demonstração.
* A configuração de colunas, filtros e consulta está em `useTabelaPlaygroundConfig.ts`.
*
* @returns Estado reativo, configurações de tabela e ações de CRUD para o template
* @see useTabelaPlaygroundConfig.ts — definições de colunas, filtros e consulta
* @see tabela.playground.vue — componente que consome este composable
*/
import { Plus } from "@lucide/vue"
import { codigosResposta } from "p-respostas"
import { inject, ref } from "vue"
import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela"
import { PLAYGROUND_CONSOLE_KEY, type PlaygroundConsole } from "./App.vue"
import { ApiSimulacao } from "./api_simulador"
import type { tipoEmpreendedores } from "./api_simulador/dados"
import { colunasTabela, criarAcoesLinha, filtrosAvancados } from "./useTabelaPlaygroundConfig"
import { consultar } from "./useTabelaPlaygroundConsultar"
export function useTabelaPlayground() {
const playgroundConsole = inject<PlaygroundConsole>(PLAYGROUND_CONSOLE_KEY)
function adicionarLog(acao: string, req: unknown, res: unknown) {
if (playgroundConsole) playgroundConsole.adicionarLog(`ApiSimulacao: ${acao}`, req, res)
}
const versaoTabelaOk = ref(0)
const selecionados = ref<tipoEmpreendedores[]>([])
const mostrarModalCadastro = ref(false)
const salvando = ref(false)
const novoCadastro = ref<Omit<tipoEmpreendedores, "codigo" | "criado_em" | "atualizado_em">>({
empreendedor: "",
empreendimento: "",
documento: "",
email: "",
telefone: "",
faturamento: 0,
peso_kg: 0,
areas_atuacao: [],
})
const erroCadastro = ref<string | undefined>(undefined)
let atualizarTabelaCallback: (() => Promise<void>) | undefined
// biome-ignore lint/suspicious/noExplicitAny: playground — parâmetros dinâmicos da ação de tabela
function abrirModalCadastro(params?: any) {
if (params?.atualizarConsulta) atualizarTabelaCallback = params.atualizarConsulta
novoCadastro.value = {
empreendedor: "",
empreendimento: "",
documento: "",
email: "",
telefone: "",
faturamento: 0,
peso_kg: 0,
areas_atuacao: [],
}
erroCadastro.value = undefined
mostrarModalCadastro.value = true
}
async function salvarNovoCadastro() {
salvando.value = true
erroCadastro.value = undefined
try {
const payload: tipoEmpreendedores = {
...novoCadastro.value,
codigo: "novo",
criado_em: new Date().toISOString(),
atualizado_em: new Date().toISOString(),
}
const resposta = await ApiSimulacao("criar", payload, (req, res) => {
console.groupCollapsed("🌐 [ApiSimulacao] criar")
console.info("Req:", req)
console.info("Res:", res)
console.groupEnd()
adicionarLog("criar", req, res)
})
if (resposta.eErro) {
erroCadastro.value = resposta.mensagem
return
}
if (atualizarTabelaCallback) await atualizarTabelaCallback()
else versaoTabelaOk.value++
mostrarModalCadastro.value = false
} catch (e) {
erroCadastro.value = String(e)
} finally {
salvando.value = false
}
}
function removerCadastro(linha: tipoEmpreendedores) {
if (confirm(`Tem certeza que deseja remover ${linha.empreendedor}?`)) {
ApiSimulacao("deletar", { codigo: linha.codigo }, (req, res) => {
console.groupCollapsed("🌐 [ApiSimulacao] deletar")
console.info("Req:", req)
console.info("Res:", res)
console.groupEnd()
adicionarLog("deletar", req, res)
}).then(() => {
versaoTabelaOk.value++
})
}
}
const acoesLinha = criarAcoesLinha(removerCadastro)
const consultarFn: tipoEliTabelaConsulta<tipoEmpreendedores>["consulta"] = (params) =>
consultar(params, adicionarLog)
const tabelaOk: tipoEliTabelaConsulta<tipoEmpreendedores> = {
nome: "ExemploPlaygroundData",
registros_por_consulta: 10,
mostrarCaixaDeBusca: true,
mostrar_selecao: true,
chave_selecao: "empreendedor",
acoesTabela: [
{ posicao: "superior", icone: Plus, rotulo: "Novo (Superior)", acao: abrirModalCadastro },
{ posicao: "inferior", icone: Plus, rotulo: "Novo (Inferior)", acao: abrirModalCadastro },
],
colunas: colunasTabela,
acoesLinha,
filtroAvancado: filtrosAvancados,
consulta: consultarFn,
}
const tabelaVazia: tipoEliTabelaConsulta<tipoEmpreendedores> = {
nome: "Exemplo Vazio",
registros_por_consulta: 10,
mostrarCaixaDeBusca: true,
colunas: colunasTabela,
mensagemVazio: "Nada para mostrar aqui.",
acoesLinha,
consulta: async () => ({
cod: codigosResposta.sucesso,
eCerto: true,
eErro: false,
mensagem: undefined,
valor: { quantidade: 0, valores: [] },
}),
}
const tabelaErro: tipoEliTabelaConsulta<tipoEmpreendedores> = {
nome: "Exemplo",
registros_por_consulta: tabelaOk.registros_por_consulta,
mostrarCaixaDeBusca: tabelaOk.mostrarCaixaDeBusca,
colunas: colunasTabela,
acoesLinha,
consulta: async () => ({
cod: codigosResposta.erroConhecido,
eCerto: false,
eErro: true,
mensagem: "Falha ao buscar dados",
valor: undefined,
}),
}
return {
tabelaOk,
tabelaVazia,
tabelaErro,
versaoTabelaOk,
mostrarModalCadastro,
salvando,
novoCadastro,
salvarNovoCadastro,
erroCadastro,
selecionados,
}
}

Some files were not shown because too many files have changed in this diff Show more