diff --git a/.gitignore b/.gitignore index 430873a3ba..0442d2e01a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ openapitools.json .DS_Store .vscode/settings.json -.claude/ \ No newline at end of file +/.claude/ \ No newline at end of file diff --git a/frontend/.claude/project-knowledge/form-creation.md b/frontend/.claude/project-knowledge/form-creation.md new file mode 100644 index 0000000000..e1c031751f --- /dev/null +++ b/frontend/.claude/project-knowledge/form-creation.md @@ -0,0 +1,112 @@ +## Formulários + +### Schema Yup + +- Schemas ficam em `src/consts/formSchemas/.ts` +- Importar primitivos de `./initSchema` (não direto do `yup`): + ```ts + import { + object, + string, + number, + array, + boolean, + date, + mixed, + } from "./initSchema"; + ``` +- Sempre usar `.label('Nome do Campo')` em cada campo — o `SmaeLabel` extrai isso automaticamente +- Campos opcionais: `.nullable()` ou `.nullableOuVazio()` +- Campos condicionais: `.when('outro_campo', { is: ..., then: ..., otherwise: ... })` +- Exportar como named export: `export const minhaEntidadeSchema = object().shape({ ... })` +- Schemas em `formSchemas/` separados **não** precisam ser registrados em `formSchemas.js` — são importados diretamente na view + +### Componente CriarEditar.vue + +Local: `src/views//CriarEditar.vue` + +**Padrão com `useForm`** — formulários complexos (arrays dinâmicos, watches, valores computados): + +```vue + + + +``` + +> **Atenção:** O padrão com `
` wrapper do vee-validate **não deve ser usado** neste projeto. Sempre use o padrão com `useForm` acima, independentemente da complexidade do formulário. + +### Componentes de campo (globais) + +| Componente | Uso | +| ----------------------------------------------------------------- | ------------------------------------------ | +| `` | Label com asterisco automático se required | +| `` | Input de texto padrão | +| `` | Select | +| `` | Mensagem de erro inline | +| `` | Textarea/input com contador de caracteres | +| `` | Datepicker | +| `` | Input numérico | +| `` | Autocomplete multi-select | +| `` | Array dinâmico de campos | +| `` | Lista todos os erros do formulário | +| `` | Botão salvar padronizado | +| `` | Cabeçalho com botão de fechar | + +### Classes CSS utilitárias + +- `flex g2` — flex com gap +- `f1` — flex: 1 (ocupa espaço disponível) +- `inputtext light` — estilo padrão de input +- `error-msg` — estilo de mensagem de erro diff --git a/frontend/.claude/project-knowledge/list-creation.md b/frontend/.claude/project-knowledge/list-creation.md new file mode 100644 index 0000000000..b9b799ae06 --- /dev/null +++ b/frontend/.claude/project-knowledge/list-creation.md @@ -0,0 +1,294 @@ +# Criação de Listas no Frontend SMAE + +## 1. Visão Geral + +Páginas de lista seguem um padrão altamente consistente: + +- **Dados**: Pinia stores (`buscarTudo`, `excluirItem`) +- **Renderização**: Componente `SmaeTable` (substitui `` manual) +- **Cabeçalho**: Componente `CabecalhoDePagina` +- **Filtros**: Componente `FiltroParaPagina` (filtros com URL) ou `LocalFilter` (busca simples) +- **Loading**: Componente `LoadingComponent` +- **Estados vazio/erro**: Tratados automaticamente pelo `SmaeTable` + +--- + +## 2. Perguntas Obrigatórias Antes de Implementar + +Antes de criar uma lista, pergunte ao usuário: + +1. **A listagem tem filtros?** Se sim: quais campos? Os filtros devem persistir na URL? + - Com persistência na URL → `FiltroParaPagina` + - Busca simples client-side → `LocalFilter` (ou `filtrarObjetos` com `route.query`) + +2. **A listagem tem paginação?** Se sim, usar `MenuPaginacao`. Ver seção [MenuPaginacao](#7-menupaginacao). + +--- + +## 3. Convenção de Arquivos e Nomes + +``` +src/views// + Lista.vue # Página de listagem + Raiz.vue # Layout raiz (nested routes) + CriarEditar.vue # Formulário criar/editar +``` + +--- + +## 4. Template Mínimo de Lista (padrão atual) + +```vue + + + +``` + +--- + +## 4.1 Watch para Filtros e Paginação + +Se a lista tiver **filtros via URL** (`FiltroParaPagina` ou `route.query`), adicionar um `watch` que recarrega os dados quando os query params mudam: + +```javascript +import { onMounted, watch } from "vue"; +import { useRoute } from "vue-router"; + +const route = useRoute(); + +// Recarrega quando qualquer filtro muda +watch( + () => [route.query.campo1, route.query.campo2], + () => { + store.$reset(); + store.buscarTudo(); + }, +); +``` + +Se tiver **paginação via `MenuPaginacao`**, usar `watchEffect` em vez de `watch` — ele rastreia automaticamente todos os `route.query` acessados dentro de `buscarTudo`: + +```javascript +import { watchEffect } from "vue"; + +// Rebusca ao mudar qualquer query param (filtros + pagina + token_paginacao) +watchEffect(() => { + store.buscarTudo(route.query); +}); +``` + +> **Regra:** filtros sem paginação → `watch` com array de campos explícitos. Com `MenuPaginacao` → `watchEffect` (rastreia tudo automaticamente). Nunca misturar os dois para o mesmo `buscarTudo`. + +--- + +## 5. SmaeTable + +**Caminho:** `@/components/SmaeTable/SmaeTable.vue` + +Ver documentação completa em [src/components/SmaeTable/README.md](../../src/components/SmaeTable/README.md). + +--- + +## 6. CabecalhoDePagina + +**Caminho:** `@/components/CabecalhoDePagina.vue` + +Substitui o `
` manual. Lê o título de `route.meta.título` automaticamente. + +```vue + + + +``` + +Slots disponíveis: `#titulo`, `#subtitulo`, `#acoes`. + +--- + +## 7. MenuPaginacao + +**Caminho:** `@/components/MenuPaginacao.vue` + +Ver documentação completa em [src/components/MenuPaginacao.md](../../src/components/MenuPaginacao.md). + +--- + +## 8. FiltroParaPagina + +**Caminho:** `@/components/FiltroParaPagina.vue` + +Ver documentação completa em [src/components/FiltroParaPagina.md](../../src/components/FiltroParaPagina.md). + +--- + +## 9. Pinia Store + +Ver documentação completa em [store-creation.md](store-creation.md). + +--- + +## 10. Configuração de Rotas + +```javascript +import tiparPropsDeRota from "@/router/helpers/tiparPropsDeRota"; +import MinhaEntidadeRaiz from "@/views/minhaEntidade/MinhaEntidadeRaiz.vue"; +import MinhaEntidadeLista from "@/views/minhaEntidade/MinhaEntidadeLista.vue"; +import MinhaEntidadeCriarEditar from "@/views/minhaEntidade/MinhaEntidadeCriarEditar.vue"; + +export default { + path: "minha-entidade", + component: MinhaEntidadeRaiz, + meta: { + título: "Minha Entidade", + limitarÀsPermissões: ["MinhaEntidade.listar"], + }, + children: [ + { + name: "minhaEntidade.listar", + path: "", + component: MinhaEntidadeLista, + meta: { título: "Minha Entidade" }, + }, + { + name: "minhaEntidade.criar", + path: "novo", + component: MinhaEntidadeCriarEditar, + meta: { + título: "Nova Entidade", + rotasParaMigalhasDePão: ["minhaEntidade.listar"], + rotaDeEscape: "minhaEntidade.listar", + }, + }, + { + name: "minhaEntidade.editar", + path: ":entidadeId", + component: MinhaEntidadeCriarEditar, + props: tiparPropsDeRota, + meta: { + título: "Editar Entidade", + rotasParaMigalhasDePão: ["minhaEntidade.listar"], + rotaDeEscape: "minhaEntidade.listar", + }, + }, + ], +}; +``` + +--- + +## 11. Ordenação Client-Side + +```javascript +import { computed } from "vue"; +import { useRoute, useRouter } from "vue-router"; + +const route = useRoute(); +const router = useRouter(); + +const parâmetroDeOrdenação = computed(() => + route.query.ordenar_por?.toLowerCase().trim(), +); +const ordemDeOrdenação = computed(() => + route.query.ordem?.toLowerCase().trim(), +); + +const listaOrdenada = computed(() => { + switch (parâmetroDeOrdenação.value) { + case "atualizado_em": + case "criado_em": + return ordemDeOrdenação.value === "decrescente" + ? lista.value.toSorted((a, b) => + a[parâmetroDeOrdenação.value] > b[parâmetroDeOrdenação.value] + ? -1 + : 1, + ) + : lista.value.toSorted((a, b) => + a[parâmetroDeOrdenação.value] > b[parâmetroDeOrdenação.value] + ? 1 + : -1, + ); + case "nome": + return ordemDeOrdenação.value === "decrescente" + ? lista.value.toSorted((a, b) => b.nome.localeCompare(a.nome)) + : lista.value.toSorted((a, b) => a.nome.localeCompare(b.nome)); + default: + return lista.value; + } +}); + +function aplicarOrdenação(nome, valor) { + router.replace({ query: { ...route.query, [nome]: valor || undefined } }); +} +``` + +--- + +## 12. Helpers Disponíveis + +| Helper | Caminho | Uso | +| ---------------- | -------------------------- | ------------------------------------------------------ | +| `filtrarObjetos` | `@/helpers/filtrarObjetos` | Filtra array por texto (recursivo, accent-insensitive) | +| `dateToField` | `@/helpers/dateToField` | Formata data para exibição | +| `dinheiro` | `@/helpers/dinheiro` | Formata valor monetário | +| `truncate` | `@/helpers/truncate` | Trunca texto longo | +| `removerHtml` | `@/helpers/removerHtml` | Remove tags HTML | + +--- + +## 13. Exemplos Reais no Projeto + +| Arquivo | Características | +| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| [EtiquetasLista.vue](src/views/projetos.etiquetas/EtiquetasLista.vue) | SmaeTable mínimo, rotaEditar condicional por `pode_editar` | +| [AreasTematicasLista.vue](src/views/areasTematicas/AreasTematicasLista.vue) | SmaeTable com slots de célula customizados, LoadingComponent separado | +| [AditivosLista.vue](src/views/tipoDeAditivo/AditivosLista.vue) | SmaeTable + FiltroParaPagina com busca client-side via `filtrarObjetos` | +| [TransferenciasVoluntariasLista.vue](src/views/transferenciasVoluntarias/TransferenciasVoluntariasLista.vue) | Filtros na URL, paginação "carregar mais" (ainda usa tabela manual) | +| [AcompanhamentosLista.vue](src/views/mdo.acompanhamentos/AcompanhamentosLista.vue) | Ordenação com URL query params (ainda usa tabela manual) | diff --git a/frontend/.claude/project-knowledge/route-creation.md b/frontend/.claude/project-knowledge/route-creation.md new file mode 100644 index 0000000000..ac64578fd6 --- /dev/null +++ b/frontend/.claude/project-knowledge/route-creation.md @@ -0,0 +1,223 @@ +# Criação de Rotas + +## Estrutura de Arquivos + +Cada módulo tem seu próprio arquivo de rotas em `src/router/`. O arquivo principal `src/router/index.js` importa e registra todos os módulos. + +``` +src/router/ +├── index.js # router principal +├── helpers/ +│ └── tiparPropsDeRota.ts +├── administracao.js +├── configuracoes.js +├── metas.js +└── [outros módulos].js +``` + +Para criar rotas de um novo módulo, crie `src/router/meu-modulo.js` e registre no `index.js`. + +--- + +## Estrutura Básica de uma Rota + +```js +import tiparPropsDeRota from '@/router/helpers/tiparPropsDeRota'; + +{ + path: '/meu-modulo', + component: MeuModuloRaiz, + name: 'meuModuloRaiz', + meta: { + título: 'Meu Módulo', + entidadeMãe: 'pdm', // contexto do módulo + limitarÀsPermissões: ['Modulo.permissao'], + íconeParaMenu: `...`, + rotasParaMenuPrincipal: ['entidadeMae.meuModulo.listar'], + }, + children: [ + { + name: 'entidadeMae.meuModulo.listar', + path: '', + component: MeuModuloLista, + meta: { título: 'Meu Módulo' }, + }, + { + name: 'entidadeMae.meuModulo.criar', + path: 'novo', + component: MeuModuloCriarEditar, + meta: { + título: 'Novo item', + rotaDeEscape: 'entidadeMae.meuModulo.listar', + rotasParaMigalhasDePão: ['entidadeMae.meuModulo.listar'], + }, + }, + { + path: ':itemId', + name: 'entidadeMae.meuModulo.editar', + component: MeuModuloCriarEditar, + props: tiparPropsDeRota, + meta: { + título: 'Editar item', + rotaDeEscape: 'entidadeMae.meuModulo.listar', + rotasParaMigalhasDePão: ['entidadeMae.meuModulo.listar'], + }, + }, + ], +} +``` + +--- + +## Props de Rota + +### `tiparPropsDeRota` — uso padrão + +Sempre que os parâmetros da rota precisam ser passados como props para o componente, use `tiparPropsDeRota`: + +```js +import tiparPropsDeRota from '@/router/helpers/tiparPropsDeRota'; + +{ + path: 'demandas/:id', + name: 'demandaDetalhe', + component: DemandaDetalhe, + props: tiparPropsDeRota, +} +``` + +A função converte automaticamente os parâmetros para os tipos corretos (string, boolean, number) e decodifica primitivas. + +### Props estáticas + +```js +props: { type: 'novo', parentPage: 'metas' }, +``` + +### Props de query e params juntos + +```js +props: tiparPropsDeRota, +``` + +--- + +## Campos `meta` Comuns + +| Campo | Tipo | Descrição | +|---|---|---| +| `título` | `string \| Function` | Título da página. Pode ser uma função que lê de uma store Pinia | +| `limitarÀsPermissões` | `string[]` | Permissões necessárias para acessar a rota | +| `entidadeMãe` | `string` | Contexto do módulo: `'pdm'`, `'projeto'`, `'mdo'`, `'planoSetorial'` | +| `rotaDeEscape` | `string` | Nome da rota para onde ir ao cancelar/voltar | +| `rotasParaMigalhasDePão` | `string[]` | Nomes de rotas que formam o breadcrumb | +| `rotasParaMenuPrincipal` | `string[]` | Nomes de rotas exibidas no menu lateral principal | +| `rotasParaMenuSecundário` | `string[] \| Function` | Nomes de rotas exibidas no menu secundário | +| `íconeParaMenu` | `string` | SVG inline para exibir no menu | +| `tituloParaMigalhaDePao` | `string \| Function` | Texto customizado para o breadcrumb (se diferente de `título`) | +| `rotaPrescindeDeChave` | `boolean` | A rota não exige uma entidade selecionada (ex: lista raiz) | +| `publico` | `boolean` | Acessível sem autenticação | + +### Título dinâmico com Pinia + +```js +meta: { + título: () => { + const meta = useCiclosStore()?.SingleMeta?.meta; + return meta?.código && meta?.titulo + ? `Meta ${meta.código} ${meta.titulo}` + : 'Meta'; + }, +} +``` + +--- + +## Importação de Componentes + +### Lazy loading (padrão para views) + +```js +component: () => import('@/views/meu-modulo/MeuModuloLista.vue'), +``` + +### Eager loading (views pequenas ou muito usadas) + +```js +import MeuModuloRaiz from '@/views/meu-modulo/MeuModuloRaiz.vue'; +// ... +component: MeuModuloRaiz, +``` + +### Com loading component + +```js +import { defineAsyncComponent } from 'vue'; +import LoadingComponent from '@/components/LoadingComponent.vue'; + +const MeuModuloLista = defineAsyncComponent({ + loader: () => import('@/views/meu-modulo/MeuModuloLista.vue'), + loadingComponent: LoadingComponent, +}); +``` + +--- + +## Registrando o Módulo no Router Principal + +Em `src/router/index.js`, importe e adicione à lista de rotas: + +```js +// import direto (objeto único) +import meuModulo from './meu-modulo.js'; + +// ou spread (array de rotas) +import * as meuModulo from './meu-modulo.js'; + +const routes = [ + // ...outras rotas + meuModulo, // import direto + ...meuModulo, // spread +]; +``` + +--- + +## Rota Pública (sem autenticação) + +```js +{ + path: '/publico', + component: PublicLayout, + meta: { publico: true }, + children: [ + { + path: 'item/:id', + name: 'itemPublico', + component: ItemPublicoDetalhe, + props: tiparPropsDeRota, + meta: { título: 'Detalhe do Item' }, + }, + ], +} +``` + +--- + +## Redirect + +```js +{ + path: '/modulo', + redirect: { name: 'moduloListar' }, + children: [...] +} +``` + +--- + +## Convenções de Nomenclatura + +- Nomes de rotas: dot notation seguindo o padrão `módulo.entidade.ação` → `portfolio.listar`, `portfolio.criar`, `portfolio.editar` (código legado usa flat camelCase como `portfolioListar` — não replicar em código novo) +- Paths: kebab-case → `/meu-modulo/novo` +- Params de ID: sempre `nomeEntidadeId` → `:portfolioId`, `:metaId` \ No newline at end of file diff --git a/frontend/.claude/project-knowledge/store-creation.md b/frontend/.claude/project-knowledge/store-creation.md new file mode 100644 index 0000000000..65afcde3e2 --- /dev/null +++ b/frontend/.claude/project-knowledge/store-creation.md @@ -0,0 +1,257 @@ +# Criação de Pinia Store + +## Convenção de Arquivos + +Stores ficam em `src/stores/.store.ts` e **devem ser escritas em TypeScript**. + +--- + +## Store Padrão (sem paginação) + +```typescript +import { defineStore } from 'pinia'; + +const baseUrl = `${import.meta.env.VITE_API_URL}`; + +interface Item { + id: number; + descricao: string; + // adicionar campos conforme o DTO do backend +} + +export const useMinhaEntidadeStore = defineStore('minhaEntidade', { + state: (): Estado => ({ + lista: [], + emFoco: null, + chamadasPendentes: { lista: false, emFoco: false }, + erros: { lista: null, emFoco: null }, + }), + + actions: { + async buscarTudo(params = {}): Promise { + this.chamadasPendentes.lista = true; + this.erros.lista = null; + try { + const { linhas } = await this.requestS.get(`${baseUrl}/minha-entidade`, params); + this.lista = linhas; + } catch (erro: unknown) { + this.erros.lista = erro; + } + this.chamadasPendentes.lista = false; + }, + + async buscarItem(id: number): Promise { + this.chamadasPendentes.emFoco = true; + this.erros.emFoco = null; + try { + this.emFoco = await this.requestS.get(`${baseUrl}/minha-entidade/${id}`); + } catch (erro: unknown) { + this.erros.emFoco = erro; + } + this.chamadasPendentes.emFoco = false; + }, + + async salvarItem(params = {}, id = 0): Promise { + this.chamadasPendentes.emFoco = true; + this.erros.emFoco = null; + try { + if (id) { + await this.requestS.patch(`${baseUrl}/minha-entidade/${id}`, params); + } else { + await this.requestS.post(`${baseUrl}/minha-entidade`, params); + } + this.chamadasPendentes.emFoco = false; + return true; + } catch (erro: unknown) { + this.erros.emFoco = erro; + this.chamadasPendentes.emFoco = false; + return false; + } + }, + + async excluirItem(id: number): Promise { + this.chamadasPendentes.lista = true; + this.erros.lista = null; + try { + await this.requestS.delete(`${baseUrl}/minha-entidade/${id}`); + this.chamadasPendentes.lista = false; + return true; + } catch (erro: unknown) { + this.erros.lista = erro; + this.chamadasPendentes.lista = false; + return false; + } + }, + }, + + getters: { + itensPorId: ({ lista }): Record => + lista.reduce((acc, cur) => ({ ...acc, [cur.id]: cur }), {}), + }, +}); +``` + +--- + +## Store com Paginação (MenuPaginacao) + +Adicionar `paginacao` ao estado e mapear a resposta da API para as props do `MenuPaginacao` (camelCase): + +```typescript +// estado inicial +state: (): EstadoPaginacao => ({ + lista: [], + emFoco: null, + chamadasPendentes: { lista: false, emFoco: false }, + erros: { lista: null, emFoco: null }, + paginacao: { + paginas: 0, + paginaCorrente: 0, + temMais: false, + tokenPaginacao: null, + totalRegistros: 0, + }, +}), +``` + +`buscarTudo` com paginação — mapear snake_case da API para camelCase: + +```typescript +async buscarTudo(params = {}): Promise { + this.chamadasPendentes.lista = true; + this.erros.lista = null; + try { + const { + linhas, + paginas, + pagina_corrente: paginaCorrente, + tem_mais: temMais, + token_proxima_pagina: tokenPaginacao, + total: totalRegistros, + } = await this.requestS.get(`${baseUrl}/minha-entidade`, params); + + this.lista = linhas; + this.paginacao = { + paginas: paginas ?? 0, + paginaCorrente: paginaCorrente ?? 0, + temMais: temMais ?? false, + tokenPaginacao: tokenPaginacao ?? null, + totalRegistros: totalRegistros ?? 0, + }; + } catch (erro: unknown) { + this.erros.lista = erro; + } + this.chamadasPendentes.lista = false; +}, +``` + +No componente de lista, usar `watchEffect` (rastreia automaticamente todos os `route.query` acessados dentro de `buscarTudo`): + +```typescript +import { watchEffect } from 'vue'; +import { useRoute } from 'vue-router'; + +const route = useRoute(); + +watchEffect(() => { + store.buscarTudo(route.query); +}); +``` + +No template, usar `v-bind="paginacao"` — colocar acima e abaixo da tabela: + +```vue + + + +``` + +--- + +## Stores com Entidade Mãe (multi-módulo) + +Algumas entidades existem em mais de um módulo do sistema (ex: temas no PDM e no Plano Setorial). Há dois padrões para lidar com isso. + +### Convenção de nomes de arquivo + +| `entidadeMãe` | Módulo | Convenção | +|---|---|---| +| `pdm` | PDM (legado) | sufixo `Ps` | +| `programaDeMetas` | Programa de Metas | sufixo `Ps` | +| `planoSetorial` | Planos Setoriais | sufixo `Ps` | +| `projeto` / `portfolio` | Gestão de Projetos | prefixo `projeto` | +| `mdo` / `obras` | Monitoramento de Obras | sufixo `Mdo` | +| `TransferenciasVoluntarias` | Transferências Voluntárias | — | + +--- + +### Padrão 1 — Implícito via `route.meta` (store estática) + +Usado quando a store tem **uma única instância** e resolve o módulo dinamicamente pela rota atual. Exemplos: `temasPs`, `macrotemasPs`, `tagsPs`. + +```typescript +function caminhoParaApi(rotaMeta: RouteMeta): string { + switch (rotaMeta.entidadeMãe) { + case 'pdm': + return 'minha-entidade'; + case 'planoSetorial': + case 'programaDeMetas': + return 'plano-setorial-minha-entidade'; + default: + throw new Error('Você precisa estar em algum módulo para executar essa ação.'); + } +} + +export const useMinhaEntidadePsStore = defineStore('minhaEntidadePsStore', { + // ... + actions: { + async buscarTudo(params = {}): Promise { + // ... + const { linhas } = await this.requestS.get( + `${baseUrl}/${caminhoParaApi(this.route.meta)}`, + params, + ); + }, + }, +}); +``` + +--- + +### Padrão 2 — Explícito via factory function (store por módulo) + +Usado quando cada módulo precisa de **instâncias independentes de estado** no Pinia. O ID da store recebe o prefixo da entidade mãe (`${entidadeMae}.minhaEntidade`), garantindo isolamento. Exemplos: `termoEncerramento`, `tipoEncerramento`, `observadores`. + +```typescript +export const useMinhaEntidadeStore = ( + entidadeMae: ModuloSistema.MDO | ModuloSistema.Projetos, +) => defineStore(`${entidadeMae}.minhaEntidade`, { + // ... + actions: { + async buscarTudo(params = {}): Promise { + const caminho = entidadeMae === ModuloSistema.Projetos + ? 'minha-entidade' + : 'minha-entidade-mdo'; + const { linhas } = await this.requestS.get(`${baseUrl}/${caminho}`, params); + }, + }, +})(); // ← invocar imediatamente +``` + +Para usar no componente: + +```typescript +// instância específica do módulo +const store = useMinhaEntidadeStore(ModuloSistema.Projetos); +``` + +--- + +## Regra watch vs watchEffect + +| Situação | Hook | +|---|---| +| Filtros sem paginação | `watch` com array explícito de `route.query` campos | +| Com `MenuPaginacao` | `watchEffect` (rastreia tudo automaticamente) | + +Nunca misturar os dois para o mesmo `buscarTudo`. diff --git a/frontend/.claude/skills/nova-lista.md b/frontend/.claude/skills/nova-lista.md new file mode 100644 index 0000000000..2c3f3aefa9 --- /dev/null +++ b/frontend/.claude/skills/nova-lista.md @@ -0,0 +1,32 @@ +--- +name: nova-lista +description: Use esta skill quando o usuário pedir para criar uma nova página de listagem, criar uma lista Vue com SmaeTable, ou criar a parte de lista de um CRUD para uma entidade. +version: 1.0.0 +allowed-tools: Read, Write, AskUserQuestion +--- + +## Sua tarefa + +$ARGUMENTS + +Antes de começar, leia os seguintes arquivos de conhecimento do projeto: + +- `.claude/project-knowledge/list-creation.md` — padrão de lista (SmaeTable, paginação, filtros, ordenação) +- `.claude/project-knowledge/store-creation.md` — padrão de store Pinia (estado, actions, paginação) +- `.claude/project-knowledge/route-creation.md` — padrão de rotas (meta campos, props, lazy loading, permissões) + +Se os argumentos não especificarem detalhes suficientes, pergunte: + +1. **Nome da entidade** (ex: "Parceiro", "TipoDeAcao") +2. **Módulo/pasta** onde ficará em `src/views/` (ex: `parceiros`, `tiposDeAcao`) +3. **Colunas da tabela**: quais campos exibir, com seus labels em português +4. **A listagem tem filtros?** Se sim: quais campos? Os filtros devem persistir na URL? +5. **A listagem tem paginação?** (MenuPaginacao) +6. **Nome da store** que fornece os dados (ex: `useParceirosStore`) +7. **Nome da rota de criação** para o botão "Novo item" (ex: `parceiros.criar`) + +Depois de coletar as informações, crie os seguintes arquivos seguindo os padrões dos documentos lidos: + +1. `src/views//Lista.vue` +2. `src/views//Raiz.vue` (se ainda não existir) +3. Bloco de configuração de rota (para adicionar no arquivo de rotas do módulo) diff --git a/frontend/.claude/skills/nova-rota.md b/frontend/.claude/skills/nova-rota.md new file mode 100644 index 0000000000..2f8b0f6e13 --- /dev/null +++ b/frontend/.claude/skills/nova-rota.md @@ -0,0 +1,30 @@ +--- +name: nova-rota +description: Use esta skill quando o usuário pedir para criar rotas Vue Router, adicionar um módulo ao router, criar arquivo de rotas, configurar breadcrumbs, permissões ou menu lateral para um módulo. +version: 1.0.0 +allowed-tools: Read, Write, Edit, AskUserQuestion +--- + +## Sua tarefa + +$ARGUMENTS + +Antes de começar, leia o seguinte arquivo de conhecimento do projeto: + +- `.claude/project-knowledge/route-creation.md` — estrutura de rotas, meta campos, props, lazy loading, permissões, breadcrumbs + +Se os argumentos não especificarem detalhes suficientes, pergunte: + +1. **Nome do módulo** (ex: `parceiros`, `tiposDeAcao`) +2. **Entidade principal** (ex: `Parceiro`, `TipoDeAcao`) +3. **Path base** da rota (ex: `/parceiros`) +4. **Permissões necessárias** (`limitarÀsPermissões`) — quais strings de permissão? +5. **Operações disponíveis**: listar, criar, editar, detalhar? +6. **Precisa de menu lateral?** Se sim, o módulo tem ícone SVG? +7. **entidadeMãe** (contexto do módulo: `pdm`, `projeto`, `mdo`, `planoSetorial`) +8. **O módulo já existe em `src/router/index.js`?** Se não, precisamos registrá-lo lá também. + +Depois de coletar as informações, crie/edite os seguintes arquivos seguindo os padrões do documento lido: + +1. `src/router/.js` — arquivo de rotas do módulo +2. `src/router/index.js` — registrar o novo módulo (se ainda não registrado) diff --git a/frontend/.claude/skills/nova-store.md b/frontend/.claude/skills/nova-store.md new file mode 100644 index 0000000000..2e0a3e44a7 --- /dev/null +++ b/frontend/.claude/skills/nova-store.md @@ -0,0 +1,38 @@ +--- +name: nova-store +description: Use esta skill quando o usuário pedir para criar uma nova store Pinia, criar store para uma entidade, ou mencionar criação de store com actions CRUD e paginação opcional. +version: 1.0.0 +allowed-tools: Read, Write, AskUserQuestion +--- + +## Sua tarefa + +$ARGUMENTS + +Antes de começar, leia o seguinte arquivo de conhecimento do projeto: + +- `.claude/project-knowledge/store-creation.md` — padrão de store Pinia (estado, actions, paginação, watch vs watchEffect) + +Se os argumentos não especificarem detalhes suficientes, pergunte: + +1. **Nome da entidade** (ex: "Parceiro", "TipoDeAcao") +2. **Endpoint base da API** (ex: `/parceiros`, `/tipos-de-acao`) +3. **A store precisa de paginação?** (MenuPaginacao) +4. **Quais actions são necessárias?** (buscarTudo, buscarItem, salvarItem, excluirItem — ou subconjunto) +5. **Algum getter específico além de `itensPorId`?** +6. **A store pertence a uma entidade mãe específica?** Algumas stores usam `route.meta.entidadeMãe` para selecionar o endpoint correto e recebem um sufixo no nome do arquivo. Os valores aceitos são: + + | `entidadeMãe` | Módulo | Sufixo no arquivo | + |---|---|---| + | `pdm` | PDM (Programa de Metas legado) | `Ps` | + | `programaDeMetas` | Programa de Metas | `Ps` | + | `planoSetorial` | Planos Setoriais | `Ps` | + | `projeto` / `portfolio` | Gestão de Projetos | `Projeto` (prefixo) | + | `mdo` / `obras` | Monitoramento de Obras | `Mdo` | + | `TransferenciasVoluntarias` | Transferências Voluntárias | — | + + Stores com sufixo `Ps` normalmente suportam os três valores (`pdm`, `planoSetorial`, `programaDeMetas`) e usam uma função `caminhoParaApi(route.meta)` para selecionar o endpoint. Consulte o project-knowledge para o padrão completo. + +Depois de coletar as informações, crie o arquivo seguindo os padrões do documento lido: + +1. `src/stores/.store.ts` diff --git a/frontend/.claude/skills/novo-formulario.md b/frontend/.claude/skills/novo-formulario.md new file mode 100644 index 0000000000..444503c0c4 --- /dev/null +++ b/frontend/.claude/skills/novo-formulario.md @@ -0,0 +1,139 @@ +--- +name: novo-formulario +description: Use esta skill quando o usuário pedir para criar um novo formulário, criar páginas de formulário Vue, criar schema Yup, criar componente CriarEditar, ou criar um CRUD completo para uma entidade. +version: 2.0.0 +allowed-tools: Read, Write, AskUserQuestion +--- + +## Sua tarefa + +$ARGUMENTS + +Antes de começar, leia os seguintes arquivos de conhecimento do projeto: + +- `.claude/project-knowledge/list-creation.md` — padrão de lista (SmaeTable, paginação, filtros) +- `.claude/project-knowledge/store-creation.md` — padrão de store Pinia (estado, actions, paginação) +- `.claude/project-knowledge/route-creation.md` — padrão de rotas (meta campos, props, lazy loading, permissões) + +Se os argumentos não especificarem detalhes suficientes, pergunte: + +1. **Nome da entidade** (ex: "Parceiro", "TipoDeAcao") +2. **Módulo/pasta** onde ficará em `src/views/` (ex: `parceiros`, `tiposDeAcao`) +3. **Campos do formulário**: nome, tipo (texto, número, data, select, autocomplete, boolean), obrigatório, label em português, tamanho máximo se aplicável +4. **Tem lista de itens dinâmica (FieldArray)?** Se sim, quais campos +5. **Nome da store** (ex: `useParceirosStore`) +6. **Nome da rota de listagem** para redirecionar após salvar (ex: `parceiros.listar`) +7. **A lista tem filtros?** Se sim: quais campos? Os filtros devem persistir na URL? +8. **A lista tem paginação?** (MenuPaginacao) + +--- + +## Padrões de Formulário + +### Schema Yup + +- Schemas ficam em `src/consts/formSchemas/.ts` +- Importar primitivos de `./initSchema` (não direto do `yup`): + ```ts + import { object, string, number, array, boolean, date, mixed } from "./initSchema"; + ``` +- Sempre usar `.label('Nome do Campo')` em cada campo — o `SmaeLabel` extrai isso automaticamente +- Campos opcionais: `.nullable()` ou `.nullableOuVazio()` +- Campos condicionais: `.when('outro_campo', { is: ..., then: ..., otherwise: ... })` +- Exportar como named export: `export const minhaEntidadeSchema = object().shape({ ... })` +- Schemas em `formSchemas/` separados **não** precisam ser registrados em `formSchemas.js` — são importados diretamente na view + +### Componente CriarEditar.vue + +Local: `src/views//CriarEditar.vue` + +**Padrão com `useForm`** — sempre usar este padrão, independentemente da complexidade do formulário: + +```vue + + + +``` + +> **Atenção:** O padrão com `` wrapper do vee-validate **não deve ser usado** neste projeto. + +### Componentes de campo (globais) + +| Componente | Uso | +|---|---| +| `` | Label com asterisco automático se required | +| `` | Input de texto padrão | +| `` | Select | +| `` | Mensagem de erro inline | +| `` | Textarea/input com contador de caracteres | +| `` | Datepicker | +| `` | Input numérico | +| `` | Autocomplete multi-select | +| `` | Array dinâmico de campos | +| `` | Lista todos os erros do formulário | +| `` | Botão salvar padronizado | +| `` | Cabeçalho com botão de fechar | + +### Classes CSS utilitárias + +- `flex g2` — flex com gap +- `f1` — flex: 1 (ocupa espaço disponível) +- `inputtext light` — estilo padrão de input +- `error-msg` — estilo de mensagem de erro + +--- + +Depois de coletar as informações, crie os seguintes arquivos seguindo os padrões acima e dos documentos lidos: + +1. `src/consts/formSchemas/.ts` +2. `src/stores/.store.ts` +3. `src/views//Raiz.vue` +4. `src/views//Lista.vue` +5. `src/views//CriarEditar.vue` +6. Bloco de configuração de rota (para adicionar no arquivo de rotas do módulo) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000000..2c16c5162a --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,22 @@ +# Frontend SMAE — Padrões de Desenvolvimento + +## Conhecimentos do Projeto + +- **Criação de formulários** (schemas Yup, componente CriarEditar, campos, padrões vee-validate): + [.claude/project-knowledge/form-creation.md](.claude/project-knowledge/form-creation.md) + +- **Criação de listas** (stores Pinia, tabelas, LocalFilter, filtros, ordenação, paginação, ações): + [.claude/project-knowledge/list-creation.md](.claude/project-knowledge/list-creation.md) + +- **Criação de rotas** (estrutura de arquivo, meta campos, props, lazy loading, permissões, breadcrumbs): + [.claude/project-knowledge/route-creation.md](.claude/project-knowledge/route-creation.md) + +- **Criação de stores Pinia** (estado, actions, paginação, watch vs watchEffect): + [.claude/project-knowledge/store-creation.md](.claude/project-knowledge/store-creation.md) + +## Skills Disponíveis + +- `/novo-formulario` — cria CRUD completo (schema Yup, store, lista, formulário, rota) +- `/nova-lista` — cria página de listagem (SmaeTable, filtros, paginação, rota) +- `/nova-store` — cria store Pinia para uma entidade +- `/nova-rota` — cria configuração de rota para um módulo diff --git a/frontend/src/components/MenuPaginacao.md b/frontend/src/components/MenuPaginacao.md new file mode 100644 index 0000000000..91080f9965 --- /dev/null +++ b/frontend/src/components/MenuPaginacao.md @@ -0,0 +1,152 @@ +# MenuPaginacao + +Componente de paginação que sincroniza com a URL via query params. Só renderiza quando `paginas > 1`. + +**Caminho:** `@/components/MenuPaginacao.vue` + +--- + +## Props + +| Prop | Tipo | Padrão | Descrição | +|---|---|---|---| +| `paginas` | `number` | `0` | Total de páginas | +| `temMais` | `boolean` | `false` | Se há mais páginas a carregar | +| `tokenPaginacao` | `string` | `''` | Token da próxima página (repassado na query) | +| `totalRegistros` | `number` | `0` | Total de registros — exibido como texto ao lado da navegação | +| `prefixo` | `string` | `''` | Prefixo para as chaves na URL (útil quando há múltiplos paginadores na mesma página) | + +### v-model + +Aceita `v-model` para controle de página sem alterar a URL (uso em modais ou listas embutidas). + +--- + +## Eventos + +| Evento | Payload | Descrição | +|---|---|---| +| `trocaDePaginaSolicitada` | `{ pagina: number }` | Emitido ao navegar para qualquer página | + +--- + +## Comportamento + +- Sem `v-model`: navega via `router.push` atualizando `route.query.pagina` e `route.query.token_paginacao` +- Com `v-model`: atualiza o model diretamente, sem alterar a URL +- O componente **não é renderizado** quando `paginas <= 1` +- O token da próxima página é preservado na query para permitir navegação para frente + +--- + +## Uso com a URL (padrão) + +### 1. No template + +O padrão mais simples é passar o objeto `paginacao` da store com `v-bind`. Colocar **acima e abaixo** da tabela em listas longas: + +```vue + + + + + +``` + +### 2. Na store (TypeScript) + +O objeto `paginacao` deve ter as mesmas chaves das props (camelCase): + +```typescript +interface Paginacao { + paginas: number; + temMais: boolean; + tokenPaginacao: string; + totalRegistros: number; +} + +interface Estado { + lista: Item[]; + paginacao: Paginacao; + chamadasPendentes: { lista: boolean }; + erro: null | unknown; +} + +// estado inicial +paginacao: { + paginas: 0, + temMais: false, + tokenPaginacao: '', + totalRegistros: 0, +}, + +// em buscarTudo, mapear a resposta da API +async buscarTudo(params = {}): Promise { + this.chamadasPendentes.lista = true; + this.erro = null; + try { + const { + linhas, + paginas, + tem_mais: temMais, + token_proxima_pagina: tokenPaginacao, + total: totalRegistros, + } = await this.requestS.get(`${baseUrl}/minha-entidade`, params); + + this.lista = linhas; + this.paginacao = { + paginas: paginas ?? 0, + temMais: temMais ?? false, + tokenPaginacao: tokenPaginacao ?? '', + totalRegistros: totalRegistros ?? 0, + }; + } catch (erro: unknown) { + this.erro = erro; + } + this.chamadasPendentes.lista = false; +}, +``` + +### 3. No componente: reagir à mudança de página + +```typescript +import { watchEffect } from 'vue'; +import { useRoute } from 'vue-router'; + +const route = useRoute(); + +// Rebusca automaticamente ao mudar pagina, token ou outros filtros na URL +watchEffect(() => { + store.buscarTudo(route.query); +}); +``` + +--- + +## Com prefixo (múltiplos paginadores na mesma página) + +```vue + + +``` + +Com `prefixo="obras_"`, usa `route.query.obras_pagina` e `route.query.obras_token_paginacao`. + +Ao usar junto com `FiltroParaPagina`, passar o mesmo prefixo para que o filtro resets a paginação correta: + +```vue + +``` + +--- + +## Exemplos Reais no Projeto + +| Arquivo | Características | +|---|---| +| [ObrasListar.vue](../views/mdo.obras/ObrasListar.vue) | `v-bind="paginacao"`, paginador acima e abaixo da tabela | +| [ComunicadosGeraisLista.vue](../views/comunicadosGerais/ComunicadosGeraisLista.vue) | Com fallback "Buscar mais" quando `temMais` | diff --git a/frontend/src/components/SmaeTable/README.md b/frontend/src/components/SmaeTable/README.md new file mode 100644 index 0000000000..55f6d39c06 --- /dev/null +++ b/frontend/src/components/SmaeTable/README.md @@ -0,0 +1,191 @@ +# SmaeTable + +Componente de tabela padrão do SMAE. Substitui o uso de `
` manual nas páginas de lista. + +**Caminho:** `@/components/SmaeTable/SmaeTable.vue` + +--- + +## Props + +| Prop | Tipo | Padrão | Descrição | +|---|---|---|---| +| `dados` | `Linha[]` | — | Array de objetos a renderizar (obrigatório) | +| `colunas` | `Coluna[]` | — | Definição das colunas (obrigatório) | +| `rotaEditar` | `string \| RouteLocationRaw \| (linha) => RouteLocationRaw` | — | Rota do botão editar. Função recebe a linha inteira. Retornar `false`/`null`/`undefined` oculta o botão. | +| `parametroDaRotaEditar` | `string` | `'id'` | Nome do parâmetro de rota | +| `parametroNoObjetoParaEditar` | `string` | `'id'` | Campo do objeto usado como valor do parâmetro | +| `esconderDeletar` | `boolean` | — | Oculta botão de excluir | +| `parametroNoObjetoParaExcluir` | `string` | `'descricao'` | Campo usado na mensagem de confirmação de exclusão | +| `mensagemExclusao` | `(linha) => string` | — | Função para mensagem customizada de confirmação | +| `titulo` | `string` | — | Caption da tabela | +| `schema` | `AnyObjectSchema` | — | Schema Yup para labels automáticos nos cabeçalhos | +| `replicarCabecalho` | `boolean` | — | Repete cabeçalho no rodapé | +| `rolagemHorizontal` | `boolean` | `false` | Envolve tabela em scroll horizontal | +| `tituloParaRolagemHorizontal` | `string` | — | Título para acessibilidade na rolagem horizontal (obrigatório se `rolagemHorizontal` e não há `titulo`) | +| `personalizarLinhas` | `{ parametro, alvo, classe }` | — | Aplica classe CSS condicional nas linhas | +| `subLinhaAbertaPorPadrao` | `boolean` | `false` | Sub-linhas expandidas por padrão | +| `subLinhaSempreVisivel` | `boolean` | `false` | Sub-linhas sempre visíveis (sem toggle) | +| `campoId` | `string` | `'id'` | Campo identificador (necessário para sub-linhas) | + +--- + +## Tipo `Coluna` + +```typescript +type Coluna = { + chave: string; // Acessa dado como linha[chave] (suporta notação ponto: 'obj.campo') + label?: string; // Texto do cabeçalho + ehCabecalho?: boolean; // Renderiza célula como
+ formatador?: (valor: unknown) => string | number; // Transforma o valor exibido + atributosDaCelula?: Record; + atributosDaColuna?: Record; + atributosDoCabecalhoDeColuna?: Record; + atributosDoRodapeDeColuna?: Record; + classe?: string | string[] | Record; +}; +``` + +--- + +## Evento + +| Evento | Payload | Descrição | +|---|---|---| +| `@deletar` | `linha: Linha` | Emitido após confirmação de exclusão. Recebe o objeto completo da linha. | + +--- + +## Slots + +| Slot | Props | Descrição | +|---|---|---| +| `#celula:` | `{ linha, celula }` | Renderização customizada de célula. Ex: `#celula:ativo` | +| `#cabecalho:` | `coluna` | Cabeçalho customizado de coluna | +| `#acoes="{ linha }"` | `{ linha, linhaIndex }` | Substitui os botões padrão de editar/excluir | +| `#sub-linha="{ linha, linhaIndex }"` | `{ linha, linhaIndex }` | Linha expansível (accordion) | +| `#corpo="{ dados }"` | `{ dados }` | Corpo inteiro customizado | +| `#rodape="{ colunas }"` | `colunas` | Rodapé customizado | +| `#titulo` | — | Caption customizado | + +O estado vazio é tratado automaticamente com a mensagem "Sem dados para exibir". + +--- + +## Exemplos de Uso + +### Mínimo + +```vue + +``` + +### rotaEditar condicional (permissão por item) + +```vue + +``` + +### Células customizadas + +```vue + + + +``` + +### Ações customizadas + +```vue + + + +``` + +### Sub-linhas expansíveis (accordion) + +```vue + + + +``` + +### Com loading separado + +```vue + + +``` + +--- + +## Exemplos Reais no Projeto + +| Arquivo | Características | +|---|---| +| [EtiquetasLista.vue](../../views/projetos.etiquetas/EtiquetasLista.vue) | Mínimo, `rotaEditar` condicional por `pode_editar` | +| [AreasTematicasLista.vue](../../views/areasTematicas/AreasTematicasLista.vue) | Slots de célula customizados, `LoadingComponent` separado | +| [AditivosLista.vue](../../views/tipoDeAditivo/AditivosLista.vue) | Com `FiltroParaPagina` e filtro client-side | diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 8e135a7215..a3831e5a47 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -33,13 +33,18 @@ declare global { emFoco: null | unknown; }; - type Estado = { - lista: unknown[]; - emFoco: unknown | null; + type Estado = { + lista: ItemLista[]; + emFoco: ItemDetalhe | null; chamadasPendentes: ChamadasPendentes; erros: Erros; }; + type EstadoPaginacao = + Estado & { + paginacao: Paginacao; + }; + type Paginacao = { tokenPaginacao: string | null; paginas: number;