diff --git a/.agent/atualizar-agente.global.sh b/.agent/atualizar-agente.global.sh index 3202604..b4707bf 100755 --- a/.agent/atualizar-agente.global.sh +++ b/.agent/atualizar-agente.global.sh @@ -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 diff --git a/.agent/rules/vue-limite-linhas.global.md b/.agent/rules/vue-limite-linhas.global.md new file mode 100644 index 0000000..4ff943b --- /dev/null +++ b/.agent/rules/vue-limite-linhas.global.md @@ -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 `` 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. diff --git a/.agent/workflows/documentar-codigo.global.md b/.agent/workflows/documentar-codigo.global.md deleted file mode 100644 index 1f53eb5..0000000 --- a/.agent/workflows/documentar-codigo.global.md +++ /dev/null @@ -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) - diff --git a/.agent/workflows/otimizar-codigos.global.md b/.agent/workflows/otimizar-codigos.global.md new file mode 100644 index 0000000..b8ad87d --- /dev/null +++ b/.agent/workflows/otimizar-codigos.global.md @@ -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 `.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 + +``` + +**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 ` diff --git a/src/componentes/EliEntrada/EliEntradaSelecao.vue b/src/componentes/EliEntrada/EliEntradaSelecao.vue index cfb87f0..2db73e2 100644 --- a/src/componentes/EliEntrada/EliEntradaSelecao.vue +++ b/src/componentes/EliEntrada/EliEntradaSelecao.vue @@ -23,14 +23,7 @@ - - diff --git a/src/componentes/EliTabela/EliTabelaBody.vue b/src/componentes/EliTabela/EliTabelaBody.vue index 2a42caa..2e4038f 100644 --- a/src/componentes/EliTabela/EliTabelaBody.vue +++ b/src/componentes/EliTabela/EliTabelaBody.vue @@ -102,12 +102,12 @@ export default defineComponent({ props: { colunas: { // biome-ignore lint/suspicious/noExplicitAny: dynamic column - type: Array as PropType>>, + type: Array as PropType[]>, required: true, }, colunasInvisiveis: { // biome-ignore lint/suspicious/noExplicitAny: dynamic column - type: Array as PropType>>, + type: Array as PropType[]>, required: true, }, temColunasInvisiveis: { @@ -119,7 +119,7 @@ export default defineComponent({ required: true, }, linhas: { - type: Array as PropType>, + type: Array as PropType, required: true, }, temAcoes: { diff --git a/src/componentes/EliTabela/EliTabelaCabecalho.vue b/src/componentes/EliTabela/EliTabelaCabecalho.vue index 509f727..3b7f22e 100644 --- a/src/componentes/EliTabela/EliTabelaCabecalho.vue +++ b/src/componentes/EliTabela/EliTabelaCabecalho.vue @@ -8,6 +8,7 @@ class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--colunas" @click="emitColunas" > + Colunas @@ -17,6 +18,7 @@ class="eli-tabela__acoes-cabecalho-botao eli-tabela__acoes-cabecalho-botao--filtro" @click="emitFiltroAvancado" > + Filtro @@ -46,12 +48,13 @@ diff --git a/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue b/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue index 7afe280..c2e87c3 100644 --- a/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue +++ b/src/componentes/EliTabela/EliTabelaDetalhesLinha.vue @@ -24,7 +24,7 @@ export default defineComponent({ }, colunasInvisiveis: { // biome-ignore lint/suspicious/noExplicitAny: dynamic column - type: Array as PropType>>, + type: Array as PropType[]>, required: true, }, }, diff --git a/src/componentes/EliTabela/EliTabelaEstados.vue b/src/componentes/EliTabela/EliTabelaEstados.vue index dccf7da..31b4c77 100644 --- a/src/componentes/EliTabela/EliTabelaEstados.vue +++ b/src/componentes/EliTabela/EliTabelaEstados.vue @@ -1,26 +1,33 @@ - diff --git a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css new file mode 100644 index 0000000..4965b6a --- /dev/null +++ b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.css @@ -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; +} + diff --git a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue index e546f5f..4d91354 100644 --- a/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue +++ b/src/componentes/EliTabela/EliTabelaModalFiltroAvancado.vue @@ -1,403 +1,182 @@ + - diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.css b/src/componentes/EliTabela/EliTabelaPaginacao.css new file mode 100644 index 0000000..c72a470 --- /dev/null +++ b/src/componentes/EliTabela/EliTabelaPaginacao.css @@ -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; +} diff --git a/src/componentes/EliTabela/EliTabelaPaginacao.vue b/src/componentes/EliTabela/EliTabelaPaginacao.vue index bc5687c..d021fb0 100644 --- a/src/componentes/EliTabela/EliTabelaPaginacao.vue +++ b/src/componentes/EliTabela/EliTabelaPaginacao.vue @@ -77,7 +77,7 @@ - diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue index 6ddb949..35a4ac3 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelula.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelula.vue @@ -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) diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue index 7e7adfd..709496d 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaData.vue @@ -12,7 +12,13 @@ @@ -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; } diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue index a5b5611..f35a788 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaNumero.vue @@ -35,12 +35,12 @@ export default defineComponent({ type: Object as PropType, }, }, - 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 } }, }) @@ -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; } diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue index 80e5b03..e2e80f8 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTags.vue @@ -37,9 +37,6 @@ export default defineComponent({ required: false, }, }, - setup({ dados }) { - return { dados } - }, }) diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue index 7e61dfb..61771f0 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoSimples.vue @@ -26,9 +26,6 @@ export default defineComponent({ return {} }, methods: {}, - setup({ dados }) { - return { dados } - }, }) @@ -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; } diff --git a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue index 30eba16..6995eb2 100644 --- a/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue +++ b/src/componentes/EliTabela/celulas/EliTabelaCelulaTextoTruncado.vue @@ -23,9 +23,6 @@ export default defineComponent({ type: Object as PropType, }, }, - setup({ dados }) { - return { dados } - }, }) @@ -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; } diff --git a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts index 049d06b..3ed9819 100644 --- a/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts +++ b/src/componentes/EliTabela/celulas/registryTabelaCelulas.ts @@ -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.vue` + * 2. Adicionar a chave em `tipoTabelaCelula` (tiposTabelaCelulas.ts) + * 3. Registrar aqui no mapa + */ export const registryTabelaCelulas = { textoSimples: EliTabelaCelulaTextoSimples, textoTruncado: EliTabelaCelulaTextoTruncado, diff --git a/src/componentes/EliTabela/colunasStorage.ts b/src/componentes/EliTabela/colunasStorage.ts index bb2b9c6..5cbe999 100644 --- a/src/componentes/EliTabela/colunasStorage.ts +++ b/src/componentes/EliTabela/colunasStorage.ts @@ -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::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 } } diff --git a/src/componentes/EliTabela/filtroAvancadoStorage.ts b/src/componentes/EliTabela/filtroAvancadoStorage.ts index fdf8230..33eba84 100644 --- a/src/componentes/EliTabela/filtroAvancadoStorage.ts +++ b/src/componentes/EliTabela/filtroAvancadoStorage.ts @@ -1,43 +1,77 @@ -export type EliTabelaFiltroAvancadoSalvo = Array<{ - coluna: keyof T - - valor: T[keyof T] -}> +export type EliTabelaFiltroAvancadoSalvo = { + linhas: Array<{ + chave: keyof T + /** Valor vindo de JSON.parse — tipo desconhecido intencionalmente; faça narrowing no consumidor. */ + valor: unknown + }> + formula?: string +} function key(nomeTabela: string) { return `eli_tabela:${nomeTabela}:filtro_avancado` } -export function carregarFiltroAvancado( - nomeTabela: string, -): EliTabelaFiltroAvancadoSalvo { +/** + * Carrega do `localStorage` as linhas de filtro avançado salvas para uma tabela. + * Trata dois formatos legados: array direto de filtros (v1) e objeto `{ linhas, formula }` (v2). + * Em caso de dado ausente, corrompido ou formato desconhecido, retorna estado vazio. + * + * @param nomeTabela - Identificador único da tabela (campo `nome` de `tipoEliTabelaConsulta`) + * @returns Estado de filtros salvo, ou `{ linhas: [], formula: '' }` como fallback seguro + */ +export function carregarFiltroAvancado(nomeTabela: string): EliTabelaFiltroAvancadoSalvo { + const vazio: EliTabelaFiltroAvancadoSalvo = { linhas: [], formula: "" } try { const raw = localStorage.getItem(key(nomeTabela)) - if (!raw) return [] as unknown as EliTabelaFiltroAvancadoSalvo + if (!raw) return vazio + const parsed = JSON.parse(raw) - return Array.isArray(parsed) - ? (parsed as EliTabelaFiltroAvancadoSalvo) - : ([] as unknown as EliTabelaFiltroAvancadoSalvo) + if (Array.isArray(parsed)) { + return { linhas: parsed, formula: "" } as EliTabelaFiltroAvancadoSalvo + } + + if (parsed && typeof parsed === "object") { + return { + linhas: Array.isArray(parsed.linhas) ? parsed.linhas : [], + formula: typeof parsed.formula === "string" ? parsed.formula : "", + } as EliTabelaFiltroAvancadoSalvo + } + + return vazio } catch { - return [] as unknown as EliTabelaFiltroAvancadoSalvo + 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( nomeTabela: string, filtros: EliTabelaFiltroAvancadoSalvo, ) { 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 } } diff --git a/src/componentes/EliTabela/types-eli-tabela.ts b/src/componentes/EliTabela/types-eli-tabela.ts index 8171d39..70fb000 100644 --- a/src/componentes/EliTabela/types-eli-tabela.ts +++ b/src/componentes/EliTabela/types-eli-tabela.ts @@ -1,14 +1,9 @@ import type { LucideIcon } from "@lucide/vue" -import type { tipoFiltro26 } from "p-comuns" - -export type tipoFiltro = tipoFiltro26 +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 = readonly [ T, @@ -19,6 +14,20 @@ export type tipoComponenteCelula = { [K in tipoTabelaCelula]: tipoComponenteCelulaBase }[tipoTabelaCelula] +/** + * Cria uma tupla tipada `[tipo, dados]` que representa o conteúdo de uma célula da tabela. + * Funciona como type-safe builder: o TypeScript infere o tipo exato de `dados` a partir de `tipo`, + * garantindo que não seja possível passar dados incorretos para um tipo de célula. + * + * Uso esperado dentro de `tipoEliColuna.celula`: + * ```ts + * celula: (linha) => celulaTabela("textoSimples", { texto: linha.nome }) + * ``` + * + * @param tipo - Identificador do tipo de célula (chave de `tiposTabelaCelulas`) + * @param dados - Dados tipados para o tipo de célula escolhido + * @returns Tupla `[tipo, dados]` consumida pelo `EliTabelaCelula.vue` + */ export const celulaTabela = ( tipo: T, dados: tiposTabelaCelulas[T], @@ -72,7 +81,7 @@ export type tipoEliTabelaAcao = { } export type parametrosConsulta = { - filtros?: tipoFiltro26[] + filtros?: tipoFiltroDeltaColunaTipada>[] coluna_ordem?: keyof T direcao_ordem?: "asc" | "desc" offSet?: number @@ -152,7 +161,7 @@ export type tipoEliTabelaConsulta = { /** Rótulo exibido no select de adicionar filtro */ rotulo: string /** vai gerar filtro e mescla com os filtros já existentes */ - filtro: (valor: unknown) => tipoFiltro26 + filtro: (valor: unknown) => tipoFiltroDeltaColunaTipada> | undefined /** componente de entrada que vai receber o valor para o filtro */ entrada: ComponenteEntrada }[] diff --git a/src/componentes/EliTabela/useColunasOrdenacao.ts b/src/componentes/EliTabela/useColunasOrdenacao.ts new file mode 100644 index 0000000..7f23781 --- /dev/null +++ b/src/componentes/EliTabela/useColunasOrdenacao.ts @@ -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 + rotulosColunas: Ref + configInicial: Ref + // biome-ignore lint/suspicious/noExplicitAny: colunas genéricas — tipo parametrizado no pai + colunas: Ref[]> +} + +/** + * Composable de drag-and-drop para reordenação e alternância de visibilidade de colunas. + * Sincroniza automaticamente quando `aberto`, `rotulosColunas`, `configInicial` ou `colunas` mudam. + * + * @param options.aberto - Ref que controla se o modal está aberto (gatilho de sincronização) + * @param options.rotulosColunas - Ref com todos os rótulos de colunas da tabela + * @param options.configInicial - Ref com a configuração persistida (visíveis + invisíveis) + * @param options.colunas - Ref com a definição completa das colunas (para ler `visivel: false` padrão) + * @returns Estado reativo e handlers de drag-and-drop prontos para o template + */ +export function useColunasOrdenacao({ + aberto, + rotulosColunas, + configInicial, + colunas, +}: UseColunasOrdenacaoOptions) { + const visiveisLocal = ref([]) + const invisiveisLocal = ref([]) + const itemSendoArrastado = ref(null) + + function sincronizarEstado() { + const todos = rotulosColunas.value + const configTemDados = + (configInicial.value.visiveis?.length ?? 0) > 0 || + (configInicial.value.invisiveis?.length ?? 0) > 0 + + const invisiveisPadraoSet = new Set( + colunas.value.filter((c) => c.visivel === false).map((c) => c.rotulo), + ) + const invisiveisSet = configTemDados + ? new Set(configInicial.value.invisiveis ?? []) + : invisiveisPadraoSet + + const baseVisiveis = todos.filter((r) => !invisiveisSet.has(r)) + const ordemSalva = configInicial.value.visiveis ?? [] + const setVis = new Set(baseVisiveis) + const ordenadas: string[] = [] + + for (const r of ordemSalva) { + if (setVis.has(r)) ordenadas.push(r) + } + for (const r of baseVisiveis) { + if (!ordenadas.includes(r)) ordenadas.push(r) + } + + visiveisLocal.value = ordenadas + invisiveisLocal.value = todos.filter((r) => invisiveisSet.has(r)) + } + + watch( + () => [aberto.value, rotulosColunas.value, configInicial.value, colunas.value] as const, + () => { + if (aberto.value) sincronizarEstado() + }, + { deep: true, immediate: true }, + ) + + function onDragStart(e: DragEvent, rotulo: string) { + itemSendoArrastado.value = rotulo + if (e.dataTransfer) { + e.dataTransfer.setData("text/plain", rotulo) + e.dataTransfer.effectAllowed = "move" + } + } + + function onDragEnd() { + itemSendoArrastado.value = null + } + + function onDragOverItem(alvoRotulo: string, destino: OrigemLista) { + const draggedRotulo = itemSendoArrastado.value + if (!draggedRotulo || draggedRotulo === alvoRotulo) return + + const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo) + const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo) + const origem = origemVisIdx >= 0 ? "visiveis" : origemInvIdx >= 0 ? "invisiveis" : null + if (!origem) return + + const origemArr = origem === "visiveis" ? visiveisLocal.value : invisiveisLocal.value + const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value + + const dragIdx = origemArr.indexOf(draggedRotulo) + const alvoIdx = destinoArr.indexOf(alvoRotulo) + + origemArr.splice(dragIdx, 1) + destinoArr.splice(alvoIdx, 0, draggedRotulo) + } + + function onDragOverLista(destino: OrigemLista) { + const draggedRotulo = itemSendoArrastado.value + if (!draggedRotulo) return + + const destinoArr = destino === "visiveis" ? visiveisLocal.value : invisiveisLocal.value + if (destinoArr[destinoArr.length - 1] === draggedRotulo) return + + const origemVisIdx = visiveisLocal.value.indexOf(draggedRotulo) + const origemInvIdx = invisiveisLocal.value.indexOf(draggedRotulo) + if (origemVisIdx >= 0) visiveisLocal.value.splice(origemVisIdx, 1) + if (origemInvIdx >= 0) invisiveisLocal.value.splice(origemInvIdx, 1) + + destinoArr.push(draggedRotulo) + } + + return { + visiveisLocal, + invisiveisLocal, + itemSendoArrastado, + onDragStart, + onDragEnd, + onDragOverItem, + onDragOverLista, + } +} diff --git a/src/componentes/EliTabela/useEliTabela.ts b/src/componentes/EliTabela/useEliTabela.ts new file mode 100644 index 0000000..973b52f --- /dev/null +++ b/src/componentes/EliTabela/useEliTabela.ts @@ -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 + +export function useEliTabela( + props: { tabela: TabelaProps; selecionados: unknown[]; esconderDebug: boolean }, + emit: (event: "update:selecionados", value: unknown[]) => void, +) { + // Estado compartilhado entre sub-composables (passado por ref para reatividade) + const paginaAtual = ref(1) + const valorBusca = ref("") + + // Filtros avançados (precisa de carregar que ainda não existe; passa proxy que usará o da consulta) + // Usamos um ref de função para quebrar a dependência circular: filtros precisam de carregar, + // e consulta precisa de filtrosAvancadosAtivos. + let _carregarProxy: () => Promise = async () => { + /* será atribuído após consulta ser criada */ + } + const carregarProxy = () => _carregarProxy() + + const filtros = useEliTabelaFiltrosAvancados(props, paginaAtual, valorBusca, carregarProxy) + + // Colunas: temAcoes vem da consulta — usamos um ref intermediário + const _temAcoesRef = ref(false) + const colunas = useEliTabelaColunas(props, _temAcoesRef) + + const consulta = useEliTabelaConsulta( + props, + filtros.filtrosAvancadosAtivos, + paginaAtual, + valorBusca, + colunas.linhasExpandidas, + ) + + // Conecta o proxy ao carregar real + _carregarProxy = consulta.carregar + // Sincroniza temAcoes para o composable de colunas + // biome-ignore lint/suspicious/noExplicitAny: ref sync interno + ;(_temAcoesRef as any).value = consulta.temAcoes.value + watch(consulta.temAcoes, (v) => { + _temAcoesRef.value = v + }) + + const selecao = useEliTabelaSelecao(props, emit, consulta.linhasPaginadas) + const menu = useEliTabelaMenu(props, consulta.acoesVisiveis) + + // Watch: troca de configuração de tabela reseta todos os estados e recarrega + watch( + () => props.tabela, + () => { + menu.menuAberto.value = null + consulta.colunaOrdenacao.value = null + consulta.direcaoOrdenacao.value = "asc" + valorBusca.value = "" + colunas.modalColunasAberto.value = false + filtros.modalFiltroAberto.value = false + colunas.configColunas.value = carregarConfigColunas(props.tabela.nome) + const filtrosRecarregados = carregarFiltroAvancado>(props.tabela.nome) + filtros.filtrosUi.value = filtrosRecarregados.linhas + colunas.linhasExpandidas.value = {} + if (paginaAtual.value !== 1) paginaAtual.value = 1 + else void consulta.carregar() + }, + ) + + onMounted(() => void consulta.carregar()) + + return { + ...consulta, + ...filtros, + ...selecao, + ...colunas, + menuAberto: menu.menuAberto, + menuPopup: menu.menuPopup, + menuPopupPos: menu.menuPopupPos, + possuiAcoes: menu.possuiAcoes, + toggleMenu: menu.toggleMenu, + acoesDisponiveisPorLinha: menu.acoesDisponiveisPorLinha, + paginaAtual, + valorBusca, + } +} diff --git a/src/componentes/EliTabela/useEliTabelaColunas.ts b/src/componentes/EliTabela/useEliTabelaColunas.ts new file mode 100644 index 0000000..6b6bb19 --- /dev/null +++ b/src/componentes/EliTabela/useEliTabelaColunas.ts @@ -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[]; nome: string } } + +export function useEliTabelaColunas(props: PropsColunas, temAcoes: Ref) { + const modalColunasAberto = ref(false) + const configColunas = ref(carregarConfigColunas(props.tabela.nome)) + const linhasExpandidas = ref>({}) + + const rotulosColunas = computed(() => props.tabela.colunas.map((c) => c.rotulo)) + + const colunasInvisiveisEfetivas = computed(() => { + // biome-ignore lint/suspicious/noExplicitAny: dynamic column + const colunas = props.tabela.colunas as tipoEliColuna[] + const configTemDados = + (configColunas.value.visiveis?.length ?? 0) > 0 || + (configColunas.value.invisiveis?.length ?? 0) > 0 + const invisiveisBaseRotulos = configTemDados + ? (configColunas.value.invisiveis ?? []) + : colunas.filter((c) => c.visivel === false).map((c) => c.rotulo) + const invisiveisSet = new Set(invisiveisBaseRotulos) + const base = colunas.filter((c) => invisiveisSet.has(c.rotulo)) + // biome-ignore lint/suspicious/noExplicitAny: dynamic map + const mapa = new Map>() + for (const c of base) { + if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c) + } + // biome-ignore lint/suspicious/noExplicitAny: dynamic column + const ordenadas: tipoEliColuna[] = [] + for (const r of invisiveisBaseRotulos) { + const c = mapa.get(r) + if (c) ordenadas.push(c) + } + for (const c of base) { + if (!ordenadas.includes(c)) ordenadas.push(c) + } + return ordenadas + }) + + const temColunasInvisiveis = computed(() => colunasInvisiveisEfetivas.value.length > 0) + + const colunasEfetivas = computed(() => { + const colunas = props.tabela.colunas + const todosRotulos = rotulosColunas.value + const configTemDados = + (configColunas.value.visiveis?.length ?? 0) > 0 || + (configColunas.value.invisiveis?.length ?? 0) > 0 + const invisiveisBaseRotulos = configTemDados + ? (configColunas.value.invisiveis ?? []) + : // biome-ignore lint/suspicious/noExplicitAny: dynamic column + (props.tabela.colunas as tipoEliColuna[]) + .filter((c) => c.visivel === false) + .map((c) => c.rotulo) + const invisiveisSet = new Set(invisiveisBaseRotulos) + const visiveisBaseRotulos = todosRotulos.filter((r) => !invisiveisSet.has(r)) + const visiveisSet = new Set(visiveisBaseRotulos) + const ordemSalva = configTemDados ? (configColunas.value.visiveis ?? []) : [] + const ordemFinal: string[] = [] + for (const r of ordemSalva) { + if (visiveisSet.has(r)) ordemFinal.push(r) + } + for (const r of visiveisBaseRotulos) { + if (!ordemFinal.includes(r)) ordemFinal.push(r) + } + // biome-ignore lint/suspicious/noExplicitAny: dynamic map + const mapa = new Map() + for (const c of colunas) { + if (!mapa.has(c.rotulo)) mapa.set(c.rotulo, c) + } + return ordemFinal.map((r) => mapa.get(r)).filter(Boolean) + }) + + const totalColunas = computed( + () => + colunasEfetivas.value.length + + (temAcoes.value ? 1 : 0) + + (temColunasInvisiveis.value ? 1 : 0), + ) + + function abrirModalColunas() { + modalColunasAberto.value = true + } + + function fecharModalColunas() { + modalColunasAberto.value = false + } + + function salvarModalColunas(cfg: EliTabelaColunasConfig) { + configColunas.value = cfg + salvarConfigColunas(props.tabela.nome, cfg) + modalColunasAberto.value = false + linhasExpandidas.value = {} + } + + function alternarLinhaExpandida(indice: number) { + const atual = Boolean(linhasExpandidas.value[indice]) + linhasExpandidas.value = { ...linhasExpandidas.value, [indice]: !atual } + } + + return { + modalColunasAberto, + configColunas, + linhasExpandidas, + rotulosColunas, + colunasEfetivas, + colunasInvisiveisEfetivas, + temColunasInvisiveis, + totalColunas, + abrirModalColunas, + fecharModalColunas, + salvarModalColunas, + alternarLinhaExpandida, + } +} diff --git a/src/componentes/EliTabela/useEliTabelaConsulta.ts b/src/componentes/EliTabela/useEliTabelaConsulta.ts new file mode 100644 index 0000000..6cfb853 --- /dev/null +++ b/src/componentes/EliTabela/useEliTabelaConsulta.ts @@ -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; esconderDebug: boolean } + +export function useEliTabelaConsulta( + props: PropsConsulta, + // biome-ignore lint/suspicious/noExplicitAny: genérico + filtrosAvancadosAtivos: Ref[]>, + paginaAtual: Ref, + valorBusca: Ref, + linhasExpandidas: Ref>, +) { + const isDev = import.meta.env.DEV && !props.esconderDebug + const carregando = ref(false) + const erro = ref(null) + const linhas = ref([]) + const quantidade = ref(0) + const acoesVisiveis = ref([]) + const colunaOrdenacao = ref(null) + const direcaoOrdenacao = ref<"asc" | "desc">("asc") + + const tabela = computed(() => props.tabela) + const registrosPorConsulta = computed(() => { + const valor = props.tabela.registros_por_consulta + return typeof valor === "number" && valor > 0 ? Math.floor(valor) : 10 + }) + const totalPaginas = computed(() => { + const limite = registrosPorConsulta.value + if (!limite || limite <= 0) return 1 + const total = quantidade.value ?? 0 + return total ? Math.max(1, Math.ceil(total / limite)) : 1 + }) + const linhasPaginadas = computed(() => linhas.value ?? []) + const quantidadeFiltrada = computed(() => quantidade.value ?? 0) + const temAcoes = computed(() => (props.tabela.acoesLinha ?? []).length > 0) + const exibirBusca = computed(() => Boolean(props.tabela.mostrarCaixaDeBusca)) + const acoesSuperiores = computed(() => + (props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "superior"), + ) + const acoesInferiores = computed(() => + (props.tabela.acoesTabela ?? []).filter((a) => a.posicao === "inferior"), + ) + const temAcoesSuperiores = computed(() => acoesSuperiores.value.length > 0) + const temAcoesInferiores = computed(() => acoesInferiores.value.length > 0) + const parametrosConsultaAtuais = computed(() => { + const limite = Math.max(1, registrosPorConsulta.value) + const offset = (paginaAtual.value - 1) * limite + // biome-ignore lint/suspicious/noExplicitAny: dynamic params + const params: parametrosConsulta = { offSet: offset, limit: limite } + const busca = (valorBusca.value ?? "").trim() + if (busca) { + params.texto_busca = busca + } else { + const filtros = filtrosAvancadosAtivos.value + if (filtros.length) params.filtros = filtros + } + if (colunaOrdenacao.value) { + params.coluna_ordem = colunaOrdenacao.value + params.direcao_ordem = direcaoOrdenacao.value + } + return { + ...params, + atualizarConsulta: async () => { + await carregar() + }, + // biome-ignore lint/suspicious/noExplicitAny: dynamic list update + editarLista: async (novaLista: any[]) => { + linhas.value = novaLista + return novaLista + }, + } + }) + + let carregamentoSequencial = 0 + + async function carregar() { + const idCarregamento = ++carregamentoSequencial + carregando.value = true + erro.value = null + try { + acoesVisiveis.value = [] + linhasExpandidas.value = {} + const limite = Math.max(1, registrosPorConsulta.value) + const offset = (paginaAtual.value - 1) * limite + const parametros: { + // biome-ignore lint/suspicious/noExplicitAny: generic + filtros?: tipoFiltroDeltaColunaTipada[] + coluna_ordem?: never + direcao_ordem?: "asc" | "desc" + offSet: number + limit: number + texto_busca?: string + } = { offSet: offset, limit: limite } + const busca = (valorBusca.value ?? "").trim() + if (busca) { + parametros.texto_busca = busca + } else { + const filtros = filtrosAvancadosAtivos.value + if (filtros.length) parametros.filtros = filtros + } + if (colunaOrdenacao.value) { + parametros.coluna_ordem = colunaOrdenacao.value as never + parametros.direcao_ordem = direcaoOrdenacao.value + } + if (isDev) console.log("[EliTabela] consulta(parametros)", parametros) // eslint-disable-line no-console + const res = await props.tabela.consulta(parametros) + if (idCarregamento !== carregamentoSequencial) return + if (res.cod !== codigosResposta.sucesso) { + linhas.value = [] + quantidade.value = 0 + erro.value = res.mensagem + return + } + const valores = res.valor?.valores ?? [] + const total = res.valor?.quantidade ?? valores.length + linhas.value = valores + quantidade.value = Number(total) || 0 + const totalPaginasRecalculado = Math.max(1, Math.ceil((quantidade.value || 0) / limite)) + if (paginaAtual.value > totalPaginasRecalculado) paginaAtual.value = totalPaginasRecalculado + const acoesConf = props.tabela.acoesLinha ?? [] + if (!acoesConf.length) { + acoesVisiveis.value = [] + return + } + acoesVisiveis.value = valores.map(() => + acoesConf.map((a) => + a.exibir === undefined ? true : typeof a.exibir === "boolean" ? a.exibir : false, + ), + ) + const visibilidade = await Promise.all( + valores.map((linha) => + Promise.all( + acoesConf.map(async (acao) => { + if (acao.exibir === undefined) return true + if (typeof acao.exibir === "boolean") return acao.exibir + try { + return Boolean(await Promise.resolve(acao.exibir(linha as never))) + } catch { + return false + } + }), + ), + ), + ) + if (idCarregamento === carregamentoSequencial) acoesVisiveis.value = visibilidade + } catch (e) { + console.error("[EliTabela] Erro capturado em carregar():", e) + if (idCarregamento !== carregamentoSequencial) return + linhas.value = [] + quantidade.value = 0 + erro.value = e instanceof Error ? e.message : "Erro ao carregar dados." + } finally { + if (idCarregamento === carregamentoSequencial) carregando.value = false + } + } + + function alternarOrdenacao(chave?: string) { + if (!chave) return + if (colunaOrdenacao.value === chave) { + direcaoOrdenacao.value = direcaoOrdenacao.value === "asc" ? "desc" : "asc" + void carregar() + return + } + colunaOrdenacao.value = chave + direcaoOrdenacao.value = "asc" + if (paginaAtual.value !== 1) paginaAtual.value = 1 + else void carregar() + } + + function atualizarBusca(texto: string) { + if (valorBusca.value === texto) return + valorBusca.value = texto + if (paginaAtual.value !== 1) paginaAtual.value = 1 + else void carregar() + } + + function irParaPagina(pagina: number) { + const alvo = Math.min(Math.max(1, pagina), totalPaginas.value) + if (alvo !== paginaAtual.value) paginaAtual.value = alvo + } + + watch(paginaAtual, (nova, antiga) => { + if (nova !== antiga) void carregar() + }) + watch(linhas, () => { + linhasExpandidas.value = {} + }) + watch( + () => props.tabela.mostrarCaixaDeBusca, + (mostrar) => { + if (!mostrar && valorBusca.value) { + valorBusca.value = "" + if (paginaAtual.value !== 1) paginaAtual.value = 1 + else void carregar() + } + }, + ) + watch( + () => props.tabela.registros_por_consulta, + () => { + if (paginaAtual.value !== 1) paginaAtual.value = 1 + else void carregar() + }, + ) + + return { + isDev, + tabela, + carregando, + erro, + linhas, + linhasPaginadas, + quantidade, + quantidadeFiltrada, + acoesVisiveis, + colunaOrdenacao, + direcaoOrdenacao, + registrosPorConsulta, + totalPaginas, + temAcoes, + exibirBusca, + acoesSuperiores, + acoesInferiores, + temAcoesSuperiores, + temAcoesInferiores, + parametrosConsultaAtuais, + carregar, + alternarOrdenacao, + atualizarBusca, + irParaPagina, + } +} diff --git a/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts b/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts new file mode 100644 index 0000000..dcdc843 --- /dev/null +++ b/src/componentes/EliTabela/useEliTabelaFiltrosAvancados.ts @@ -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, + valorBusca: Ref, + carregar: () => Promise, +) { + const _filtrosCarregados = carregarFiltroAvancado>(props.tabela.nome) + const filtrosUi = ref(_filtrosCarregados.linhas) + const filtrosFormula = ref(_filtrosCarregados.formula ?? "") + const modalFiltroAberto = ref(false) + + const exibirFiltroAvancado = computed(() => (props.tabela.filtroAvancado ?? []).length > 0) + + // biome-ignore lint/suspicious/noExplicitAny: generic + const filtrosAvancadosAtivos = computed[]>(() => { + const base = props.tabela.filtroAvancado ?? [] + const ativos = (filtrosUi.value ?? []).filter((f) => f && f.chave !== undefined) + if (ativos.length === 0) return [] + + const filtrosIndividuais = ativos + .map((f) => { + const b = base.find((x) => String(x.chave) === String(f.chave)) + if (!b) return null + return { chave: f.chave, filtro: b.filtro(f.valor) } + }) + .filter( + (f): f is { chave: string; filtro: tipoFiltroDeltaColunaTipada } => + 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 = {} + 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>(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, + } +} diff --git a/src/componentes/EliTabela/useEliTabelaMenu.ts b/src/componentes/EliTabela/useEliTabelaMenu.ts new file mode 100644 index 0000000..eb8c58a --- /dev/null +++ b/src/componentes/EliTabela/useEliTabelaMenu.ts @@ -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[] } } + +export function useEliTabelaMenu(props: PropsMenu, acoesVisiveis: Ref) { + const menuAberto = ref(null) + const menuPopup = ref<{ menuEl: HTMLElement | null } | null>(null) + const menuPopupPos = ref({ top: 0, left: 0 }) + + function atualizarPosicaoMenu(anchor: HTMLElement) { + const rect = anchor.getBoundingClientRect() + const gap = 8 + const alturaMenu = menuPopup.value?.menuEl?.offsetHeight ?? 0 + const larguraMenu = menuPopup.value?.menuEl?.offsetWidth ?? 180 + let top = rect.bottom + gap + const left = rect.right - larguraMenu + if (alturaMenu && top + alturaMenu > window.innerHeight - gap) { + top = rect.top - gap - alturaMenu + } + menuPopupPos.value = { + top: Math.max(gap, Math.round(top)), + left: Math.max(gap, Math.round(left)), + } + } + + function handleClickFora(evento: MouseEvent) { + if (menuAberto.value === null) return + const alvo = evento.target as Node + if (menuPopup.value?.menuEl?.contains(alvo)) return + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] click fora => fechar menu", { menuAberto: menuAberto.value }) + } + menuAberto.value = null + } + + function acoesDisponiveisPorLinha(i: number) { + const acoesLinha = props.tabela.acoesLinha ?? [] + const visibilidade = acoesVisiveis.value[i] ?? [] + return acoesLinha + .map((acao, indice) => { + const fallbackVisivel = + acao.exibir === undefined ? true : typeof acao.exibir === "boolean" ? acao.exibir : false + return { acao, indice, visivel: visibilidade[indice] ?? fallbackVisivel } + }) + .filter((item) => item.visivel) + } + + function possuiAcoes(i: number) { + return acoesDisponiveisPorLinha(i).length > 0 + } + + function toggleMenu(i: number, evento?: MouseEvent) { + if (!possuiAcoes(i)) return + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] toggleMenu (antes)", { i, atual: menuAberto.value }) + } + if (menuAberto.value === i) { + menuAberto.value = null + return + } + menuAberto.value = i + if (import.meta.env.DEV) { + // eslint-disable-next-line no-console + console.log("[EliTabela] toggleMenu => abriu", { i }) + } + const anchor = (evento?.currentTarget as HTMLElement | null) ?? null + if (anchor) { + atualizarPosicaoMenu(anchor) + nextTick(() => { + if (menuAberto.value !== null) atualizarPosicaoMenu(anchor) + }) + } + } + + onMounted(() => document.addEventListener("click", handleClickFora)) + onBeforeUnmount(() => document.removeEventListener("click", handleClickFora)) + + return { menuAberto, menuPopup, menuPopupPos, possuiAcoes, toggleMenu, acoesDisponiveisPorLinha } +} diff --git a/src/componentes/EliTabela/useEliTabelaSelecao.ts b/src/componentes/EliTabela/useEliTabelaSelecao.ts new file mode 100644 index 0000000..cebd5b3 --- /dev/null +++ b/src/componentes/EliTabela/useEliTabelaSelecao.ts @@ -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, +) { + const mostrarSelecao = computed(() => Boolean(props.tabela.mostrar_selecao)) + const chaveSelecao = computed(() => props.tabela.chave_selecao as string | undefined) + + function obterChaveLinha(linha: unknown): unknown { + if (chaveSelecao.value && linha && typeof linha === "object") { + return (linha as Record)[chaveSelecao.value] + } + return linha + } + + function isSelecionado(linha: unknown): boolean { + const chave = obterChaveLinha(linha) + if (chaveSelecao.value) { + return props.selecionados.some((s) => obterChaveLinha(s) === chave) + } + return props.selecionados.includes(linha) + } + + function alternarSelecao(linha: unknown) { + const atual = [...props.selecionados] + const chave = obterChaveLinha(linha) + + let index = -1 + if (chaveSelecao.value) { + index = atual.findIndex((s) => obterChaveLinha(s) === chave) + } else { + index = atual.indexOf(linha) + } + + if (index === -1) { + atual.push(linha) + } else { + atual.splice(index, 1) + } + emit("update:selecionados", atual) + } + + const todosSelecionados = computed(() => { + if (linhasPaginadas.value.length === 0) return false + return linhasPaginadas.value.every((linha) => isSelecionado(linha)) + }) + + const algunsSelecionados = computed(() => { + if (linhasPaginadas.value.length === 0) return false + return linhasPaginadas.value.some((linha) => isSelecionado(linha)) && !todosSelecionados.value + }) + + function alternarSelecaoTodos(forcarMarcar?: boolean) { + const marcarTodos = forcarMarcar ?? !todosSelecionados.value + let atual = [...props.selecionados] + + if (marcarTodos) { + for (const linha of linhasPaginadas.value) { + if (!isSelecionado(linha)) atual.push(linha) + } + } else { + const chavesPagina = linhasPaginadas.value.map(obterChaveLinha) + if (chaveSelecao.value) { + atual = atual.filter((s) => !chavesPagina.includes(obterChaveLinha(s))) + } else { + atual = atual.filter((s) => !chavesPagina.includes(s)) + } + } + emit("update:selecionados", atual) + } + + return { + mostrarSelecao, + isSelecionado, + alternarSelecao, + todosSelecionados, + algunsSelecionados, + alternarSelecaoTodos, + } +} diff --git a/src/componentes/EliTabela/useFiltroAvancado.ts b/src/componentes/EliTabela/useFiltroAvancado.ts new file mode 100644 index 0000000..010420a --- /dev/null +++ b/src/componentes/EliTabela/useFiltroAvancado.ts @@ -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 = NonNullable["filtroAvancado"]>[number] + +export type LinhaFiltro = { + chave: string + entrada: ComponenteEntrada + /** Valor do campo de filtro — unknown intencionalmente (vem de localStorage ou entrada do usuário). */ + valor: unknown +} + +/** + * Retorna o rótulo de exibição de uma definição de filtro. + * Utilizado no template do modal para exibir o nome do campo ao usuário. + * + * @param f - Definição de filtro da configuração da tabela + * @returns Rótulo textual do filtro + */ +export function rotuloDoFiltro(f: FiltroBase) { + return f.rotulo +} + +interface UseFiltroAvancadoOptions { + aberto: Ref + filtrosBase: Ref[]> + modelo: Ref> + modeloFormula: Ref + emit: { + (event: "fechar"): void + (event: "limpar"): void + (event: "salvar", linhas: Array<{ chave: string; valor: unknown }>, formula?: string): void + } +} + +/** + * Composable principal do modal de filtro avançado da EliTabela. + * Sincroniza automaticamente quando o modal abre ou o modelo/filtrosBase mudam. + * + * @param options.aberto - Ref que controla se o modal está aberto + * @param options.filtrosBase - Ref com as definições de filtros disponíveis na tabela + * @param options.modelo - Ref com as linhas de filtro persistidas (vindo do pai) + * @param options.modeloFormula - Ref com a fórmula booleana persistida no pai + * @param options.emit - Função de emit do componente Vue pai + * @returns Estado reativo e ações do modal de filtro + */ +export function useFiltroAvancado({ + aberto, + filtrosBase, + modelo, + modeloFormula, + emit, +}: UseFiltroAvancadoOptions) { + const linhas = ref([]) + const chaveParaAdicionar = ref("") + const formulaCustomizada = ref("") + + const formulaGerada = computed(() => linhas.value.map((_, i) => `{c${i + 1}}`).join(" E ")) + + function formulaParaExibicao(formula: string): string { + let resultado = formula + linhas.value.forEach((l, i) => { + resultado = resultado.replace(new RegExp(`\\{${l.chave}\\}`, "g"), `{c${i + 1}}`) + }) + return resultado + } + + function formulaParaPersistencia(formula: string): string { + let resultado = formula + for (let i = linhas.value.length; i >= 1; i--) { + const chave = linhas.value[i - 1]?.chave + if (chave) { + resultado = resultado.replace(new RegExp(`\\{c${i}\\}`, "g"), `{${chave}}`) + } + } + return resultado + } + + const chavesDisponiveis = computed(() => (filtrosBase.value ?? []).map((b) => String(b.chave))) + + const opcoesParaAdicionar = computed(() => { + const usadas = new Set(linhas.value.map((l) => String(l.chave))) + return (filtrosBase.value ?? []).filter((b) => !usadas.has(String(b.chave))) + }) + + function componenteEntrada(entrada: ComponenteEntrada) { + const tipo = entrada?.[0] + if (tipo === "numero") return EliEntradaNumero + if (tipo === "dataHora") return EliEntradaDataHora + return EliEntradaTexto + } + + function opcoesEntrada(entrada: ComponenteEntrada) { + const opcoes: Record = + typeof entrada?.[1] === "object" && entrada[1] !== null + ? { ...(entrada[1] as Record) } + : {} + return { ...opcoes, rotulo: "" } + } + + function valorInicialPorEntrada(entrada: ComponenteEntrada) { + if (entrada?.[0] === "numero") return null + return "" + } + + function normalizarModelo() { + const base = filtrosBase.value ?? [] + const modeloArr = Array.isArray(modelo.value) ? modelo.value : [] + linhas.value = modeloArr + .map((m: unknown) => { + if (!m || typeof m !== "object") return null + const item = m as Record + const baseItem = base.find((b) => String(b.chave) === String(item["chave"])) ?? base[0] + if (!baseItem) return null + const entrada = (baseItem.entrada ?? item["entrada"]) as ComponenteEntrada + const chave = String(baseItem.chave ?? item["chave"]) + const val = item["valor"] ?? valorInicialPorEntrada(entrada) + return { chave, entrada, valor: val } as LinhaFiltro + }) + .filter(Boolean) as LinhaFiltro[] + + linhas.value = linhas.value.filter((l) => chavesDisponiveis.value.includes(String(l.chave))) + + if (modeloFormula.value) { + formulaCustomizada.value = formulaParaExibicao(modeloFormula.value) + } else { + formulaCustomizada.value = formulaGerada.value + } + } + + watch( + () => [aberto.value, filtrosBase.value, modelo.value] as const, + () => { + if (aberto.value) normalizarModelo() + }, + { deep: true, immediate: true }, + ) + + function adicionar() { + if (!chaveParaAdicionar.value) return + const b0 = (filtrosBase.value ?? []).find( + (b) => String(b.chave) === String(chaveParaAdicionar.value), + ) + if (!b0) return + if (linhas.value.some((l) => String(l.chave) === String(b0.chave))) return + linhas.value.push({ + chave: String(b0.chave), + entrada: b0.entrada, + valor: valorInicialPorEntrada(b0.entrada), + }) + formulaCustomizada.value = formulaGerada.value + chaveParaAdicionar.value = "" + } + + function remover(idx: number) { + linhas.value.splice(idx, 1) + formulaCustomizada.value = formulaGerada.value + } + + function emitFechar() { + emit("fechar") + } + function emitLimpar() { + emit("limpar") + } + + function emitSalvar() { + const formulaPersistida = formulaParaPersistencia(formulaCustomizada.value) + emit( + "salvar", + linhas.value.map((l) => ({ chave: l.chave, valor: l.valor })), + formulaPersistida, + ) + } + + function rotuloDoFiltroAtivo(chave: string): string { + const base = (filtrosBase.value ?? []).find((b) => String(b.chave) === chave) + return base ? rotuloDoFiltro(base) : chave + } + + return { + linhas, + opcoesParaAdicionar, + chaveParaAdicionar, + formulaCustomizada, + formulaGerada, + componenteEntrada, + opcoesEntrada, + adicionar, + remover, + emitFechar, + emitSalvar, + emitLimpar, + rotuloDoFiltro, + rotuloDoFiltroAtivo, + } +} diff --git a/src/design-tokens.css b/src/design-tokens.css new file mode 100644 index 0000000..4b840ad --- /dev/null +++ b/src/design-tokens.css @@ -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; +} diff --git a/src/index.ts b/src/index.ts index 97391f7..c1c04b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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" diff --git a/src/playground/App.vue b/src/playground/App.vue index dde1d02..b27ddd8 100644 --- a/src/playground/App.vue +++ b/src/playground/App.vue @@ -1,33 +1,78 @@ + + @@ -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; } diff --git a/src/playground/useTabelaPlayground.ts b/src/playground/useTabelaPlayground.ts new file mode 100644 index 0000000..165d9b1 --- /dev/null +++ b/src/playground/useTabelaPlayground.ts @@ -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(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([]) + const mostrarModalCadastro = ref(false) + const salvando = ref(false) + const novoCadastro = ref>({ + empreendedor: "", + empreendimento: "", + documento: "", + email: "", + telefone: "", + faturamento: 0, + peso_kg: 0, + areas_atuacao: [], + }) + const erroCadastro = ref(undefined) + + let atualizarTabelaCallback: (() => Promise) | 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["consulta"] = (params) => + consultar(params, adicionarLog) + + const tabelaOk: tipoEliTabelaConsulta = { + 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 = { + 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 = { + 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, + } +} diff --git a/src/playground/useTabelaPlaygroundConfig.ts b/src/playground/useTabelaPlaygroundConfig.ts new file mode 100644 index 0000000..8ebc242 --- /dev/null +++ b/src/playground/useTabelaPlaygroundConfig.ts @@ -0,0 +1,190 @@ +/** + * useTabelaPlaygroundConfig + * Definições de colunas, filtros avançados, ações por linha e função consultar do playground. + * Extraído de useTabelaPlayground.ts para manter cada arquivo abaixo de 250 linhas. + * + * @see useTabelaPlayground.ts — composable que consome estas configurações + */ +import { BadgeCheck, Eye, Pencil, Trash2 } from "@lucide/vue" +import { iniciarFiltroDelta } from "p-comuns" + +import type { ComponenteEntrada } from "@/componentes/EliEntrada/tiposEntradas" +import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela" +import { celulaTabela } from "@/componentes/EliTabela" + +import type { tipoEmpreendedores } from "./api_simulador/dados" + +const { criarFiltroDelta } = iniciarFiltroDelta>() + +/** Ações por linha usadas nas 3 instâncias de tabela do playground */ +export function criarAcoesLinha( + removerCadastro: (linha: tipoEmpreendedores) => void, +): tipoEliTabelaConsulta["acoesLinha"] { + return [ + { + 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é"), + }, + ] +} + +/** Colunas da tabela de empreendedores */ +export const colunasTabela: tipoEliTabelaConsulta["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: () => { + 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$", + 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, + }, +] + +/** Filtros avançados da tabela de empreendedores */ +export const filtrosAvancados: tipoEliTabelaConsulta["filtroAvancado"] = [ + { + chave: "empreendedor", + rotulo: "Empreendedor", + filtro: (valor: unknown) => { + if (!valor) return undefined + return criarFiltroDelta({ + condicoes: [ + { id: "{c1}", coluna: "empreendedor", operador: "like", valor: valor as string }, + ], + formula: "{c1}", + }) + }, + entrada: ["texto", { rotulo: "Empreendedor" }] as ComponenteEntrada, + }, + { + chave: "empreendimento", + rotulo: "Empreendimento", + filtro: (valor: unknown) => { + if (!valor) return undefined + return criarFiltroDelta({ + condicoes: [ + { id: "{c1}", coluna: "empreendimento", operador: "like", valor: valor as string }, + ], + formula: "{c1}", + }) + }, + entrada: ["texto", { rotulo: "Empreendimento" }] as ComponenteEntrada, + }, + { + chave: "documento", + rotulo: "Documento", + filtro: (valor: unknown) => { + if (!valor) return undefined + return criarFiltroDelta({ + condicoes: [{ id: "{c1}", coluna: "documento", operador: "like", valor: valor as string }], + formula: "{c1}", + }) + }, + entrada: ["texto", { rotulo: "Documento", formato: "cpfCnpj" }] as ComponenteEntrada, + }, + { + chave: "email", + rotulo: "E-mail", + filtro: (valor: unknown) => { + if (!valor) return undefined + return criarFiltroDelta({ + condicoes: [{ id: "{c1}", coluna: "email", operador: "like", valor: valor as string }], + formula: "{c1}", + }) + }, + entrada: ["texto", { rotulo: "E-mail", formato: "email" }] as ComponenteEntrada, + }, +] + +/** + * Executa a consulta na ApiSimulacao, traduzindo busca textual e filtros avançados para FiltroDelta. + * @param parametrosConsulta - Parâmetros vindos do EliTabela (offSet, limit, filtros, texto_busca) + * @param adicionarLog - Callback para registrar no painel de debug do playground + */ diff --git a/src/playground/useTabelaPlaygroundConsultar.ts b/src/playground/useTabelaPlaygroundConsultar.ts new file mode 100644 index 0000000..9849d9d --- /dev/null +++ b/src/playground/useTabelaPlaygroundConsultar.ts @@ -0,0 +1,102 @@ +/** + * consultarPlayground + * Executa a consulta na ApiSimulacao traduzindo busca textual e filtros avançados para FiltroDelta. + * Extraído de useTabelaPlaygroundConfig.ts para manter cada arquivo abaixo de 250 linhas. + * + * @see useTabelaPlaygroundConfig.ts — definições de colunas e filtros + * @see useTabelaPlayground.ts — composable que usa este consultar + */ +import { iniciarFiltroDelta } from "p-comuns" +import { codigosResposta } from "p-respostas" +import type { tipoEliTabelaConsulta } from "@/componentes/EliTabela" +import { ApiSimulacao } from "./api_simulador" +import type { tipoEmpreendedores } from "./api_simulador/dados" + +const { criarFiltroDelta } = iniciarFiltroDelta>() + +export async function consultar( + parametrosConsulta: Parameters["consulta"]>[0], + adicionarLog: (acao: string, req: unknown, res: unknown) => void, +): ReturnType["consulta"]> { + try { + // biome-ignore lint/suspicious/noExplicitAny: playground — mock de API com estrutura dinâmica + const req: any = { ...(parametrosConsulta || {}) } + if (req.texto_busca) { + const termo = `${req.texto_busca}` + const fBusca = criarFiltroDelta({ + condicoes: [ + { id: "{c1}", coluna: "empreendedor", operador: "like", valor: termo }, + { id: "{c2}", coluna: "empreendimento", operador: "like", valor: termo }, + ], + formula: "{c1} OU {c2}", + }) + if (req.filtros) { + const fExistente = req.filtros + const condicoesBusca = fBusca.condicoes.map( + (c: { id: string; coluna: string; operador: string; valor: unknown }) => ({ + ...c, + id: c.id.replace(/[{}]/g, (m: string) => (m === "{" ? "{busca_" : "}")), + }), + ) + const formulaBusca = fBusca.formula.replace(/\{(\w+)\}/g, "{busca_$1}") + req.filtros = criarFiltroDelta({ + condicoes: [...fExistente.condicoes, ...condicoesBusca], + formula: `(${fExistente.formula}) E (${formulaBusca})`, + }) + } else { + req.filtros = fBusca + } + delete req.texto_busca + } else if (Array.isArray(req.filtros)) { + const todos = req.filtros as ReturnType[] + if (todos.length === 1) { + req.filtros = todos[0] + } else if (todos.length > 1) { + const condicoesFinais: unknown[] = [] + const partes: string[] = [] + for (let i = 0; i < todos.length; i++) { + const f = todos[i] + const prefixo = `f${i}_` + const conds = f.condicoes.map( + (c: { id: string; coluna: string; operador: string; valor: unknown }) => ({ + ...c, + id: c.id.replace(/\{(\w+)\}/, `{${prefixo}$1}`), + }), + ) + const formula = f.formula.replace(/\{(\w+)\}/g, `{${prefixo}$1}`) + condicoesFinais.push(...conds) + partes.push(`(${formula})`) + } + req.filtros = criarFiltroDelta({ + condicoes: condicoesFinais as ReturnType["condicoes"], + formula: partes.join(" E "), + }) + } else { + delete req.filtros + } + } + const resposta = await ApiSimulacao("listar", req, (r, res) => { + console.groupCollapsed("🌐 [ApiSimulacao] listar") + console.info("Req:", r) + console.info("Res:", res) + console.groupEnd() + adicionarLog("listar", r, res) + }) + if (resposta.eErro) throw new Error(resposta.mensagem) + return { + cod: codigosResposta.sucesso, + eCerto: true, + eErro: false, + mensagem: undefined, + valor: { quantidade: resposta.valor.total, valores: resposta.valor.dados }, + } + } catch (error) { + return { + cod: codigosResposta.erroConhecido, + eCerto: false, + eErro: true, + mensagem: String(error), + valor: undefined, + } + } +} diff --git a/src/tipos/indicador.ts b/src/tipos/indicador.ts index c9660a6..cef88df 100644 --- a/src/tipos/indicador.ts +++ b/src/tipos/indicador.ts @@ -12,16 +12,7 @@ export type IndicadorLocalizacao = | "left center" | "bottom left" -export type IndicadorOffset = - | "-20" - | "-15" - | "-10" - | "-5" - | "0" - | "20" - | "15" - | "10" - | "5" +export type IndicadorOffset = "-20" | "-15" | "-10" | "-5" | "0" | "20" | "15" | "10" | "5" export type IndicadorPresetRaio = "suave" | "pill"