Padrão Evológica/Curio — Novo Projeto Web
Guia normativo para criar um novo projeto web no padrão Evológica/Curio. Base de referência para bootstrap de projetos React + @curio/client.
- Índice
- Fundação
- 01 — Stack e decisões
- 02 — Bootstrap
- 03 — Estrutura de pastas
- 04 — Ambientes e configuração
- 21 — Catálogo de hooks
- Qualidade e Processo
- 13 — Convenções
- 14 — Qualidade e pré-commit
- 15 — Build e deploy
- 16 — Template de `CLAUDE.md`
- 17 — Primeira tela
- 18 — Harness
- 19 — `init.sh` e Definition of Done
- 20 — Ciclo de desenvolvimento
- 22 — Testes E2E com Playwright
- UI
- 08 — Componentes
- 09 — Layout e menu lateral
- 10 — Rotas e proteção
- 11 — Formulários e validação
- 12 — Tema e estilo
- Backend (Curio)
Índice
00 — Índice
Guia normativo para criar um novo projeto web no padrão Evológica/Curio. Consolidado a partir de investigação de código real, não de teoria — cada decisão aqui foi verificada contra uma implementação de verdade antes de virar regra. Não é tutorial de React. É o manual de padrões do time.
Como ler
Leia na ordem. Cada documento assume os anteriores.
| # | Documento | Decide |
|---|---|---|
| 01 | Stack e decisões | O que é fixo, o que é negociável |
| 02 | Bootstrap | Do git init até a tela de login rodando |
| 03 | Estrutura de pastas | Onde cada arquivo mora |
| 04 | Ambientes e config |
config/*.json, public/config.json, .env
|
| 05 | Curio: conexão e sessão | Login, sessão, expiração, logout |
| 06 | Caso de uso | Como chamar o backend |
| 07 | React Query | Cache, erro global, query keys |
| 08 | Componentes | Regra dos 3 arquivos, catálogo common/
|
| 09 | Layout e menu lateral | Shell, menuTree, abas |
| 10 | Rotas e proteção |
paths / routes / guards |
| 11 | Formulários e validação | react-hook-form + zod |
| 12 | Tema e estilo | MUI, sx vs styled
|
| 13 | Convenções | Nomenclatura, _Prefixo, PT-BR vs inglês |
| 14 | Qualidade e pré-commit | ESLint, Prettier, husky, lint-staged |
| 15 | Build e deploy | Builds por ambiente, ISAPI |
| 16 | Template de CLAUDE.md | Arquivo pronto para colar |
| 17 | Primeira tela | Receita end-to-end verificável |
| 18 | Harness | Disciplina de sessão entre devs |
| 19 | init.sh e Definition of Done | Verificação de baseline |
| 20 | Ciclo de desenvolvimento | Issue → branch → commits → MR → merge |
| 21 | Catálogo de hooks | Os 12 hooks/módulos reutilizáveis de src/hooks/
|
| 22 | Testes E2E com Playwright | Setup do zero, login reutilizável, Page Object Model |
Os documentos 00–19 seguem a numeração planejada em PROMPT.md. O 20, 21 e 22 foram acrescentados depois — processo de trabalho, catálogo de hooks e testes E2E não estavam no plano original, e todos são pré-requisito para o time operar, não só para o código compilar.
Convenção destes documentos
- Todo snippet traz o caminho de destino no topo e é colável sem edição.
- Exemplos usam a entidade fictícia
Fornecedor, não o domínio de nenhum projeto real.
Checklist — projeto novo pronto
Marque quando cada item estiver verificado, não quando parecer feito.
Fundação
Configuração
Curio
Aplicação
Qualidade
Testes E2E
Harness
Aviso sobre usar código existente como referência
Nenhuma implementação real é um exemplar perfeito. Antes de copiar de um projeto existente, confira a lista de anti-padrões recorrentes em 13-CONVENCOES.md — são inconsistências reais que já apareceram mais de uma vez e não devem ser replicadas.
Fundação
Stack, bootstrap, estrutura de pastas e configuração de ambientes.
01 — Stack e decisões
01 — Stack e decisões
Define a stack fixa do projeto web e o que cada peça resolve. Itens marcados FIXO não são escolha do projeto novo — mudá-los quebra compatibilidade com o time. Itens NEGOCIÁVEL podem variar se houver motivo declarado. Versões verificadas em 2026-08-19 contra o npm registry e testadas de verdade (
tsc --noEmit,lint,build, tela no browser) — não é combinação só especulada.
Stack
| Peça | Versão | Status | Por quê |
|---|---|---|---|
| Node | >= 24 |
FIXO | Última LTS ativa (Node 24, "Krypton"). process.loadEnvFile exige ≥ 20.6 |
| TypeScript | ^6.0 |
FIXO | Bumpado de 5.9. Teto real: typescript-eslint (v8, latest) exige typescript >=4.8.4 <6.1.0 — 6.0.3 é a versão mais alta possível hoje. 7.x fica pra quando houver suporte |
| Vite | ^8.2 |
FIXO | Bumpado de 4. @vitejs/plugin-react v6 exige vite ^8 — os três (vite, @vitejs/plugin-react, vite-plugin-checker) sobem juntos |
| React | ^19.2 |
FIXO | Bumpado de 18. @curio/client só exige react >=16.8.0 — sem bloqueio |
@curio/client |
^1.4.2 |
FIXO | Transporte proprietário para o backend. Não avaliado nesta rodada (decisão do projeto) |
MUI (@mui/material) |
^9.3 |
FIXO | Bumpado de 5 direto para 9 — ver migração MUI 5→9 |
@mui/x-date-pickers |
^9.11 |
FIXO | Acoplado ao major do @mui/material. Peer cobre React 19 |
@tanstack/react-query |
^5.101 |
FIXO | Bumpado de 4.36. cacheTime→gcTime já ajustado nos hooks |
react-router-dom |
^7.18 |
FIXO | Bumpado de 6. BrowserRouter não aceita mais a prop future
|
react-hook-form |
^7.85 |
FIXO | Bumpado de 7.48 — exigido pelo @hookform/resolvers v5 (ver Zod abaixo) |
@hookform/resolvers |
^5.9 |
FIXO | Bumpado de 3 — obrigatório para usar Zod v4, não é opcional |
zod |
^4.4 |
FIXO | Bumpado de 3. Mudança de tipos exigiu ajuste em useValidatedForm — ver abaixo |
date-fns |
^4.4 |
FIXO | Bumpado de 2. Exige @mui/x-date-pickers/AdapterDateFns (v9 já é o adapter para v3/v4 — ver nota) |
| ESLint 10 + Prettier 3 | — | FIXO | Bumpado de 8. Flat config (eslint.config.js, não mais .eslintrc.json) — ver 14
|
typescript-eslint |
^8.67 |
FIXO | Bumpado de 7. Compatível com ESLint 8/9/10 e TS <6.1.0 — é o teto que bloqueia o TypeScript acima |
eslint-plugin-react-hooks |
^7.1 |
FIXO | Bumpado de 4. recommended ficou bem mais amplo (regras novas tipo set-state-in-effect) — ver 14
|
| husky + lint-staged | — | FIXO | Gate de pré-commit. lint-staged bumpado de 15 para 17 (dev tool, sem mudança de config) |
| Emotion | ^11.14 |
FIXO | Peer dependency do MUI |
Sem suíte de testes por padrão. Este guia não inventa uma nem assume que ela existe. O gate de qualidade é type-check + lint + build — ver 14 e 19. Se o projeto novo quiser testes, essa é uma decisão a tomar explicitamente no início, não depois.
Por que nem tudo foi para o latest
Em 2026-08-19, depois de uma rodada completa de atualização testada de verdade, só sobrou um pacote fora do latest:
| Pacote | Aqui | Latest hoje | Ficou de fora porque |
|---|---|---|---|
| TypeScript | 6.0.3 |
7.0.2 |
typescript-eslint@8.67.0 (latest estável) exige typescript >=4.8.4 <6.1.0 — não há versão estável do typescript-eslint que aceite TS 7 ainda (só canary). 6.0.3 é o teto real: existe uma linha 6.x entre o 5.9 antigo e o 7.0 novo, e ela cabe no range aceito — não pule direto de 5→7 sem checar se há uma minor intermediária. Bumpar além de 6.0.x quebraria o lint, não é escolha. |
Todo o resto (MUI, Vite, ESLint, date-fns, typescript-eslint, react-hooks) já está no latest — ver as notas de migração abaixo para quem for repetir isso num projeto mais antigo.
Migração MUI 5 → 9: o que mudou de verdade
O caminho não é incremental por major (5→6→7→8→9): @mui/x-date-pickers@9 exige
@mui/material: "^7.3.0 || ^9.0.0" — não aceita a v8 como peer. Suba os três pacotes
(@mui/material, @mui/icons-material, @mui/x-date-pickers) juntos, direto pra v9.
Superfície de quebra real, testada com tsc --noEmit:
-
Sem uso de
Gridno projeto — a maior fonte de quebra entre majors do MUI não se aplicou aqui. Se o projeto novo usaGrid, verifique a API v1→v2 separadamente. -
InputProps,inputProps,InputLabelPropsforam removidos deTextField/Checkbox(não são só deprecados — o tipo não aceita mais). ViramslotProps:InputProps→slotProps.input,inputProps→slotProps.htmlInput,InputLabelProps→slotProps.inputLabel. Isso afeta qualquer componente decommon/que componhaTextField(FormField,CurrencyInput,PhoneInput,CnpjInput,DatePickerInput,DataTable,TransferListCard). -
Props de atalho de estilo direto em
Box/Stack/Typography/DialogTitle(display,alignItems,justifyContent,fontWeight, etc., sem passar porsx) pararam de tipar — MUI removeu o suporte a "system props" desses componentes. Mova prasx={{ ... }}. -
disableEscapeKeyDownfoi removido deDialog/Modalsem substituto direto — se oDialognão temonClose, a prop já era redundante (nada fecha no Escape de qualquer forma); se tem, trate areasondentro do próprioonClose. -
@mui/x-date-pickersinverteu a convenção de adapter entre majors: na v6/v7,AdapterDateFnsera para date-fns v2 eAdapterDateFnsV3para v3/v4. Na v9, inverteu —AdapterDateFns(sem sufixo) já é o adapter para v3/v4, eAdapterDateFnsV2é o legado. Confira o import de novo a cada major, não assuma que o nome antigo continua correto.
date-fns e o adapter do x-date-pickers
Bumpar date-fns sem trocar o subpath do adapter (ver acima) quebra silenciosamente em runtime, não
em tipo — o import resolve, mas o parsing de data se comporta diferente. Sempre os dois juntos.
Decisões que valem entender
O backend não é REST
@curio/client é um transporte RPC proprietário. Não existe GET /api/fornecedores. Existe:
abrir um caso de uso por id numérico e enviar requests nomeados (RM_OBTEM_LISTA) dentro dele.
Consequência prática: não use axios, fetch direto (fora do carregamento do config.json), nem
qualquer geração de cliente a partir de OpenAPI. Ver 06.
O fetch do config é a única exceção
src/lib/curio/index.ts faz fetch("./config.json") para descobrir a URL do serviço em runtime.
Isso é intencional: permite trocar de ambiente sem rebuild. Ver 04.
React Query v5 — gcTime, não cacheTime
A opção cacheTime foi renomeada para gcTime na v5, em defaultOptions.queries e
defaultOptions.mutations do queryClient.ts, e em qualquer useQuery que a declare
(useConnectQuery). isPending já era o nome correto desde a v4.36 — nenhuma mudança ali.
Zod v4 exige @hookform/resolvers v5 e um ajuste de generics
Zod v4 mudou a arquitetura interna de tipos de ZodType. Duas consequências reais, encontradas
rodando tsc de verdade, não hipotéticas:
-
@hookform/resolversv3 só aceita Zod^3— subir o Zod exige subir os resolvers para v5 (que por sua vez exigereact-hook-form >= 7.55.0). Não é uma escolha independente. - O hook genérico
useValidatedForm<T extends z.ZodType>deixou de compilar: ooutputdez.ZodTypeem v4 não fecha automaticamente emFieldValues. A correção é dupla — apertar o bound do genérico paraz.ZodType<FieldValues>, e no ponto de chamada dozodResolver, usar um cast de tipo (as anyjustificado +as unknown as Resolver<...>) porque o tipo internoZod4Typedo resolver não compõe com um schema genérico sem perder a inferência. Versrc/hooks/useValidatedForm.ts— o cast é só de tipo;zodResolvercontinua validando em runtime exatamente como antes.
React Router v7 — sem prop future
Os future flags (v7_startTransition, v7_relativeSplatPath) que o BrowserRouter aceitava na v6
viraram comportamento padrão na v7. A prop future não existe mais — remova-a de main.tsx.
Estado de servidor no React Query, estado de UI no componente
Não há Redux, Zustand ou store global. O que vem do backend vive no React Query; o resto é useState
local ou Context (AuthProvider, NotificationProvider, TabsContext). Se você sentir falta de uma
store global, provavelmente está guardando resposta de servidor no lugar errado.
Erro é global, não local
Nenhuma página trata erro de request com try/catch para exibir mensagem. O QueryCache/MutationCache
do queryClient captura e empurra para o NotificationProvider. Ver 07.
Negociável
| Item | Padrão sugerido | Quando mudar |
|---|---|---|
| Porta do dev server | 5000 |
Conflito local; ajuste em vite.config.ts
|
| Paleta do tema | Azul #1976d2
|
Identidade visual do produto novo |
| Navegação por abas | Opcional | Só adote se o produto realmente precisar de abas |
| Dark mode | Ausente | Se o produto exigir; ver 12 |
sourcemap em produção |
true |
Desligue se o bundle for público e sensível |
02 — Bootstrap
02 — Bootstrap
Do repositório vazio até
npm run devservindo a aplicação. Siga na ordem; cada passo assume o anterior. Ao final, o checklist "Fundação" de 00-INDICE.md deve estar todo marcado.
1. Node
O projeto exige Node ≥ 24 (última LTS). Fixe a versão no repositório para que os git hooks consigam validá-la sem depender do shell do dev.
// .node-version
24.19.0
Com fnm:
fnm install 24 && fnm use
Armadilha conhecida. Se o
fnmnão estiver avaliado no perfil do shell (fnm env), o Node ativo pode ser uma versão antiga e osetEnvironment.jsfalha emprocess.loadEnvFile. GUIs de Git tipicamente não herdam o perfil — por isso opre-commitvalida a versão explicitamente (ver 14).
2. Esqueleto
npm create vite@latest . -- --template react-ts
Depois remova o que o template traz e não usamos: src/App.css, src/index.css,
src/assets/. Mantenha eslint.config.js — o próprio scaffold do Vite já gera flat config,
e é isso que este guia usa (ver 14); você vai reescrever o conteúdo,
não trocar de formato.
3. Dependências
Sem @latest em nada. npm i sem versão instala o mais novo do registro, o que pode não ter sido
verificado ainda contra este guia — hoje só o TypeScript está deliberadamente atrás do latest
(01). Pin explícito em tudo:
npm i @curio/client@^1.4.2 @emotion/react@^11.14.0 @emotion/styled@^11.14.1 @hookform/resolvers@^5.9.1 @mui/icons-material@^9.3.1 @mui/material@^9.3.1 @mui/x-date-pickers@^9.11.0 @tanstack/react-query@^5.101.4 @tanstack/react-query-devtools@^5.101.4 date-fns@^4.4.0 react@^19.2.0 react-dom@^19.2.0 react-hook-form@^7.85.0 react-router-dom@^7.18.2 zod@^4.4.3
npm i -D @eslint/js@^10.0.1 @types/node@^26.2.0 @types/react@^19.2.2 @types/react-dom@^19.2.4 @typescript-eslint/eslint-plugin@^8.67.0 @typescript-eslint/parser@^8.67.0 @vitejs/plugin-react@^6.0.5 eslint@^10.8.1 eslint-config-prettier@^10.1.8 eslint-plugin-react-hooks@^7.1.1 eslint-plugin-react-refresh@^0.5.4 globals@^17.11.0 husky@^9.1.7 lint-staged@^17.3.0 prettier@^3.9.6 typescript@^6.0.3 vite@^8.2.1 vite-plugin-checker@^0.14.5
Versões exatas em 01-STACK-E-DECISOES.md — essa tabela é a fonte de verdade; se as duas divergirem, ela vence.
@types/node@^26com Node>=24em runtime é uma folga deliberada. A linha 26.x dos tipos é a mais recente disponível hoje; a linha 24.x parou em24.13.3(sem novas patches). Não houve conflito detsc/build ao testar essa combinação de verdade, mas se aparecer algum tipo de API que não existe no Node 24 real, prenda de volta em^24.
4. package.json
{
"name": "nome-do-projeto",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"env:dev": "node ./setEnvironment.js dev",
"env:homolog": "node ./setEnvironment.js homolog",
"env:prod": "node ./setEnvironment.js prod",
"dev": "npm run env:dev && vite",
"start": "npm run env:dev && vite",
"start:homolog": "npm run env:homolog && vite",
"start:prod": "npm run env:prod && vite",
"build": "npm run lint && npm run env:prod && tsc && vite build",
"build:homolog": "npm run env:homolog && tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json,md}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json,md}\"",
"prepare": "husky"
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint --fix --report-unused-disable-directives --max-warnings 0",
"prettier --write"
],
"**/*.{json,css,scss,md,html,yml,yaml}": ["prettier --write"]
},
"engines": { "node": ">=24.0.0" }
}
preparedepende do layout do repositório. Acima está a forma para um repositório de módulo único (projeto web na raiz). Se o web for submódulo de um monorepo, o script muda de forma — algo como"prepare": "cd ../.. && husky caminho/do/modulo/.husky", apontando para a raiz real do repositório git. Se o projeto novo for um módulo dentro de um monorepo, replique essa forma — ver 14.
5. vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
import checker from "vite-plugin-checker";
export default defineConfig({
plugins: [
react(),
checker({
typescript: true,
overlay: { initialIsOpen: false, position: "tl" },
terminal: true
})
],
define: {
// curio espera globals de node; browser nao tem
global: "globalThis",
"process.env": {}
},
resolve: {
alias: {
"@": resolve(import.meta.dirname, "./src"),
"@components": resolve(import.meta.dirname, "./src/components"),
"@pages": resolve(import.meta.dirname, "./src/pages"),
"@hooks": resolve(import.meta.dirname, "./src/hooks"),
"@utils": resolve(import.meta.dirname, "./src/utils"),
"@types": resolve(import.meta.dirname, "./src/types"),
"@api": resolve(import.meta.dirname, "./src/api"),
"@context": resolve(import.meta.dirname, "./src/context"),
"@theme": resolve(import.meta.dirname, "./src/theme")
}
},
server: { port: 5000, open: true },
build: { outDir: "dist", sourcemap: true }
});
O bloco define não é opcional: @curio/client referencia global e process.env, que não
existem no browser. Sem ele a aplicação quebra em runtime no primeiro request.
6. tsconfig.json
{
"compilerOptions": {
"types": ["vite/client"],
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@pages/*": ["./src/pages/*"],
"@hooks/*": ["./src/hooks/*"],
"@utils/*": ["./src/utils/*"],
"@types/*": ["./src/types/*"],
"@api/*": ["./src/api/*"],
"@context/*": ["./src/context/*"],
"@theme/*": ["./src/theme/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Regra: todo alias novo entra nos dois arquivos. Só no
vite.config.ts→ o build passa e o editor reclama. Só notsconfig.json→ o editor aceita e o build quebra.
7. Configuração de ambiente
Crie setEnvironment.js, config/*.json e .env.example conforme
04-AMBIENTES-E-CONFIG.md. Sem isso, npm run dev falha no primeiro passo.
8. .gitignore
# .gitignore
node_modules
dist
dist-ssr
*.local
.env
# gerado por setEnvironment.js — nao versionar
public/config.json
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Harness (Claude Code) — estado local, nao versionado
harness/state/*.json
harness/state/*.md
!harness/state/_examples/
harness/handoffs/*.md
!harness/handoffs/_examples/
harness/user_preferences.md
9. Ponto de entrada
index.html, src/main.tsx e src/App.tsx conforme
03-ESTRUTURA-DE-PASTAS.md.
10. Hooks reutilizáveis
Copie os doze hooks/módulos de 21-CATALOGO-DE-HOOKS.md para src/hooks/, mais
src/utils/useCaseTriggersLogger.ts, e crie o src/hooks/index.ts.
Faça isso agora, não na primeira tela: useCurioMutation e useUseCaseControls são a única forma
suportada de chamar um caso de uso (06), e useAuthQuery é exigido pelo
AuthProvider (05).
11. Tela de exemplo (placeholder)
Antes de existir qualquer contrato real com o backend, crie uma tela Exemplo seguindo exatamente
a estrutura de 17-PRIMEIRA-TELA.md, mas com todo id de caso de uso e nome
de RM como placeholder explícito ("SUBSTITUA_USE_CASE_ID", "RM_SUBSTITUA_SALVAR", ...) — nunca
invente um valor plausível que pareça real.
Isso é obrigatório, não opcional, por dois motivos:
- É a única forma de provar, com
tsc+lint+buildreais, que a cadeia completa (service/constants.ts→service/interfaces.ts→service/hooks.ts→Page.tsx→UseCaseManager→paths.ts/routes.ts/menuTree.ts) compila e roteia antes de qualquer feature de negócio existir. - Dá ao time um exemplo executável para copiar, em vez de reconstruir o padrão de memória a cada tela nova.
Nomeie a entidade de forma que não possa ser confundida com domínio real — Exemplo, não uma
entidade do produto. Comente no topo do arquivo que é placeholder e que deve ser removido/substituído
quando a primeira tela de negócio for criada. Registre a rota e o item de menu normalmente
(09, 10) — a tela precisa ser navegável e
verificável no browser, não só compilar.
12. Qualidade e harness
13. Verificar
npm run lint && npx tsc --noEmit && npm run dev
Os três precisam passar. Se npm run dev abrir a página mas o console mostrar erro de global is not defined, o bloco define do passo 5 está faltando.
03 — Estrutura de pastas
03 — Estrutura de pastas
Árvore canônica de
src/, a responsabilidade de cada pasta e o que não pode morar nela. Estrutura é contrato: se um arquivo não cabe em nenhuma pasta, o problema é o arquivo.
Árvore
projeto/
├── .husky/ hooks de git (versionado)
├── config/ um JSON por ambiente (versionado)
│ ├── dev.json
│ ├── homolog.json
│ └── prod.json
├── docs/ documentação do projeto
├── harness/ disciplina de sessão — ver 18
├── public/
│ └── config.json GERADO por setEnvironment.js — nao versionar
├── src/
│ ├── api/ fronteira com o backend
│ ├── components/
│ │ ├── common/ reutilizáveis de domínio-neutro
│ │ ├── layout/ shell da aplicação
│ │ └── <Especifico>/ componentes de uso restrito
│ ├── context/ React Context providers
│ ├── hooks/ hooks reutilizáveis entre páginas
│ ├── lib/
│ │ └── curio/ wrapper do @curio/client
│ ├── pages/ uma pasta por tela
│ ├── router/ montagem do React Router
│ ├── routes/ declaração de paths, rotas e menu
│ ├── theme/ tema MUI
│ ├── types/ tipos globais
│ ├── utils/ funções puras
│ ├── App.tsx
│ └── main.tsx
├── .env local — nao versionar
├── .env.example versionado
├── .node-version
├── index.html
├── init.sh verificação de baseline — ver 19
├── setEnvironment.js
├── tsconfig.json
└── vite.config.ts
Responsabilidade por pasta
src/api/
A fronteira com o backend. Três arquivos, plano, sem subpastas:
| Arquivo | Responsabilidade |
|---|---|
session.ts |
login(), connect(), logoutSession() — ver 05
|
queryClient.ts |
Fábrica do QueryClient com tratamento global de erro — ver 07
|
auth.ts |
Helpers de autenticação |
Não pode morar aqui: requests de uma tela específica. Esses vivem em
src/pages/<Tela>/service/, junto de quem os usa — ver 06.
src/lib/curio/
O único lugar que importa @curio/client diretamente para configurar transporte. Envolve o
SecurityManager e expõe getSessionManager().
Não pode morar aqui: regra de negócio, componente React, chamada de caso de uso específico.
src/pages/
Uma pasta por tela. Estrutura de uma tela completa:
src/pages/Fornecedor/Cadastro/
├── IncluirFornecedorPage.tsx componente da tela
├── IncluirFornecedorPage.styles.ts objetos sx / styled
├── schemas.ts schemas zod do formulário
├── index.ts export { default } from "./IncluirFornecedorPage"
└── service/
├── hooks.ts hooks de caso de uso (useCurioMutation)
└── interfaces.ts tipos de request/response do backend
Telas simples podem omitir service/ e schemas.ts. index.ts é obrigatório — é o que permite
lazy(() => import("@pages/Fornecedor/Cadastro")).
Não pode morar aqui: componente usado por mais de uma tela (vai para components/common/),
tipo usado por mais de uma tela (vai para src/types/).
src/components/
| Subpasta | Critério |
|---|---|
common/ |
Reutilizável e agnóstico de domínio (DataTable, FormField) |
layout/ |
Shell: Main, PageContainer, TabBar
|
<Especifico>/ |
Reutilizado por 2+ telas mas preso ao domínio (ActionModal) |
Regra: um componente só sai de pages/ quando a segunda tela precisar dele. Não promova por
antecipação.
Não pode morar aqui: chamada direta de caso de uso. Componentes recebem dados por props.
Exceção: componentes de layout/ podem consumir Context (useAuth, useTabs).
src/hooks/
Hooks reutilizáveis entre páginas: useCurioMutation, useUseCaseControls, useValidatedForm,
useSearcher, useDebounce, useLocalStorage, useModal.
Não pode morar aqui: hook que serve a uma única tela — esse vai em pages/<Tela>/service/hooks.ts.
src/context/
Providers de estado global de aplicação: AuthProvider, NotificationProvider, TabsContext,
CurrentTabContext.
Não pode morar aqui: estado de servidor. Isso é React Query.
src/routes/ vs src/router/
Separação deliberada:
-
routes/= dados.paths.ts(constantes de URL),routes.ts(tabela de rotas),menuTree.ts(árvore do menu). Nenhum JSX. -
router/= comportamento.AppRouter.tsxconsome os dados e monta o React Router.
Ver 10.
src/utils/
Funções puras, sem hook/Context/sessão:
| Arquivo | Conteúdo |
|---|---|
formatters.ts |
Formatação para exibição: CPF, CNPJ, telefone, CEP, moeda, data/hora, truncar texto |
validators.ts |
Validação booleana: CPF, CNPJ, e-mail, telefone, CEP, senha forte, URL |
handlers.ts |
handleOpenReport — abre o visualizador de relatório (resources.viewer do config.json, ver 04) numa aba nova |
useCaseTriggersLogger.ts |
Log de debug de useUseCaseControls — ver 21
|
validators/Cnpj.ts + validators/index.ts
|
Validador de CNPJ no formato { isValid, errorMessage } que MaskedInput.customValidate espera — ver 08
|
validators/ é uma subpasta, não um arquivo — existe porque MaskedInput (em common/) importa
validateCnpj com uma assinatura específica (ValidationResult), diferente do isValidCNPJ booleano
de validators.ts. Os dois convivem: use isValidCNPJ para checagem simples, validateCnpj quando o
consumidor for um MaskedInput.
Não pode morar aqui: qualquer coisa que use hook, Context ou toque a sessão.
src/types/
Tipos usados por mais de uma tela. Tipos de request/response de um caso de uso específico ficam em
pages/<Tela>/service/interfaces.ts.
Barrel exports (index.ts)
Cada pasta de componente e a raiz de common/, layout/ e hooks/ expõem um index.ts.
// src/components/common/index.ts
export { default as DataTable } from "./DataTable";
export { default as FormField } from "./FormField";
export type { Column } from "./DataTable";
Armadilha comum. O barrel é mantido à mão e sai de sincronia com facilidade: um componente novo é criado mas não é exportado no
index.ts. Resultado: importes inconsistentes, uns pelo barrel e outros pelo caminho completo. Ao criar um componente, atualize o barrel no mesmo commit.
Pontos de entrada
// src/main.tsx
import React, { useState } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import { ptBR } from "date-fns/locale";
import App from "./App";
import theme from "@/theme";
import { AuthProvider } from "@/context/AuthProvider";
import { NotificationProvider, useNotification } from "@/context/NotificationProvider";
import { createQueryClient } from "@/api/queryClient";
// queryClient precisa do setNotification -> so pode nascer dentro do NotificationProvider
const AppWithQueryClient: React.FC = () => {
const { setNotification } = useNotification();
const [queryClient] = useState(() => createQueryClient(setNotification));
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</AuthProvider>
</QueryClientProvider>
);
};
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ThemeProvider theme={theme}>
<CssBaseline />
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={ptBR}>
<NotificationProvider>
<AppWithQueryClient />
</NotificationProvider>
</LocalizationProvider>
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>
);
A ordem dos providers é obrigatória. NotificationProvider precisa envolver o QueryClientProvider
porque o queryClient recebe setNotification na construção. AuthProvider precisa estar dentro do
QueryClientProvider (usa React Query) e dentro do BrowserRouter (usa useNavigate).
// src/App.tsx
import React from "react";
import { useAuth } from "@context/AuthProvider";
import LoadingScreen from "@components/LoadingScreen";
import AppRouter from "@/router";
const App: React.FC = () => {
const { isLoading } = useAuth();
if (isLoading) return <LoadingScreen />;
return <AppRouter />;
};
export default App;
04 — Ambientes e configuração
04 — Ambientes e configuração
Como o projeto descobre em runtime a qual backend falar. Dois mecanismos distintos:
config/{env}.json(por ambiente, versionado) e.env(por máquina, local). Não os confunda. Regra de ouro:public/config.jsoné gerado, nunca editado à mão, nunca versionado.
O fluxo
config/{env}.json ──[setEnvironment.js]──▶ public/config.json ──[fetch em runtime]──▶ SessionManager
▲
│
.env (override)
-
npm run devexecutanpm run env:devantes do Vite. -
setEnvironment.js devlêconfig/dev.json, aplica overrides do.enve escrevepublic/config.json. - Em runtime,
getSessionManager()fazfetch("./config.json")e constrói o transporte.
Por que em runtime e não em build: permite apontar o mesmo bundle para outro servidor trocando um arquivo, sem recompilar. É o que viabiliza o deploy ISAPI — ver 15.
config/{env}.json
Um arquivo por ambiente. Versionados.
// config/dev.json
{
"service": {
"url": "https://srvd1.dev.exemplo.com.br/cxClient/cxIsapiClient.dll/gatewayJSONBalanced?version=4",
"server": "192.168.0.00",
"system": "61",
"port": "5369"
},
"accessToken": "SUBSTITUA",
"resources": {
"get": "https://srvd1.dev.exemplo.com.br/cxClient/cxIsapiClient.dll/getpr",
"put": "https://srvd1.dev.exemplo.com.br/cxClient/cxIsapiClient.dll/putpr",
"viewer": "https://srvd1.dev.exemplo.com.br/relatorios/viewer/rel.html?id="
},
"logs": true
}
Chaves
| Chave | Tipo | O que é |
|---|---|---|
service.url |
string |
Endpoint do gateway Curio. Inclui ?version=4
|
service.server |
string |
IP/host do servidor de aplicação de destino |
service.system |
string |
Identificador numérico do sistema no Curio |
service.port |
string |
Porta do servidor de aplicação |
accessToken |
string |
Token de acesso ao gateway |
resources.get |
string |
Endpoint de download de recurso/arquivo |
resources.put |
string |
Endpoint de upload |
resources.viewer |
string |
URL base do visualizador de relatórios; recebe o id concatenado |
logs |
boolean |
Liga logs verbosos do transporte |
O tipo é declarado em src/lib/curio/index.ts:
// src/lib/curio/index.ts
export interface Config {
service: { url: string; server: string; system: number; port: number };
accessToken: string;
resources: { get: string; put: string; viewer: string };
logs: boolean;
}
Cuidado com mentira de tipo. Se o JSON trouxer
systemeportcomo string mas a interfaceConfigdeclarar number, o TypeScript não acusa nada — o JSON é lido viafetch, sem checagem de tipo em runtime, e o Curio aceita ambos os formatos. Ainda assim é uma mentira de tipo. Declare a interface fiel ao que o JSON realmente contém (system: string; port: string, se for o caso) em vez de forçar um tipo que não corresponde ao dado real.
setEnvironment.js
#!/bin/node
// setEnvironment.js — copia config/{env}.json para public/config.json
import fs from "fs";
import path from "path";
const environment = process.argv[2];
if (!environment) {
console.error("Por favor, especifique um ambiente: dev, homolog ou prod");
process.exit(1);
}
// .env so eh lido se Node >= 20.6 e arquivo existe
if (typeof process.loadEnvFile === "function" && fs.existsSync(".env")) {
process.loadEnvFile(".env");
}
try {
const envFileContent = JSON.parse(fs.readFileSync(`./config/${environment}.json`, "utf8"));
// override so vale em dev — prod nunca le .env
if (environment === "dev") {
if (process.env.LOCAL_SERVER) {
envFileContent.service.server = process.env.LOCAL_SERVER;
console.log(`service.server sobrescrito via LOCAL_SERVER: ${process.env.LOCAL_SERVER}`);
}
if (process.env.ACCESS_TOKEN) {
envFileContent.accessToken = process.env.ACCESS_TOKEN;
console.log("accessToken sobrescrito via ACCESS_TOKEN");
}
}
console.log(`Configurando ambiente: ${environment}`);
const publicDir = path.join(process.cwd(), "public");
if (!fs.existsSync(publicDir)) fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, "config.json"), JSON.stringify(envFileContent, undefined, 2));
console.log(`Ambiente ${environment} configurado com sucesso!`);
} catch (error) {
console.error(`Erro ao configurar ambiente ${environment}:`, error.message);
process.exit(1);
}
Nunca logue o valor do
ACCESS_TOKENno console. Token não vai para stdout. O snippet acima já evita isso.
.env — variáveis por máquina
Serve para o dev apontar o ambiente dev ao seu servidor local sem sujar o config/dev.json
compartilhado.
# .env.example — copie para .env e ajuste. .env nao eh versionado.
# Sobrescreve service.server no config gerado (so em dev)
LOCAL_SERVER="ENDERECO_DO_SERVIDOR_LOCAL"
# Sobrescreve accessToken (so em dev)
ACCESS_TOKEN="TOKEN_DE_ACESSO"
Regras:
-
.env.exampleé versionado e contém apenas placeholders. -
.envé gitignored. - Overrides só se aplicam ao ambiente
dev.homologeprodignoram o.envpor construção — isso é proposital, para que um.envesquecido não vaze para um build de produção. - Não use
import.meta.env/ prefixoVITE_para configuração de backend. Isso embute o valor no bundle em tempo de build e derrota o propósito doconfig.json.import.meta.env.DEV(flag de modo) é aceitável.
Adicionar um ambiente novo
- Crie
config/{nome}.jsoncom todas as chaves. - Adicione ao
package.json:"env:{nome}": "node ./setEnvironment.js {nome}", "start:{nome}": "npm run env:{nome} && vite" - Se o ambiente tiver build próprio, adicione
"build:{nome}".
Segurança
-
public/config.jsonno.gitignore. É gerado; versioná-lo cria conflito toda vez que alguém troca de ambiente. -
config/*.jsoné servido ao browser. Tudo ali é público para quem abrir o DevTools. Não coloque segredo que não possa ser lido pelo usuário final. -
accessTokenemconfig/dev.jsonfica no histórico do Git para sempre. Nunca commite um token real nesse arquivo. Commite"accessToken": "SUBSTITUA"e distribua o valor real via.env(dev) ou via substituição no pipeline (homolog/prod).
21 — Catálogo de hooks
21 — Catálogo de hooks
Os hooks reutilizáveis que todo projeto web deste padrão deve ter em
src/hooks/. São agnósticos de domínio: nenhum conhece Fornecedor, Documento ou qualquer entidade. Copie os doze arquivos deste documento no bootstrap — antes da primeira tela, não depois.
Inventário
| Hook | Camada | Depende de | Documento |
|---|---|---|---|
useConnectQuery |
Sessão |
api/session.connect, STORAGEKEY
|
05 |
useLoginMutation |
Sessão |
api/session.login, Session
|
05 |
useAuthQuery |
Sessão | os dois acima + logoutSession
|
05 |
mutationMessages |
Caso de uso | NotificationProvider |
06 |
useCurioMutation |
Caso de uso |
@curio/client/react, mutationMessages
|
06 |
useUseCaseControls |
Caso de uso | idem + isAuthError + useCaseTriggersLogger
|
06 |
useSearcher |
Caso de uso | AuthProvider |
06 |
useHookMutation |
Caso de uso | mutationMessages |
09 |
useTabCloseCallback |
Abas |
TabsContext, CurrentTabContext
|
09 |
useValidatedForm |
Formulário |
react-hook-form, zod
|
11 |
useDebounce |
Utilitário | nada | — |
useLocalStorage |
Utilitário | nada | — |
Mais um arquivo de apoio: src/utils/useCaseTriggersLogger.ts, exigido por useUseCaseControls.
mutationMessages.ts não é um hook de tela — é o núcleo compartilhado entre useCurioMutation e
useHookMutation (ver abaixo). Copie-o junto dos dois.
Grafo de dependências
useAuthQuery ─┬─ useConnectQuery ── api/session.connect
└─ useLoginMutation ── api/session.login
└─ (logout) ── api/session.logoutSession
mutationMessages ── NotificationProvider (useNotifyMutationSuccess + tipo MutationMessages)
useCurioMutation ─┬─ UseCaseManager (@curio/client/react)
└─ mutationMessages
useUseCaseControls ─┬─ UseCaseManager
├─ NotificationProvider
└─ utils/useCaseTriggersLogger
useSearcher ──── AuthProvider (session direta, sem UseCaseManager)
useHookMutation ──── mutationMessages (funcao async qualquer)
useTabCloseCallback ─┬─ TabsContext
└─ CurrentTabContext
useValidatedForm ── react-hook-form + zod
useDebounce, useLocalStorage ── nada
Ordem de cópia: utils/useCaseTriggersLogger.ts e mutationMessages.ts primeiro (nada depende deles
e várias coisas dependem deles), depois os demais hooks. useAuthQuery por último — ele importa os
outros dois de sessão.
Barrel
// src/hooks/index.ts
// Sessao e autenticacao
export { useAuthQuery } from "./useAuthQuery";
export { useConnectQuery } from "./useConnectQuery";
export { useLoginMutation } from "./useLoginMutation";
// Caso de uso Curio
export { useCurioMutation } from "./useCurioMutation";
export { useUseCaseControls } from "./useUseCaseControls";
export { useSearcher } from "./useSearcher";
export { useHookMutation } from "./useHookMutation";
export { useNotifyMutationSuccess, type MutationMessages } from "./mutationMessages";
// Abas
export { useTabCloseCallback } from "./useTabCloseCallback";
// Formularios
export { useValidatedForm } from "./useValidatedForm";
// Utilitarios
export { default as useDebounce } from "./useDebounce";
export { default as useLocalStorage } from "./useLocalStorage";
Note a mistura de export nomeado e default: useDebounce e useLocalStorage usam export default;
os demais, export nomeado. É herança da referência. Num projeto novo, padronize em export nomeado
— o barrel fica uniforme e o rename fica rastreável.
Camada de sessão
Os três hooks de sessão formam uma composição. AuthProvider consome apenas useAuthQuery;
página nenhuma toca nos outros dois.
useConnectQuery
Reconecta a partir do token guardado, no boot e no refresh de página.
const connectQuery = useConnectQuery();
Três decisões embutidas:
-
enabled: hasToken— sem token, a query nem roda. Evita request inútil no primeiro acesso. -
Timeout de 8s via
Promise.race—connect()não tem timeout próprio; sem isso a tela fica em loading indefinido quando o servidor não responde. -
Os
throwexplícitos —connect()retorna o erro em vez de lançar (05). Este hook é a fronteira que converte isso no que o React Query espera.
useLoginMutation
const loginMutation = useLoginMutation();
await loginMutation.mutateAsync({ email, password });
Grava o token no sessionStorage e invalida ["auth", "connect"], mantendo um único estado de sessão.
useAuthQuery
Compõe os dois e expõe a API que o AuthProvider usa:
const { session, isAuth, isLoading, login, logout, refetchConnection } = useAuthQuery();
| Campo | O que é |
|---|---|
session |
Session ativa, ou undefined
|
isAuth |
!!session |
isLoading |
Reconectando ou autenticando |
login |
(params) => Promise<Session> |
logout |
Aborta no servidor, reseta e limpa o cache |
refetchConnection |
Força a reconexão — usado no boot pelo AuthProvider
|
isConnecting / isLoggingIn / connectError / loginError
|
Estado granular, para telas de login |
A sessão vem de loginMutation.data ?? connectQuery.data: um login recém-feito tem precedência sobre
a reconexão.
O
logoutprecisa chamarlogoutSession(session). LimparsessionStoragee o cache não encerra a sessão no servidor — sósession.abort()faz isso. É comum uma implementação deuseAuthQueryomitir essa chamada e deixar sessão órfã no backend; a versão deste catálogo corrige.
Camada de caso de uso
Núcleo compartilhado: mutationMessages.ts
useCurioMutation e useHookMutation são, na maior parte, o mesmo hook: um useMutation que aceita
msgSucesso/msgErro/msgErroFallback e dispara a notificação de sucesso do mesmo jeito. A diferença
real entre os dois está só em como msgErro/msgErroFallback chegam ao mutationCache.onError
global — porque a forma de TParams é diferente:
-
useCurioMutation:TParamsé sempre um objeto (o payload do backend), então as mensagens cabem dentro dele — viajam porvariables. -
useHookMutation:TParamsé o valor que a função recebe (umaSession,void, o que for), não necessariamente um objeto — as mensagens não cabem ali. Viajam pelometada mutation, que o React Query aceita justamente para isto.
O que é idêntico foi extraído para um arquivo à parte, e os dois hooks reutilizam:
// src/hooks/mutationMessages.ts
import { useCallback } from "react";
import { useNotification } from "@context/NotificationProvider";
export interface MutationMessages {
msgSucesso?: string;
/** Sobrescreve qualquer mensagem de erro do backend — sempre prevalece. */
msgErro?: string;
/** Usada só quando o backend não devolve mensagem e msgErro não foi definido. */
msgErroFallback?: string;
}
// unico ponto que dispara a notificacao de sucesso — compartilhado por todo hook de mutation
export function useNotifyMutationSuccess() {
const { setNotification } = useNotification();
return useCallback(
(msgSucesso?: string) => {
if (msgSucesso) setNotification({ type: "success", message: msgSucesso });
},
[setNotification]
);
}
queryClient.ts também importa só o tipo MutationMessages (import type, sem custo em runtime)
para ler msgErro/msgErroFallback de variables ou de mutation.meta no mesmo lugar — ver
07.
Não force os dois hooks a usar o mesmo mecanismo de transporte (variables vs meta). A diferença
não é acidental — é consequência de TParams ter formas diferentes. Force-fit aqui pioraria os dois
lados: useCurioMutation perderia a possibilidade de variar msgErro por chamada, ou useHookMutation
teria que envolver todo TParams num objeto só para caber uma mensagem.
useCurioMutation
O mais importante do conjunto. Envia um request nomeado dentro do caso de uso aberto pelo
UseCaseManager.
export const useSalvaFornecedor = () => useCurioMutation<void, SalvaFornecedorRequest>("RM_SALVA_OBJETO");
const { mutateAsync: salvar, isPending } = useSalvaFornecedor();
await salvar({
Fornecedor: { _OID: "123", _Nome: "ACME" },
msgSucesso: "Fornecedor salvo com sucesso!",
onSuccess: () => setModalAberto(true)
});
Quatro coisas que ele faz e o useMutation cru não faria:
-
msgSucesso,msgErro,msgErroFallbackviram notificação e são removidos do payload antes de ir ao backend (delete params.msgSucesso, etc.). -
A mensagem de erro segue prioridade fixa, resolvida no
mutationCache.onErrorglobal (07), não dentro deste hook:msgErro(sempre prevalece) → mensagem do backend →msgErroFallback→ fallback genérico interno.useCurioMutationnão temonErrorpróprio — se tivesse, a notificação apareceria duas vezes. -
Callbacks no mesmo objeto dos parâmetros —
onSuccess,onError,onSettled,onMutateconvivem com os params num argumento só. Difere do React Query puro, onde seriam um segundo argumento. -
Tipagem condicional em
TParams— quando évoid, o argumento inteiro fica opcional (incluirFornecedor()); quando não é, fica obrigatório.
Use isPending, não isLoading — em mutations da v4.36 o isLoading está deprecado.
useUseCaseControls
Abre e fecha o caso de uso, e expõe o status.
const { open, close, status, error } = useUseCaseControls();
status vai de "idle" a "open". O open devolvido é a versão segura: o open() do Curio lança,
e este converte em notificação.
Descobrir os requests disponíveis:
const { open, status } = useUseCaseControls({ enableLogs: true });
Loga no console, só em desenvolvimento, os RMs chamáveis no estado atual. Remova antes de commitar
— o hook check-debug-flags.sh do pré-commit barra enableLogs: true
(14). Exige src/utils/useCaseTriggersLogger.ts.
useSearcher
Busca genérica pelas operações 120 (buscar) e 134 (obter contexto), sem UseCaseManager.
const { getContext, search } = useSearcher<FiltrosBusca, ResultadoBusca>("465");
const resultado = await search.mutateAsync({ _Nome: "ACME", _Ativo: true });
Usa a sessão do useAuth() diretamente. Escolha entre os dois caminhos:
| Situação | Use |
|---|---|
| Tela só de filtro + lista, sem estado no servidor | useSearcher |
| Incluir, alterar, salvar — há estado no caso de uso |
UseCaseManager + useCurioMutation
|
useHookMutation
Roda uma função async qualquer como mutation — para ação que não passa por UseCaseManager.
O caso canônico é o action de um item de menu (09).
// src/hooks/useHookMutation.ts
import { useMutation } from "@tanstack/react-query";
import { MutationMessages, useNotifyMutationSuccess } from "./mutationMessages";
export const useHookMutation = <TData = unknown, TParams = void>(
func: (params: TParams) => Promise<TData>,
messages: MutationMessages = {}
) => {
const notifySuccess = useNotifyMutationSuccess();
const { msgSucesso, msgErro, msgErroFallback } = messages;
return useMutation<TData, Error, TParams>({
mutationFn: (params) => func(params),
meta: { msgErro, msgErroFallback }, // TParams nao e' objeto — nao cabe em variables
onSuccess: () => notifySuccess(msgSucesso)
});
};
const { mutateAsync: executar, isPending } = useHookMutation<void, Session | undefined>(handleAbrirRelatorio, {
msgSucesso: "Relatório gerado.",
msgErroFallback: "Não foi possível gerar o relatório."
});
await executar(session);
Diferença para useCurioMutation: aquele envia um request dentro de um caso de uso já aberto;
este executa uma função que abre e fecha o próprio caso de uso. Ambos reutilizam
MutationMessages/useNotifyMutationSuccess de mutationMessages.ts — ver
"Núcleo compartilhado" acima.
Assinatura divergente de um padrão comum. É comum ver
msgSucesso/msgErrodentro dos parâmetros ({ options: { ... } }) com o parâmetro tipado comounknown. Isso quebra quando o parâmetro é uma instância de classe comoSession, e obriga a um cast em cada nó do menu. Aqui as mensagens são argumento de criação do hook eTParamsé genérico de verdade.
Abas
useTabCloseCallback
Registra o que rodar quando a aba da página for fechada.
const { close } = useUseCaseControls();
useTabCloseCallback(close);
Existe porque abas ficam montadas o tempo todo — só o painel ativo é visível. Logo, cleanup de
useEffect não dispara ao fechar a aba. Sem este hook, o caso de uso fica aberto no servidor
depois que o usuário fecha a aba.
É no-op quando a página veio pelo <Outlet/> da rota, então a mesma página serve aos dois modos de
navegação sem if.
Formulário e utilitários
useValidatedForm
const form = useValidatedForm({
schema: fornecedorSchema,
defaultValues: { _Nome: "", _Ativo: true }
});
Garante zodResolver e mode: "onChange" uniformes. Nunca use useForm direto —
ver 11.
useDebounce
const termoDebounced = useDebounce(termo, 400);
Atrasa a propagação de um valor. Use em filtro que dispara busca a cada tecla.
useLocalStorage
const [colunas, setColunas, removeColunas] = useLocalStorage("tabela.colunas", colunasPadrao);
Valor JSON no localStorage, sincronizado entre abas — dispara um CustomEvent próprio porque o
evento storage nativo não chega à aba que escreveu.
Não use para token de sessão. Isso é responsabilidade de lib/curio + api/session, que usam
sessionStorage (05).
O que não copiar de uma implementação existente
É comum achar hooks em src/hooks/ que parecem genéricos mas não pertencem a este catálogo:
| Sinal | Por quê |
|---|---|
Nome ligado a uma tela específica (ex.: useDashboard) |
Casos de uso do domínio daquele projeto. Específico |
Envelopa useState booleano só para renomear (ex.: useModal) |
Abstração fina demais para justificar existir |
Verificação
Depois de copiar:
npx tsc --noEmit && npm run lint
Erros que aparecem quando falta uma peça:
| Erro | Falta |
|---|---|
Cannot find module '@utils/useCaseTriggersLogger' |
O arquivo de apoio, ou o alias @utils
|
Cannot find module '@curio/client/react' |
Versão do @curio/client sem entrada react
|
Property 'isPending' does not exist |
React Query v4 antigo — exige 4.36+ |
useNotification precisa estar dentro de... |
NotificationProvider fora do lugar em main.tsx (03) |
Os hooks de sessão só se provam com backend real: faça login, dê F5 (deve manter a sessão) e faça logout (deve voltar ao login). Os de caso de uso só se provam na primeira tela (17).
Confira sempre contra este catálogo antes de copiar um hook de outra implementação — o defeito de
logout descrito acima é um erro recorrente em versões de useAuthQuery.
Qualidade e Processo
Convenções, pré-commit, build/deploy, harness e Definition of Done.
13 — Convenções
13 — Convenções
Nomenclatura de arquivos, dados, hooks, handlers e constantes. A regra que governa tudo: dado do backend mantém o nome do backend; lógica do front é em inglês. Ao final, uma lista de anti-padrões reais e recorrentes que não devem ser replicados.
Idioma
| Elemento | Idioma | Exemplo |
|---|---|---|
| Propriedade vinda do backend | Português |
_Nome, _DataExpiracao, Documentos
|
| Interface que espelha o backend | Inglês |
SupplierXML, SaveShipmentRequest
|
| Variável local, função, helper | Inglês |
isLoading, groupByLayout, handleClick
|
| Nome de componente | Inglês |
DataTable, LoadingButton
|
| Nome de página | Inglês | IncludeSupplierPage |
| Texto de interface | Português | label="Nome do fornecedor" |
| Mensagem de validação | Português | "Informe o CNPJ" |
| Comentário de código | Português | ver estilo |
| Segmento de URL | Inglês | registration/supplier-type/add |
O critério: se o nome atravessa a fronteira com o backend, ele é do backend. Se vive só no front, é inglês.
Propriedades do backend: _Prefixo
interface FornecedorXML {
_OID: string; // primitivo → underscore
_Nome: string; // primitivo → underscore
_Ativo: boolean; // primitivo → underscore
_DataCadastro: string; // primitivo → underscore
Endereco: EnderecoXML; // objeto → sem prefixo
Documentos: DocumentoXML[]; // coleção → sem prefixo
}
Regra do Curio: primitivo leva _, objeto complexo não leva.
Isso não é decoração — é como o payload chega. Renomear para camelCase exigiria uma camada de
mapeamento em toda request e resposta. O projeto opta por não ter essa camada: o custo é conviver com
_Nome no front.
Consequências:
- Campos de formulário usam os mesmos nomes (
name="_Nome"), evitando conversão no submit. - Sufixo
XMLnas interfaces que espelham a estrutura do backend, distinguindo-as dos tipos do front.
Booleano do backend: Flag
O Curio não tem tipo booleano nativo trafegando na rede — o valor real que chega/sai é a string "S"
ou "N". Nunca converta para boolean na borda; mantenha Flag do início ao fim.
type Flag = "S" | "N";
interface FornecedorXML {
_Ativo: Flag; // não `boolean`
}
Regra: true → "S", false → "N", em qualquer campo que atravesse a fronteira com o backend
(request ou response). Se o front precisar de um boolean de verdade (ex.: checked de um Checkbox),
converta na borda da UI, nunca no tipo que representa o payload:
<Checkbox checked={fornecedor._Ativo === "S"} onChange={(e) => setValue("_Ativo", e.target.checked ? "S" : "N")} />
Arquivos e pastas
| Item | Padrão | Exemplo |
|---|---|---|
| Pasta de componente | PascalCase |
FormField/ |
| Componente | PascalCase.tsx |
FormField.tsx |
| Estilos | PascalCase.styles.ts |
FormField.styles.ts |
| Barrel | index.ts |
— |
| Hook | camelCase.ts |
useCurioMutation.ts |
| Utilitário | camelCase.ts |
formatters.ts |
| Tipos de uma tela | interfaces.ts |
service/interfaces.ts |
| Hooks de uma tela | hooks.ts |
service/hooks.ts |
| Constantes de RM/UC | constants.ts |
service/constants.ts |
| Schemas de uma tela | schemas.ts |
— |
| Página | PascalCasePage.tsx |
IncluirFornecedorPage.tsx |
Páginas levam sufixo Page; componentes não. Deixa óbvio, no import, o que é rota e o que é peça.
Hooks
| Tipo | Padrão | Exemplo |
|---|---|---|
| Query de coleção | use{Entidade}s |
useFornecedores |
| Query de item | use{Entidade} |
useFornecedor |
| Mutation de caso de uso | use{Verbo}{Entidade} |
useSalvaFornecedor |
| Mutation genérica | use{Verbo}{Entidade}Mutation |
useCreateFornecedorMutation |
| Utilitário | use{Descritor} |
useDebounce, useModal
|
Hooks de caso de uso usam verbo em português, espelhando o request do backend:
RM_INCLUI_OBJETO → useIncluiFornecedor
RM_SALVA_OBJETO → useSalvaFornecedor
RM_OBTEM_DOCUMENTOS → useObtemDocumentos
Isso torna rastreável qual hook corresponde a qual request sem abrir o arquivo.
Handlers e callbacks
interface Props {
onSubmit: (data: FormData) => void; // prop → on{Ação}
onCancel: () => void;
}
const Componente = ({ onSubmit }: Props) => {
const handleFormSubmit = (data: FormData) => {
// interno → handle{Ação}
onSubmit(data);
};
};
on* é o que o componente recebe. handle* é o que ele define. Nunca inverta — a distinção
diz, na leitura, de onde vem o comportamento.
Constantes
// Módulo, imutável, conhecido em tempo de escrita → SCREAMING_SNAKE_CASE
const FORNECEDOR_RMS = { USE_CASE: "4821", SALVAR: "RM_SALVAR_DADOS_FORNECEDOR" };
export const STORAGEKEY = "br.com.nomedoprojeto";
// Objeto de configuração / default → camelCase
const defaultFormValues = { _Nome: "", _Ativo: true };
const fornecedorKeys = { all: ["fornecedores"] as const };
Ids de caso de uso e nomes de request sempre dentro de um objeto _RMS — ver
06 — nunca literal no JSX.
Tipos e interfaces
-
interfacepara formato de objeto;typepara união, interseção e utilitário. - Interface de props do componente exportada e nomeada
{Componente}Props. - Tipo de formulário inferido do zod (
z.infer), nunca escrito à mão — ver 11. - Sufixo
XMLpara interfaces que espelham o backend. - Sufixos
Request/Responsepara payloads de caso de uso.
Comentários
Comentário em código segue estilo ultra-comprimido (telegráfico), não prosa:
// ERRADO
// This function is responsible for grouping the routes by their layout so that
// we can render one parent Route per layout.
// CERTO
// agrupa por layout pra montar uma <Route> pai por layout
Comente por quê, não o quê. O código já diz o quê.
Quando um eslint-disable for inevitável, justifique na mesma linha:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @curio nao tipa o service
constructor(service: any, driver: RequestDriver) {
-- seguido do motivo é obrigatório. eslint-disable sem justificativa não passa em revisão.
Imports
Ordem, com linha em branco entre grupos:
// 1. React e libs externas
import React, { useEffect, useState } from "react";
import { Box, Button } from "@mui/material";
// 2. Aliases internos
import { useAuth } from "@context/AuthProvider";
import { FormField } from "@components/common";
// 3. Relativos da própria feature
import { useSalvaFornecedor } from "./service/hooks";
import { containerStyles } from "./IncluirFornecedorPage.styles";
Use aliases para cruzar pastas (@components/...), relativos dentro da própria feature (./service/...).
Nunca ../../../hooks/useX — para isso existe @hooks.
Anti-padrões recorrentes
Implementações reais tendem a acumular as mesmas inconsistências. A coluna "Padrão correto" é o que o projeto novo adota sempre — nunca replique a coluna da esquerda.
| Anti-padrão | Padrão correto |
|---|---|
Duas pastas para o mesmo domínio em idiomas diferentes (Documentos/ e Documents/) |
Um idioma só para nomes de pasta de página. Adote português |
Constantes de rota misturando idiomas (PATHS.DOCUMENTS.* ao lado de PATHS.DOCUMENTOS.*) |
Um idioma só nas URLs. Adote português |
CLAUDE.md/guia descreve uma pasta de requests que não existe mais no código |
Requests por tela em pages/<Tela>/service/ — 03
|
Documentação diz uma porta; vite.config.ts usa outra |
Documente a porta real, confira contra o código |
Documentação cita uma STORAGEKEY de exemplo; o código usa outra |
Chave própria do projeto, documentada corretamente |
Documentação diz sessionStorage; o código de logout limpa localStorage (ou vice-versa) |
Um storage só, consistente — 05 |
Logout faz localStorage.clear() (apaga tudo, não só a sessão) |
Remover só a chave da sessão |
Barrel (index.ts) desatualizado, não exporta componente/módulo já existente |
Barrel atualizado no mesmo commit do componente |
Campo de config tipado como number, mas o JSON de runtime entrega string
|
Tipar como string — 04
|
Rota como string literal ("dashboard") em vez da constante (PATHS.DASHBOARD) |
Sempre a constante |
Código de debug (<Profiler>, console.log) atrás de uma env var que nunca é definida |
Remover código morto; sem eslint-disable decorativo |
| Script de setup de ambiente loga o valor de um token/segredo | Nunca logar segredo |
| Arquivo de config de ambiente com token real commitado | Placeholder no repo; valor via .env — 04
|
| Detecção de expiração de sessão comparando string de mensagem de erro | Sinalização por código de erro em isAuthError
|
login()/connect() retornam o erro em vez de lançar |
Deixar lançar e tratar no hook |
Estilos num objeto styles = { root, drawer, ... } por arquivo |
Constantes nomeadas exportadas individualmente — ver 12 |
Se você encontrar mais algum ao portar código, acrescente aqui em vez de resolver em silêncio.
14 — Qualidade e pré-commit
14 — Qualidade e pré-commit
Três gates:
vite-plugin-checkerdurante o dev,pre-commitno commit,npm run buildno build. Não há suíte de testes — assumido e declarado, não esquecido. Regra: o gate corrige o que dá para corrigir e barra o que não dá.
Os gates
| Quando | O que roda | Barra? |
|---|---|---|
npm run dev |
vite-plugin-checker (type-check contínuo) |
Não — mostra overlay |
git commit |
Node version, flags de debug, lint-staged
|
Sim |
git pull |
post-merge → npm install se preciso |
Não |
npm run build |
lint → tsc → vite build
|
Sim |
Ausência de testes — declarado
Este padrão não tem suíte de testes automatizados. Não há Vitest, Jest, Testing Library nem Playwright. Isso não é omissão da documentação: é o estado da referência.
O que substitui:
-
strict: true+noUnusedLocals+noUnusedParametersno TypeScript - ESLint com
--max-warnings 0 -
npm run buildcomo verificação de integração (compila tudo) - Verificação manual da tela contra o backend real
Se o projeto novo quiser testes, decida no início. Adicionar depois exige refatorar componentes que
nasceram acoplados a Context e ao UseCaseManager. Se adotar, atualize 19 para
incluir o comando na Definition of Done.
ESLint
Flat config (eslint.config.js) — ESLint 9+ abandonou o formato .eslintrc.json. Não é uma
escolha do guia, é obrigatório a partir da v9; --ext ts,tsx também deixou de ser aceito no CLI (o
escopo de arquivos agora vem do files de cada bloco de config).
// eslint.config.js
import js from "@eslint/js";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
import prettier from "eslint-config-prettier";
import globals from "globals";
export default [
{ ignores: ["dist", "build", "vite.config.ts", "setEnvironment.js"] },
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
globals: { ...globals.browser, ...globals.node, ...globals.es2020 }
},
plugins: { "@typescript-eslint": tsPlugin, "react-hooks": reactHooks },
rules: {
...tsPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"no-unused-vars": "off",
"no-undef": "off",
"@typescript-eslint/no-unused-vars": "error",
"react-hooks/exhaustive-deps": "warn",
"no-debugger": "error",
"no-console": ["error", { allow: ["warn", "error", "info"] }],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-empty-interface": "error",
"no-shadow": "off",
"@typescript-eslint/no-shadow": "error",
"no-throw-literal": "error",
"no-return-await": "error",
"no-duplicate-imports": "error",
eqeqeq: ["error", "always"],
"no-var": "error",
"prefer-const": "error",
"use-isnan": "error"
}
},
prettier
];
Requer @eslint/js e globals como devDependencies novas (não existiam no formato antigo).
prettier (de eslint-config-prettier) por último desliga as regras de formatação do ESLint —
Prettier manda no formato, ESLint manda na correção.
no-undef: "off"não é opcional. O@typescript-eslint/recommendeddo formato antigo (.eslintrc.json) desligavano-undefinternamente ao resolver oextends; pegando o objeto de regras direto (tsPlugin.configs.recommended.rules) em flat config, esse desligamento não vem junto — sem repetir explicitamente,no-undefacusa falso positivo em tipos ambient do TS (EventListener,Reactusado só como tipo em.ts). Otscjá cobre isso; deixe o ESLint fora do caminho.
Regras que merecem explicação
| Regra | Efeito prático |
|---|---|
no-console (permite warn/error/info) |
console.log esquecido barra o commit |
@typescript-eslint/no-explicit-any |
any exige eslint-disable justificado |
react-hooks/exhaustive-deps: warn |
Ver abaixo |
eqeqeq |
== proibido |
no-shadow |
Variável interna não pode mascarar externa |
exhaustive-depscomo"warn", não"off". OsuseEffectde abertura de caso de uso (06) dependem de rodar em condições específicas, e a regra reclamaria de arrays intencionalmente incompletos — resolva com// eslint-disable-next-linejustificado nesses poucos casos, não desligando a regra inteira.
eslint-plugin-react-hooks v7 trouxe regras novas — decida o alcance
O recommended da v7 é bem mais amplo que o da v4 (que só tinha rules-of-hooks +
exhaustive-deps). Ele inclui regras no estilo "React Compiler" — por exemplo
react-hooks/set-state-in-effect, que acusa qualquer setStateX(...) chamado direto no corpo de um
useEffect (fora de um callback de evento/subscrição).
Isso pega padrões legítimos de código já existente: estado 100% derivado de outro estado/prop que
era sincronizado via useEffect. A correção correta não é suprimir a regra, é aplicar o padrão que
o React recomienda:
- Se o valor é derivado de outro estado/prop (ex.: uma lista filtrada de uma query), calcule direto
no corpo do componente — não precisa de
useState/useEffectnenhum. - Se precisa resincronizar quando uma prop muda (ex.: um hook que lê de
localStorageporkey), ajuste o estado durante o render (guardando a prop anterior em outrouseStatee comparando), não dentro de umuseEffect.
Adotar o recommended completo é a recomendação — ele pega bug real de cascata de render. Se o
projeto novo preferir adiar, ligue só rules-of-hooks + exhaustive-deps (equivalente ao v4) e trate o
resto depois, mas isso é uma escolha explícita, não o padrão.
--max-warnings 0
eslint . --report-unused-disable-directives --max-warnings 0
Warning é erro. Sem isso, warnings acumulam até ninguém mais ler a saída.
--report-unused-disable-directives acusa eslint-disable que não suprime mais nada — remove
supressão obsoleta. Sem --ext: em flat config o CLI não aceita mais essa flag, o escopo de arquivos
vem do files de cada bloco em eslint.config.js.
Prettier
// .prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 120,
"arrowParens": "always",
"endOfLine": "crlf",
"bracketSpacing": true,
"bracketSameLine": true,
"proseWrap": "preserve"
}
# .prettierignore
node_modules
dist
build
coverage
# Gerado em runtime por setEnvironment.js
public/config.json
package-lock.json
Dois pontos não-óbvios:
-
endOfLine: "crlf"— o time é Windows. Se houver dev em Linux/macOS, troque para"auto"e configure.gitattributes, senão todo commit reescreve o arquivo inteiro. -
bracketSameLine: true— o>de tag multi-linha fica na última prop, não em linha própria. Incomum, mas é o padrão da base.
husky
Instalação
// package.json — repositório de módulo único
"scripts": { "prepare": "husky" }
// package.json — projeto web dentro de um monorepo
"scripts": { "prepare": "cd ../.. && husky caminho/do/modulo/.husky" }
A forma do monorepo instala os hooks apontando para .husky/ dentro do módulo web, enquanto o
core.hooksPath é configurado na raiz do repositório.
Armadilha do monorepo.
core.hooksPathé config local do clone, mas.husky/é versionado. Trocar de branch pode ligar ou desligar os hooks em silêncio. Depois de clonar ou de trocar para uma branch que mexe empackage.json, rodenpm run preparee confirme comgit config core.hooksPath.
pre-commit
# .husky/pre-commit
# git roda o hook da raiz do repo; projeto Node pode estar em subpasta
hookdir="$(cd "$(dirname -- "$0")" && pwd)"
cd "$hookdir/.." || exit 1
# valida Node sem depender de fnm no shell
. "$hookdir/check-node-version.sh"
. "$hookdir/check-debug-flags.sh"
npx lint-staged
Três verificações, nesta ordem: versão de Node → flags de debug → lint-staged.
check-node-version.sh
# .husky/check-node-version.sh
# Sourced pelo hook, ja com cwd no projeto Node. Nao depende de fnm/nvm
# estarem carregados (GUIs de git nao herdam o profile) -- compara o node
# do PATH com .node-version (aceita >=).
expected_version=$(tr -d '[:space:]' < .node-version 2>/dev/null)
actual_version=$(node -v 2>/dev/null | sed 's/^v//')
if [ -z "$actual_version" ]; then
echo "hook: 'node' nao encontrado no PATH. Ative o Node >= ${expected_version:-do projeto} (ex: 'fnm use')."
exit 1
fi
if [ -n "$expected_version" ]; then
lower_version=$(printf '%s\n%s\n' "$expected_version" "$actual_version" | sort -V | head -n1)
if [ "$lower_version" != "$expected_version" ]; then
echo "hook: Node v$actual_version encontrado, necessario >= v$expected_version (.node-version)."
exit 1
fi
fi
Resolve um problema real: commitar por GUI de Git com Node 8 ativo faz o lint-staged falhar com erro
incompreensível.
check-debug-flags.sh
# .husky/check-debug-flags.sh
# Bloqueia commit com enableLogs: true (flag de depuracao de useUseCaseControls).
#
# So chama 'exit' no caminho de erro -- este arquivo e' sourced, entao
# 'exit 0' encerraria o hook antes do lint-staged rodar.
repo_root=$(git rev-parse --show-toplevel)
staged_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx')
if [ -n "$staged_files" ]; then
matches=""
for file in $staged_files; do
file_match=$(grep -n "enableLogs:[[:space:]]*true" "$repo_root/$file" 2>/dev/null || true)
if [ -n "$file_match" ]; then
matches="$matches$file:
$file_match
"
fi
done
if [ -n "$matches" ]; then
echo "hook: 'enableLogs: true' encontrado em arquivo(s) staged - remova antes de commitar:" >&2
printf '%s' "$matches" >&2
exit 1
fi
fi
O comentário sobre exit 0 não é decorativo. O arquivo é carregado com . (source). Um exit 0
no fim encerraria o pre-commit inteiro antes do lint-staged, e o commit passaria sem lint. Ao
escrever um hook novo neste estilo, só chame exit no caminho de erro.
Adapte a lista de flags ao projeto: qualquer flag de depuração que não deva ser commitada entra aqui.
post-merge
# .husky/post-merge
# Instala dependencias quando o merge/pull alterou package.json/lock.
changed=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
case "$changed" in
*caminho/do/modulo/package-lock.json*|*caminho/do/modulo/package.json*)
echo "post-merge: dependencias mudaram -> rodando npm install..."
hookdir="$(cd "$(dirname -- "$0")" && pwd)"
cd "$hookdir/.." || exit 1
. "$hookdir/check-node-version.sh"
npm install
;;
esac
Elimina a classe de bug "puxei a branch e o app não sobe". Ajuste os caminhos do case ao layout do
projeto novo.
lint-staged
// package.json
"lint-staged": {
"src/**/*.{ts,tsx}": [
"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"prettier --write ."
],
"**/*.{json,css,scss,md,html,yml,yaml}": ["prettier --write ."]
}
Duas imprecisões comuns nesta configuração, que valem corrigir.
- Os comandos usam
.(projeto inteiro) em vez dos arquivos staged. Olint-stagedpassa a lista de arquivos como argumento, que aqui é ignorada. Funciona, mas fica lento e formata arquivos que você não tocou.prettier --write .faz o hook reescrever e re-stagear arquivos. Um commit pode sair com formatação que você não fez.Forma correta:
"lint-staged": { "src/**/*.{ts,tsx}": [ "eslint --fix --report-unused-disable-directives --max-warnings 0", "prettier --write" ], "**/*.{json,css,scss,md,html,yml,yaml}": ["prettier --write"] }Sem
., olint-stagedanexa só os arquivos staged.
vite-plugin-checker
checker({
typescript: true,
overlay: { initialIsOpen: false, position: "tl" },
terminal: true
});
Type-check contínuo durante npm run dev, em overlay e no terminal. Não substitui tsc no build —
o npm run build roda tsc separadamente, porque o checker só verifica o que foi tocado na sessão.
Contornar os gates
git commit --no-verify pula os hooks. Reserve para emergência real (commit de WIP em branch pessoal).
Nunca em branch compartilhada.
HUSKY=0 desabilita os hooks no ambiente — útil em CI, onde o pipeline já roda lint e build.
15 — Build e deploy
15 — Build e deploy
Builds por ambiente, o artefato gerado e como ele é servido atrás do ISAPI. A escolha de ambiente acontece em build (qual
config.jsoné embarcado) e pode ser trocada em deploy (substituindo o arquivo) — sem recompilar. Não há pipeline de CI na referência. Isso é lacuna, não decisão.
Scripts
| Comando | O que faz |
|---|---|
npm run dev |
env:dev + Vite dev server (porta 5000) |
npm run start:homolog |
env:homolog + dev server apontando para homologação |
npm run build |
lint → env:prod → tsc → vite build
|
npm run build:homolog |
env:homolog → tsc → vite build
|
npm run preview |
Serve o dist/ localmente |
O build de produção inclui o lint
"build": "npm run lint && npm run env:prod && tsc && vite build"
Quatro etapas, sequenciais, qualquer falha aborta:
-
lint— ESLint com zero warnings -
env:prod— gerapublic/config.jsona partir deconfig/prod.json -
tsc— type-check completo (noEmit) -
vite build— bundle emdist/
build:homolognão roda o lint. É inconsistente com obuild. No projeto novo, inclua o lint nos dois:"build:homolog": "npm run lint && npm run env:homolog && tsc && vite build".
Por que tsc além do vite-plugin-checker
O checker do dev só verifica o que foi tocado na sessão. tsc verifica o projeto inteiro. Um erro de
tipo num arquivo que ninguém abriu passa pelo checker e é pego aqui.
O artefato
dist/
├── index.html
├── config.json ← copiado de public/, define o backend
└── assets/
├── index-<hash>.js
├── index-<hash>.css
└── <Pagina>-<hash>.js ← um chunk por página (lazy)
build: { outDir: "dist", sourcemap: true }
sourcemap: true publica os .map junto do bundle — qualquer um com acesso à URL lê o código-fonte
original. Aceitável em sistema interno; desligue se o app for exposto na internet.
Trocar de ambiente sem rebuild
Como o config.json é lido em runtime (fetch("./config.json") — ver 04),
o mesmo dist/ serve qualquer ambiente:
# aponta um build existente para outro backend
cp config/homolog.json dist/config.json
Isso viabiliza promover exatamente o artefato testado em homologação para produção, sem recompilar.
Consequência: o config.json do build não é a verdade final. Confirme o que está no servidor,
não o que foi buildado.
Deploy ISAPI
O backend Curio é servido por cxIsapiClient.dll sob IIS. O front é um SPA estático publicado no
mesmo IIS, normalmente num diretório virtual ao lado do gateway.
Passos:
-
npm run build(oubuild:homolog) - Copiar o conteúdo de
dist/para o diretório publicado no IIS - Ajustar
dist/config.jsonse o destino diferir do ambiente do build - Validar: abrir a aplicação, fazer login, executar uma tela que chame o backend
Rewrite para SPA
O React Router usa BrowserRouter (history API). Uma URL profunda como
/cadastro/fornecedor/incluir chega ao IIS como um caminho que não existe em disco — e retorna 404.
O IIS precisa de uma regra de rewrite que devolva index.html para qualquer caminho que não seja
arquivo real:
<!-- web.config no diretório publicado -->
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="SPA fallback" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
Sintoma de rewrite ausente: a aplicação funciona navegando pelo menu, mas F5 numa tela interna dá 404.
É comum uma implementação de referência não versionar um
web.config. Se o projeto novo for publicado em IIS, versione-o junto do código — configuração de servidor não deve viver só na memória de quem publica.
base do Vite
Se a aplicação for servida em subcaminho (https://servidor/sistema/ em vez da raiz), configure:
// vite.config.ts
export default defineConfig({ base: "/sistema/" });
Sem isso, os assets/ são requisitados da raiz e não carregam. A referência assume publicação na raiz
(base não é definido).
CI/CD — o que existe e o que falta
É comum encontrar .gitlab/ na raiz só com templates de issue e merge request:
.gitlab/
├── issue_templates/default.md
└── merge_request_templates/default.md
Sem .gitlab-ci.yml, não há pipeline. Se o template de MR pede "Pipeline passando" e "Testes
escritos ou atualizados" sem que o projeto tenha como cumprir nenhum dos dois, é checklist
aspiracional — alinhe o texto ao que existe de verdade.
Recomendação para o projeto novo
Um pipeline mínimo cobre o que hoje depende de disciplina individual:
# .gitlab-ci.yml
image: node:22
stages: [verify, build]
cache:
key: "$CI_COMMIT_REF_SLUG"
paths: [node_modules/]
verify:
stage: verify
script:
- npm ci
- npm run lint
- npx tsc --noEmit
- npm run format:check
build:
stage: build
script:
- npm ci
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
Ajuste os caminhos se o projeto web for submódulo de um monorepo (cd caminho/do/modulo antes dos comandos).
Se adotar CI, alinhe o checklist do template de MR ao que o pipeline realmente verifica. Checklist que pede o que não existe treina o time a marcar caixas sem ler.
Antes de publicar
16 — Template de `CLAUDE.md`
16 — Template de CLAUDE.md
Arquivo pronto para colar na raiz do projeto novo. Substitua tudo entre
{{ }}e apague o que não se aplicar. Inclui a seção de harness — ver 18 e 19.
Como manter
O CLAUDE.md é carregado automaticamente em toda sessão. Ele é índice, não enciclopédia: aponta
para os guias, não os duplica. Duplicar cria duas versões da verdade — é assim que um CLAUDE.md
diverge do código com o tempo (ver anti-padrões em
13).
Regra: se um fato pode mudar sem que ninguém lembre de atualizar o CLAUDE.md, ele não deveria estar
aqui — deveria estar num guia, referenciado daqui.
Template
# CLAUDE.md
Guia para o Claude Code (claude.ai/code) trabalhar neste repositório.
Projeto: **{{NOME_DO_PROJETO}}** — SPA React que fala com backend Curio via `@curio/client`.
Stack, arquitetura e convenções detalhadas vivem em `harness/guides/` — ver tabela abaixo.
---
## Comandos
```bash
npm run dev # ambiente dev + Vite (porta {{PORTA}})
npm run start:homolog # ambiente de homologação
npm run build # lint + tsc + build de produção
npm run lint # ESLint, zero warnings
npm run format # Prettier
npx tsc --noEmit # type-check completo
```
Node >= {{VERSAO_NODE}} (fixado em `.node-version`). Ative antes de qualquer comando — o `pre-commit`
valida e barra o commit se estiver errado.
**Não há suíte de testes.** A verificação é `lint` + `tsc` + `build` + teste manual da tela contra o
backend real. Não afirme que algo "passou nos testes".
---
## Guide Files
Toda a documentação de harness vive em `harness/`. `CLAUDE.md` e `init.sh` ficam na raiz — são os
pontos de entrada.
```
harness/
├── guides/ — referência técnica — versionado
├── _examples/
│ ├── state/ — feature_list.example.json, progress.example.md, session-handoff.example.md
│ ├── handoffs/ — handoff.example.md
│ └── user_preferences.example.md
├── <branch>/ — tudo que a branch produziu — VERSIONADO
│ ├── state/ — feature_list.json, progress.md, session-handoff.md
│ ├── plans/ — planos de implementação
│ ├── specs/ — specs de design
│ └── handoffs/ — handoffs de sessão
└── user_preferences.md — preferências do dev — ÚNICO ARQUIVO LOCAL (gitignored)
```
`<branch>` é o nome literal da branch atual (`git rev-parse --abbrev-ref HEAD`). **Só
`harness/user_preferences.md` é local.** Tudo dentro de `harness/<branch>/` é versionado — se
`harness/<branch>/state/` não existir ainda, **crie a pasta a partir de `_examples/`** (mecânico); não
invente conteúdo de `feature_list.json`/`progress.md`.
### Guias técnicos
- `harness/guides/commands.md` — build, run, ambientes, variáveis
- `harness/guides/architecture.md` — camadas do front, fluxo até o backend, autenticação
- `harness/guides/curio-framework-guide.md` — sessão, caso de uso, `useCurioMutation`, React Query
- `harness/guides/dev-environment.md` — Node/fnm, `.env`, acesso aos servidores
- `harness/guides/husky-git-hooks.md` — o que cada hook faz e como configurar
- `harness/guides/gitlab-access.md` — autenticação com a API do GitLab {{REMOVER_SE_NAO_USA}}
### Estado de features
- `harness/<branch>/plans/*.md` — planos task-by-task por feature
- `harness/<branch>/specs/*.md` — specs de design
- `harness/<branch>/handoffs/*.md` — handoffs por feature (versionado)
Quando existir plano/spec da feature ativa, ele tem precedência sobre o `feature_list.json` genérico
para o detalhe fino — mas atualize os dois ao final da sessão. Um plano/spec fica na branch onde
nasceu — não realoque se o trabalho continuar em outra branch depois.
### Sem histórico compartilhado entre branches
Não existe mais `global_feature_list.json`. Cada branch é autocontida — decisão deliberada em troca de
simplicidade (ver [18](18-HARNESS.md#reestruturação-2026-08-25-por-que-branch-primeiro)). Não recrie
esse arquivo.
---
## Harness — Estado, Verificação e Módulos
### Startup Workflow
Antes de escrever código:
1. Confirme o diretório com `pwd`.
2. Resolva a branch atual (`git rev-parse --abbrev-ref HEAD`). Se `harness/<branch>/state/` não
existir, crie a partir de `harness/_examples/` (scaffolding — não preencha conteúdo).
3. Leia `harness/user_preferences.md` (se existir).
4. Leia `harness/<branch>/state/feature_list.json` e `harness/<branch>/state/progress.md` (se existirem).
5. Revise commits recentes: `git log --oneline -5`.
6. Rode `./init.sh` (FAST por padrão; `FULL=1 ./init.sh` para a suíte completa).
Se o baseline já estiver quebrado, corrija antes de implementar feature nova.
### Interação com o usuário
Ao levantar decisões ou pontos em aberto, **faça uma pergunta por vez** e espere a resposta antes da
próxima. Não empacote várias perguntas numa lista só.
### Convenções de código
Comentários em código seguem estilo ultra-comprimido (telegráfico), não prosa. Comente **por quê**,
não **o quê**. Ver `harness/guides/` e a seção de convenções da documentação do projeto.
### Módulos (fonte de verdade)
| Módulo | Path | Doc | Verificação |
| ---------------- | ---------- | ----------------------------------------- | ---------------------------------------------------- |
| Frontend (React) | `{{PATH}}` | `harness/guides/curio-framework-guide.md` | `npm run lint` (rápido) / `npm run build` (completo) |
| Configuração | `config/` | `harness/guides/commands.md` | `npm run env:dev` gera `public/config.json` |
Antes de editar um módulo, leia o guia correspondente.
**Nunca editar à mão:** `public/config.json` — é gerado por `setEnvironment.js` a partir de
`config/{env}.json`. Toda mudança de configuração vai no arquivo de origem.
### Definition of Done
Uma tarefa só está concluída quando:
- o comportamento alvo foi implementado;
- `./init.sh` passa (ao menos FAST; FULL antes de considerar pronto para revisão);
- a tela foi **aberta no browser** e o fluxo verificado contra o backend real — sem suíte de testes,
esta é a única evidência funcional que existe;
- `harness/<branch>/state/feature_list.json` foi atualizado (`status` + `evidence`);
- `harness/<branch>/state/progress.md` foi atualizado ao fim da sessão.
Não marque como `passing` só porque o código foi escrito.
### Fim de sessão
1. Atualizar `harness/<branch>/state/feature_list.json` (status + evidência); se algo chegou a
`passing`, mover de `features` para `completed` no mesmo arquivo.
2. Atualizar `harness/<branch>/state/progress.md`.
3. Atualizar `harness/<branch>/state/session-handoff.md` com o resultado da verificação.
4. Commit descritivo com o repo em estado seguro — inclui `state/`, `plans/`, `specs/` e `handoffs/`
da branch, tudo versionado.
5. A próxima sessão deve conseguir rodar `./init.sh` imediatamente.
---
## Arquitetura — resumo
```
Componente → hook de caso de uso → useCurioMutation → UseCaseManager → @curio/client → Backend
```
- **Não é REST.** Casos de uso por id numérico, requests nomeados (`RM_SALVA_OBJETO`).
- **Erro é global.** `queryClient` captura e notifica; páginas não fazem `try/catch` para exibir erro.
- **Config em runtime.** `public/config.json` é lido por `fetch`, não embutido no bundle.
- **Propriedades do backend:** `_Prefixo` para primitivos, sem prefixo para objetos; nomes em português.
Detalhe completo em `harness/guides/curio-framework-guide.md`.
---
## Aliases
| Alias | Path |
| ------------- | ----------------- |
| `@` | `src/` |
| `@components` | `src/components/` |
| `@pages` | `src/pages/` |
| `@hooks` | `src/hooks/` |
| `@utils` | `src/utils/` |
| `@types` | `src/types/` |
| `@api` | `src/api/` |
| `@context` | `src/context/` |
| `@theme` | `src/theme/` |
Todo alias novo entra em `vite.config.ts` **e** `tsconfig.json`.
Se o projeto já tem um CLAUDE.md
Insira a seção de harness nele, não sobrescreva. Leia o arquivo inteiro antes, para não duplicar uma seção "Harness" criada numa tentativa anterior.
17 — Primeira tela
17 — Primeira tela
Receita end-to-end: uma tela de busca + cadastro de
Fornecedor, tocando todas as camadas. Copie, troqueFornecedorpela sua entidade e o id do caso de uso. Ao final há um checklist de verificação — a tela só está pronta quando ele passa.
O que vamos construir
Uma tela que:
- Abre um caso de uso no backend
- Busca fornecedores por filtro
- Exibe o resultado em tabela
- Salva um fornecedor novo
Camadas tocadas: constants → interfaces → hooks → schemas → página → paths → routes →
menuTree.
Pré-requisitos
Do backend, você precisa saber:
-
Id do caso de uso (ex.:
"4821") -
Nomes dos requests (ex.:
RM_INCLUI_OBJETO,RM_BUSCA_FORNECEDORES,RM_SALVA_OBJETO) - Formato dos payloads
Não invente. Pergunte a quem implementou o caso de uso ou descubra com enableLogs
(06).
Sem esse contrato ainda? Construa com placeholders
Se o backend real não existe ou ainda não foi definido, não pule esta receita — construa-a mesmo
assim, com todo id de caso de uso e nome de RM como placeholder explícito
("SUBSTITUA_USE_CASE_ID", "RM_SUBSTITUA_SALVAR"), numa entidade que não possa ser confundida com
domínio real (Exemplo, não Fornecedor). Isso é o passo 11 do bootstrap (02) —
prova, com tsc/lint/build reais, que a cadeia inteira compila e roteia antes de qualquer feature
de negócio existir, e dá ao time algo executável para copiar. Comente no topo do arquivo que é
placeholder, registre a rota normalmente, e apague quando a primeira tela de negócio nascer.
1. Constantes
// src/pages/Cadastro/Fornecedor/service/constants.ts
export const FORNECEDOR_RMS = {
USE_CASE: "4821",
OBTEM_DADOS: "RM_INCLUI_OBJETO",
BUSCAR: "RM_BUSCA_FORNECEDORES",
SALVAR: "RM_SALVA_OBJETO"
};
Ver 06. Nenhum id ou nome de request literal fora deste arquivo.
2. Interfaces
// src/pages/Cadastro/Fornecedor/service/interfaces.ts
export interface FornecedorXML {
_OID: string;
_Nome: string;
_CNPJ: string;
_Ativo: boolean;
TipoFornecedor?: TipoFornecedorXML;
}
export interface TipoFornecedorXML {
_OID: string;
_Titulo: string;
}
// abertura: backend devolve objeto novo + listas de apoio
export interface FornecedorInicialResponse {
Fornecedor: FornecedorXML;
TiposFornecedor: TipoFornecedorXML[];
}
export interface BuscaFornecedoresRequest {
OBJECTID: {
_Nome: string;
_Ativo: boolean;
};
}
export interface BuscaFornecedoresResponse {
Response: FornecedorXML[];
}
export interface SalvaFornecedorRequest {
Fornecedor: {
_OID: string;
_Nome: string;
_CNPJ: string;
TipoFornecedor?: { _OID: string };
};
}
3. Hooks de caso de uso
// src/pages/Cadastro/Fornecedor/service/hooks.ts
import { useCurioMutation } from "@/hooks/useCurioMutation";
import { FORNECEDOR_RMS } from "./constants";
import {
BuscaFornecedoresRequest,
BuscaFornecedoresResponse,
FornecedorInicialResponse,
SalvaFornecedorRequest
} from "./interfaces";
export const useIncluiFornecedor = () => useCurioMutation<FornecedorInicialResponse, void>(FORNECEDOR_RMS.OBTEM_DADOS);
export const useBuscaFornecedores = () =>
useCurioMutation<BuscaFornecedoresResponse, BuscaFornecedoresRequest>(FORNECEDOR_RMS.BUSCAR);
export const useSalvaFornecedor = () => useCurioMutation<void, SalvaFornecedorRequest>(FORNECEDOR_RMS.SALVAR);
4. Schemas
// src/pages/Cadastro/Fornecedor/schemas.ts
import { z } from "zod";
export const fornecedorSchema = z.object({
_Nome: z.string().min(1, "Informe o nome").max(120, "Máximo de 120 caracteres"),
_CNPJ: z
.string()
.min(1, "Informe o CNPJ")
.regex(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, "CNPJ inválido"),
_TipoFornecedor: z.string().min(1, "Selecione o tipo")
});
export type FornecedorFormData = z.infer<typeof fornecedorSchema>;
export const filtroFornecedorSchema = z.object({
_Nome: z.string(),
_Ativo: z.boolean()
});
export type FiltroFornecedorData = z.infer<typeof filtroFornecedorSchema>;
5. Estilos
// src/pages/Cadastro/Fornecedor/FornecedorPage.styles.ts
import { SxProps, Theme } from "@mui/material";
export const containerStyle: SxProps<Theme> = {
display: "flex",
flexDirection: "column",
gap: 2,
p: 3
};
export const headerBarStyle: SxProps<Theme> = {
display: "flex",
justifyContent: "flex-end",
gap: 1
};
export const filtroStyle: SxProps<Theme> = {
display: "flex",
gap: 2,
alignItems: "flex-start",
flexWrap: "wrap"
};
export const formSectionStyle: SxProps<Theme> = {
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2
};
Nomes de constante nomeada, não um objeto styles — ver 12.
6. A página
// src/pages/Cadastro/Fornecedor/FornecedorPage.tsx
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Divider, Typography } from "@mui/material";
import { Save, Search } from "@mui/icons-material";
import { UseCaseManager } from "@curio/client/react";
import { useAuth } from "@context/AuthProvider";
import { useUseCaseControls, useValidatedForm } from "@hooks/index";
import { DataTable, FormField, SelectInput, type Column, type SelectOption } from "@components/common";
import {
fornecedorSchema,
filtroFornecedorSchema,
type FornecedorFormData,
type FiltroFornecedorData
} from "./schemas";
import { FORNECEDOR_RMS } from "./service/constants";
import { useBuscaFornecedores, useIncluiFornecedor, useSalvaFornecedor } from "./service/hooks";
import type { FornecedorXML } from "./service/interfaces";
import { containerStyle, filtroStyle, formSectionStyle, headerBarStyle } from "./FornecedorPage.styles";
const colunas: Column<FornecedorXML>[] = [
{ id: "_Nome", label: "Nome", minWidth: 200 },
{ id: "_CNPJ", label: "CNPJ", minWidth: 160 },
{ id: "_Ativo", label: "Ativo", align: "center", format: (value) => (value ? "Sim" : "Não") }
];
const FornecedorContent: React.FC = () => {
const { open, status } = useUseCaseControls();
const [fornecedores, setFornecedores] = useState<FornecedorXML[]>([]);
const { data: dadosIniciais, mutate: incluirFornecedor, isPending: isIniciando } = useIncluiFornecedor();
const { data: resultadoBusca, mutate: buscarFornecedores, isPending: isBuscando } = useBuscaFornecedores();
const { mutateAsync: salvarAsync, isPending: isSalvando } = useSalvaFornecedor();
// refs impedem reabrir/reinicializar a cada render
const hasAttemptedOpenRef = useRef(false);
const hasInitializedRef = useRef(false);
// maquina de estados: idle -> abre; open -> inicializa
useEffect(() => {
if (status === "idle" && !hasAttemptedOpenRef.current) {
hasAttemptedOpenRef.current = true;
open();
} else if (status === "open" && !hasInitializedRef.current) {
hasInitializedRef.current = true;
incluirFornecedor();
}
}, [status, open, incluirFornecedor]);
useEffect(() => {
if (resultadoBusca) setFornecedores(resultadoBusca.Response ?? []);
}, [resultadoBusca]);
const tiposFornecedor: SelectOption[] = useMemo(
() => (dadosIniciais?.TiposFornecedor ?? []).map((tipo) => ({ value: tipo._OID, label: tipo._Titulo })),
[dadosIniciais]
);
const form = useValidatedForm({
schema: fornecedorSchema,
defaultValues: { _Nome: "", _CNPJ: "", _TipoFornecedor: "" }
});
const filtroForm = useValidatedForm({
schema: filtroFornecedorSchema,
defaultValues: { _Nome: "", _Ativo: true }
});
const handleBuscar = (data: FiltroFornecedorData) => {
buscarFornecedores({ OBJECTID: { _Nome: data._Nome, _Ativo: data._Ativo } });
};
const handleSalvar = async (data: FornecedorFormData) => {
await salvarAsync({
Fornecedor: {
_OID: String(dadosIniciais?.Fornecedor._OID ?? ""),
_Nome: data._Nome,
_CNPJ: data._CNPJ,
TipoFornecedor: { _OID: data._TipoFornecedor }
},
msgSucesso: "Fornecedor salvo com sucesso!"
});
form.reset();
incluirFornecedor(); // novo objeto pro proximo cadastro
filtroForm.handleSubmit(handleBuscar)();
};
const isAnyLoading = isIniciando || isBuscando || isSalvando;
return (
<Box sx={containerStyle}>
<Box sx={headerBarStyle}>
<Button
variant="contained"
startIcon={<Save />}
onClick={form.handleSubmit(handleSalvar)}
disabled={isAnyLoading}>
Salvar
</Button>
</Box>
<Typography variant="h3">Cadastro de fornecedor</Typography>
<Box sx={formSectionStyle}>
<FormField name="_Nome" control={form.control} label="Nome" size="small" fullWidth />
<FormField name="_CNPJ" control={form.control} label="CNPJ" size="small" fullWidth />
<SelectInput
name="_TipoFornecedor"
control={form.control}
label="Tipo"
options={tiposFornecedor}
size="small"
fullWidth
/>
</Box>
<Divider />
<Typography variant="h4">Fornecedores cadastrados</Typography>
<Box sx={filtroStyle}>
<FormField name="_Nome" control={filtroForm.control} label="Filtrar por nome" size="small" />
<Button
variant="outlined"
startIcon={<Search />}
onClick={filtroForm.handleSubmit(handleBuscar)}
disabled={isBuscando}>
Buscar
</Button>
</Box>
<DataTable
columns={colunas}
data={fornecedores}
loading={isBuscando}
emptyMessage="Nenhum fornecedor encontrado."
stickyHeader
maxHeight={400}
/>
</Box>
);
};
const FornecedorPage: React.FC = () => {
const { session } = useAuth();
return (
<UseCaseManager session={session} useCaseId={FORNECEDOR_RMS.USE_CASE} autoClose={false} openOnMount={false}>
<FornecedorContent />
</UseCaseManager>
);
};
export default FornecedorPage;
7. Barrel da página
// src/pages/Cadastro/Fornecedor/index.ts
export { default } from "./FornecedorPage";
Obrigatório. Sem ele, o lazy(() => import("@pages/Cadastro/Fornecedor")) falha em runtime sem erro
de compilação — o TypeScript não verifica o alvo do import dinâmico.
8. Caminho
// src/routes/paths.ts
export const PATHS = {
LOGIN: "login",
DASHBOARD: "dashboard",
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor" // novo
}
} as const;
9. Rota
// src/routes/routes.ts
export const routes: AppRoute[] = [
// ...
{
path: PATHS.CADASTRO.FORNECEDOR,
element: lazy(() => import("@pages/Cadastro/Fornecedor")),
guard: "protected"
}
];
10. Menu
// src/routes/menuTree.ts
export const menuTree: MenuNode[] = [
{ label: "Dashboard", path: PATHS.DASHBOARD, mode: "route" },
{
label: "Cadastro",
children: [{ label: "Fornecedor", path: PATHS.CADASTRO.FORNECEDOR }] // modo default: "tab"
}
];
Árvore final
src/pages/Cadastro/Fornecedor/
├── FornecedorPage.tsx
├── FornecedorPage.styles.ts
├── schemas.ts
├── index.ts
└── service/
├── constants.ts
├── hooks.ts
└── interfaces.ts
Mais três arquivos tocados: routes/paths.ts, routes/routes.ts, routes/menuTree.ts.
Verificação
Sem suíte de testes, esta é a evidência. Rode tudo.
Estático
npm run lint && npx tsc --noEmit
Funcional — npm run dev, então:
Falha proposital
Só depois de tudo isso registre a feature como passing no harness/state/feature_list.json —
ver 19.
Erros comuns
| Sintoma | Causa |
|---|---|
| Tela em branco, console: hook fora de contexto |
useCurioMutation no mesmo componente que renderiza o UseCaseManager
|
| Select "Tipo" vazio | Caso de uso não abriu; verifique status e o id |
| Import dinâmico falha em runtime | Faltou index.ts na pasta da página |
| Item no menu leva a 404 ou "Rota não encontrada" | Registrado em menuTree mas não em routes.ts
|
Rota duplica barra (//cadastro) |
PATHS com barra inicial |
| Requests repetidos em loop | Faltou o useRef de guarda no useEffect
|
| Backend recebe id/RM diferente do esperado | Constante duplicada fora de service/constants.ts, divergindo do _RMS
|
18 — Harness
18 — Harness
Disciplina de sessão: guias versionados, estado por branch, tudo versionado exceto preferências pessoais do dev. Ao criar em projeto novo, invoque a skill
anthropic-skills:curio-harness-bootstrap— ela é a fonte canônica dos templates.Reestruturado em 2026-08-25 (branch-primeiro, sem
global_feature_list.json) — ver nota de migração ao final.
O problema que isso resolve
Em projeto grande tocado por várias pessoas com Claude Code, três coisas quebram:
- Contexto se perde entre sessões. Cada sessão redescobre o que a anterior já sabia.
- Dois devs pisam no mesmo trabalho. Ninguém vê o que o outro está fazendo.
- "Está pronto" vira afirmação sem evidência. Código escrito é confundido com código verificado.
A solução não é framework — é convenção de arquivos. Guias versionados (o que o Claude precisa saber
antes de mexer), estado de sessão por branch (sem conflito entre branches), e um init.sh que
verifica baseline antes e depois.
Estrutura
Fica na raiz do repositório, ao lado de CLAUDE.md e init.sh. Não dentro de src/ nem de docs/.
projeto/
├── CLAUDE.md ponto de entrada (carregado automaticamente)
├── init.sh verificação de baseline — ver 19
└── harness/
├── guides/ referência técnica — VERSIONADO
├── _examples/
│ ├── state/ templates: feature_list.example.json, progress.example.md,
│ │ session-handoff.example.md
│ ├── handoffs/ template: handoff.example.md
│ └── user_preferences.example.md
├── <branch>/ tudo que a branch produziu — VERSIONADO
│ ├── state/ feature_list.json, progress.md, session-handoff.md
│ ├── plans/ planos de implementação (task-by-task)
│ ├── specs/ specs de design
│ └── handoffs/ handoffs de sessão
└── user_preferences.md preferências do dev — ÚNICO ARQUIVO LOCAL (gitignored)
<branch> é o nome literal da branch (git rev-parse --abbrev-ref HEAD). Branch com / no nome vira
subpasta naturalmente — feature/x → harness/feature/x/.
A distinção que importa
| Versionado | Local (gitignored) |
|---|---|
guides/, _examples/, tudo dentro de <branch>/
|
user_preferences.md |
| Todo o trabalho de sessão, por branch | Preferência pessoal do dev, não é da branch |
Trocar de branch agora troca de estado automaticamente — git checkout já isola o harness/<branch>/
de qualquer outra. Não existe mais "estado local do dev": o estado é da branch, e é versionado com ela.
.gitignore
# Harness (Claude Code) — so preferencia pessoal e' local
harness/user_preferences.md
Todo o resto — guides/, _examples/, e tudo dentro de harness/<branch>/ — é versionado.
guides/ — quais escrever
Um projeto fullstack usa guias de backend além dos de frontend. Um projeto só de frontend usa um subconjunto.
Escrever
| Guia | Conteúdo |
|---|---|
commands.md |
Build, run, ambientes, variáveis. Comandos reais, confirmados |
architecture.md |
Camadas do front, fluxo até o backend, autenticação, roteamento |
curio-framework-guide.md |
Sessão, caso de uso, useCurioMutation, React Query, tratamento de erro |
dev-environment.md |
Node/fnm, .env, acesso aos servidores por ambiente |
husky-git-hooks.md |
O que cada hook faz, como configurar, armadilhas de sh -e e source
|
Não escrever (são de backend)
curio-backend-syntax.md, curio-spring-boot-guide.md, staruml-tooling.md, test-data-scripts.md.
Se o projeto novo tiver backend próprio, aí sim — mas isso está fora do escopo deste guia.
Condicionais
-
gitlab-access.md— só se scripts do projeto usarem a API do GitLab (GITLAB_PRIVATE_TOKEN). Nunca commitar o valor do token, só a variável de ambiente. - Glossário de domínio (ex.: um
arquitetura-dominio-<assunto>.md) — só se o domínio for genuinamente intrincado. É investigação cara; pergunte antes de criar.
Regra ao escrever um guia
Investigue o código real. Não escreva de memória do que "Curio costuma ser" — pode ser outra versão
do framework, outra convenção, outro layout. Se um comando não foi confirmado (rodado ou lido do
package.json), não o documente.
<branch>/state/ — estado da branch
feature_list.json
{
"project": "{{NOME_DO_PROJETO}}",
"project_type": "frontend",
"last_updated": "YYYY-MM-DD",
"rules": {
"passing_requires_evidence": true,
"do_not_skip_verification": true
},
"status_legend": {
"not_started": "Work has not begun.",
"in_progress": "The feature is the current active task.",
"blocked": "Work cannot continue until a documented blocker is resolved.",
"passing": "Required verification has passed and evidence is recorded."
},
"features": [
{
"id": "exemplo-001",
"type": "ui",
"area": "nome-da-area",
"title": "Título curto da feature",
"behavior": "Descrição do comportamento esperado/pendência.",
"verification": "Como confirmar que está correto (comando, tela, checklist).",
"status": "not_started",
"evidence": "Preenchido só quando houver evidência real de verificação."
}
],
"completed": []
}
Num projeto só de frontend, type é predominantemente "ui" — a verificação é abrir a tela e conferir
o fluxo, não rodar teste.
O array features guarda o que ainda não é passing. Ao concluir, a entrada sai de features e
entra em completed — histórico da branch, versionado, sem cópia para nenhum arquivo global.
progress.md
# Session Progress Log (versionado — por branch)
## Current State
**Last Updated:** YYYY-MM-DD
**Active Feature:** o que você está trabalhando agora
## Sessão YYYY-MM-DD (parte N) — título curto
- O que foi feito, decisões tomadas, bugs encontrados.
- Comandos de verificação rodados e resultado.
- Pendências para a próxima sessão.
## Status
### What's Done
- [ ] ...
### What's In Progress
- [ ] ...
### What's Next
1. ...
## Blockers / Risks
- [ ] ...
session-handoff.md
# Session Handoff (versionado — por branch)
## Current Objective
- Goal: ...
- Active feature (from harness/<branch>/state/feature_list.json): ...
- Branch / commit: ...
## Completed This Session
- [x] ...
## Verification Evidence
| Check | Command | Result | Notes |
| ----- | ------- | ------ | ----- |
| ... | ... | ... | ... |
## Files Changed
- ...
## Decisions Made
- ...
## Blockers / Risks
- ...
## Next Session Startup
1. Ler `CLAUDE.md` (seção "Harness")
2. Resolver a branch atual (`git rev-parse --abbrev-ref HEAD`) e ler `harness/user_preferences.md`
(se existir) e `harness/<branch>/state/progress.md`
3. Rodar `./init.sh` (FAST) antes de editar
## Recommended Next Step
- ...
Regra inegociável
feature_list.json e progress.md nunca nascem pré-preenchidos. Só os _examples/ são criados no
bootstrap. harness/<branch>/state/ é criado (scaffolding a partir dos _examples/) na primeira sessão
daquela branch — mas o conteúdo é preenchido por quem trabalha, não pelo Claude "para ajudar".
Se harness/<branch>/state/ não existir ainda, o Claude deve criar a pasta a partir dos _examples/
(mecânico) e recomendar preencher — não inventar feature_list.json/progress.md com valores
supostos.
plans/ e specs/
-
<branch>/plans/*.md— planos de implementação task-by-task, com checkboxes. Nome:YYYY-MM-DD-nome-da-feature.md. -
<branch>/specs/*.md— specs de design para decisões pontuais. Nome:YYYY-MM-DD-nome-do-assunto-design.md.
Quando existir plano ou spec da feature ativa, ele tem precedência sobre o feature_list.json
genérico para o detalhe fino. Atualize os dois ao final da sessão.
Regra de proveniência: um plano/spec fica na branch onde foi criado, mesmo que o trabalho continue depois em outra branch. Não é para realocar quando isso acontecer — é só registro de onde nasceu.
Se o projeto já tem planos/specs soltos em outro lugar (ex.:
docs/superpowers/plans/edocs/superpowers/specs/, de um uso anterior da skillsuperpowers), esse conteúdo passa a viver emharness/<branch>/plans/eharness/<branch>/specs/.Nota de compatibilidade: a skill
superpowerspode assumir o caminho antigodocs/superpowers/. Se ela não encontrar os arquivos no novo local, ajuste a skill — não volte a mover os arquivos.
handoffs/ e user_preferences.md
-
<branch>/handoffs/*.md— handoff de uma feature específica, versionado. Complementa osession-handoff.md, que cobre a sessão mais recente de qualquer feature naquela branch. -
user_preferences.md— preferências pessoais do dev (modo padrão doinit.sh, verbosidade, ambiente local, convenções de commit). Único arquivo local, criado a partir de_examples/user_preferences.example.md. Não é da branch — é da pessoa, atravessa todas as branches.
Bootstrap em projeto novo
Invoque a skill anthropic-skills:curio-harness-bootstrap. Ela traz os templates já validados,
o init.sh.template e a seção de CLAUDE.md. Não reconstrua a estrutura lendo outro projeto na mão —
use os templates da skill como fonte, para não divergir silenciosamente.
Passos:
- Criar
harness/guides/eharness/_examples/(nomes exatos acima — não invente variação). - Copiar os templates para dentro de
_examples/(state/,handoffs/,user_preferences.example.md). - Adicionar
harness/user_preferences.mdao.gitignore— só ele. - Escrever os guias a partir do código real do projeto.
- Gerar o
init.shcom os comandos reais — ver 19. - Inserir a seção de harness no
CLAUDE.md— ver 16, incluindo o passo de resolver a branch atual e criarharness/<branch>/state/a partir dos_examples/. -
Rodar o
init.sh(FAST) e confirmar que passa.
Não existe mais passo de criar global_feature_list.json — foi eliminado (ver nota abaixo).
O que não fazer
- Não copie os guias de outro projeto achando que "é o mesmo framework". Pode ser outra versão do Curio, outra linguagem, outras convenções. Sempre reescreva a partir da investigação do projeto alvo.
-
Não crie
<branch>/state/feature_list.jsonouprogress.mdpreenchidos "para ajudar" — só a pasta, a partir dos_examples/; o conteúdo é de quem trabalha. -
Não pule a execução do
init.sh. Script quebrado passa despercebido até a primeira sessão real, quando já é tarde. -
Não recrie
global_feature_list.json. Foi removido deliberadamente — ver nota de migração abaixo.
Reestruturação 2026-08-25: por que branch-primeiro
Problema encontrado: harness/state/feature_list.json (+ progress.md, session-handoff.md) era
um único arquivo, compartilhado entre todas as branches — apesar do .gitignore marcar state/* como
local, os arquivos já estavam commitados de antes. Trocar de branch sobrescrevia o estado de outra
branch. Essa foi a causa raiz de um bug real observado num projeto que segue este padrão.
Decisão: layout branch-primeiro. Cada branch ganha harness/<branch>/ com state/, plans/,
specs/ e handoffs/ próprios, tudo versionado — sem gitignore para nada disso.
O que foi removido: global_feature_list.json (histórico compartilhado entre devs/branches de
features passing). Decisão explícita: aceitar perder a visibilidade cross-branch em troca de
simplicidade — cada branch agora é autocontida. Não recrie esse arquivo achando que é uma omissão.
Migração de histórico pré-existente: se um projeto já tem harness no formato antigo (um state/
único, global_feature_list.json) e vai migrar para este layout, os plans//specs/ antigos devem ser
reatribuídos à branch onde nasceram — via git log --merges (merge commits guardam a branch de
origem mesmo depois dela ser deletada: para cada arquivo, ache o commit de criação e caminhe pelos
merges até achar aquele em que o commit é ancestral só do segundo parent, não do primeiro). Não deixe
esse histórico solto numa pasta _legacy — reatribua de fato.
19 — `init.sh` e Definition of Done
19 — init.sh e Definition of Done
Um script que verifica o baseline do projeto antes e depois de cada sessão, em dois modos. E a definição de quando uma tarefa está de fato concluída. Um
init.shque não roda é pior que nenhum. Execute-o antes de dizer que terminou.
Por que existe
Sem baseline verificado, uma sessão começa sobre terreno já quebrado e o dev gasta uma hora
depurando um erro que não era dele. O init.sh responde a uma pergunta em segundos: o projeto está
são agora?
Roda no início da sessão (o terreno está limpo?) e no fim (eu quebrei algo?).
FAST e FULL
A distinção é o que faz o script ser usado de fato, em vez de pulado por demorar.
| Modo | Invocação | O que roda | Quando |
|---|---|---|---|
| FAST | ./init.sh |
Lint + type-check | Toda hora, várias vezes ao dia |
| FULL | FULL=1 ./init.sh |
+ format:check + build completo |
Antes de considerar pronto |
FAST precisa terminar em segundos. Se passar de ~30s, algo está no modo errado.
O script
#!/usr/bin/env bash
# init.sh — verifica o baseline do projeto antes/depois de uma sessao.
#
# Uso:
# ./init.sh # rapido (padrao): lint + type-check
# FULL=1 ./init.sh # completo: + format:check + build
#
# Pre-requisitos:
# - Node >= 24 no PATH (gerenciado via fnm — ver harness/guides/dev-environment.md)
# - npm install ja rodado
set -euo pipefail
FULL="${FULL:-0}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "== {{NOME_DO_PROJETO}} — init.sh (modo $([ "$FULL" = "1" ] && echo FULL || echo FAST)) =="
echo ""
echo "--- [1/4] Node ---"
node_expected=$(tr -d '[:space:]' < "$ROOT_DIR/.node-version")
node_actual=$(node -v 2>/dev/null | sed 's/^v//')
if [ -z "$node_actual" ]; then
echo "ERRO: 'node' nao encontrado no PATH. Rode 'fnm use'."
exit 1
fi
lower=$(printf '%s\n%s\n' "$node_expected" "$node_actual" | sort -V | head -n1)
if [ "$lower" != "$node_expected" ]; then
echo "ERRO: Node v$node_actual encontrado, necessario >= v$node_expected."
exit 1
fi
echo "Node v$node_actual OK"
echo ""
echo "--- [2/4] Lint ---"
(cd "$ROOT_DIR" && npm run lint)
echo ""
echo "--- [3/4] Type-check ---"
(cd "$ROOT_DIR" && npx tsc --noEmit)
if [ "$FULL" = "1" ]; then
echo ""
echo "--- [4/4] Formatacao + build ---"
(cd "$ROOT_DIR" && npm run format:check)
(cd "$ROOT_DIR" && npm run build)
else
echo ""
echo "--- [4/4] Build — PULADO (rode com FULL=1 para incluir) ---"
fi
echo ""
echo "== init.sh OK =="
Projeto web dentro de um monorepo
Se o web for submódulo de um monorepo, o init.sh fica na raiz do repositório e entra na pasta do
módulo:
WEB_DIR="$ROOT_DIR/caminho/do/modulo"
echo "--- [2/4] Lint ---"
(cd "$WEB_DIR" && npm run lint)
Decisões do script
| Item | Razão |
|---|---|
set -euo pipefail |
Aborta no primeiro erro; variável indefinida vira falha, não string vazia |
| Checagem de Node primeiro | Erro claro em vez de falha incompreensível do npm |
npx tsc --noEmit no FAST |
Barato e pega o que o checker do dev não viu |
build só no FULL |
Demora demais para rodar a toda hora |
format:check, não format
|
Verificação não deve reescrever arquivo |
Não copie comandos de outro projeto (ex.: ./mvnw compile, mvn test de um projeto fullstack
Java+React). Os comandos precisam ser os reais deste projeto.
Rodar o script
chmod +x init.sh
./init.sh
No Windows, execute pelo Git Bash. Se o time for todo Windows, considere um init.ps1 equivalente —
mas mantenha um só como fonte de verdade, para não divergirem.
Execute o script depois de gerá-lo. Isso não é sugestão: um init.sh que nunca rodou tem
probabilidade alta de estar quebrado (caminho errado, script npm inexistente, set -e derrubando num
comando que retorna não-zero legitimamente).
Startup Workflow
Antes de escrever código:
-
pwd— confirme o diretório. - Resolva a branch atual (
git rev-parse --abbrev-ref HEAD). Seharness/<branch>/state/não existir, crie a partir deharness/_examples/(scaffolding mecânico, não preenchimento de conteúdo). - Leia
harness/user_preferences.md(se existir). - Leia
harness/<branch>/state/feature_list.jsoneprogress.md(se existirem). -
git log --oneline -5. -
./init.sh.
Se o baseline já estiver quebrado, corrija antes de implementar feature nova. Misturar correção de baseline com feature nova produz um diff que ninguém consegue revisar.
Definition of Done
Uma tarefa só está concluída quando:
O item do browser não é opcional
Não há suíte de testes (14). lint e tsc provam que o código
compila, não que funciona. A única evidência funcional que existe neste padrão é abrir a tela e
verificar o fluxo. Ver o checklist de 17.
Evidência é texto, não adjetivo
// RUIM — nao e' evidencia
"evidence": "Implementado e testado."
// BOM — verificavel
"evidence": "Implementado em 2026-08-18. ./init.sh FULL verde. Tela cadastro/fornecedor aberta em dev:
busca com filtro 'ACME' retornou 3 registros; salvar com CNPJ invalido bloqueou no zod; salvar valido
mostrou notificacao verde e o registro apareceu na tabela apos rebusca. Sem erro no console."
Não marque como passing só porque o código foi escrito. É o erro mais comum, e o que torna o
feature_list.json inútil.
Fim de sessão
- Atualizar
harness/<branch>/state/feature_list.json(status + evidência): se algo chegou apassing, mover a entrada defeaturesparacompletedno mesmo arquivo — sem cópia para nenhum arquivo global, não existe mais (ver 18). - Atualizar
harness/<branch>/state/progress.md. - Atualizar
harness/<branch>/state/session-handoff.mdcom o resultado da verificação. - Commit descritivo com o repositório em estado seguro — inclui
state/,plans/,specs/ehandoffs/da branch, tudo versionado. - A próxima sessão deve conseguir rodar
./init.shimediatamente.
O passo 5 é o teste do fim de sessão: se a próxima pessoa não consegue verificar o baseline sem antes consertar algo, a sessão não fechou.
Se o projeto adotar testes
Se 14 for revisto e o projeto passar a ter suíte:
- Adicione
npm testao bloco FULL doinit.sh. - Adicione "testes passando" à Definition of Done.
- Mantenha o item da verificação no browser — teste unitário não prova integração com o Curio.
20 — Ciclo de desenvolvimento
20 — Ciclo de desenvolvimento
Do recebimento da tarefa ao merge na master: issue, branch, commits, MR, review. Processo pensado para projeto só de frontend, com o harness integrado. Este documento cobre como o trabalho flui; os anteriores cobrem como o código se organiza.
Referência rápida
tarefa recebida
→ clarificação (contexto, escopo, critérios, dependências)
→ issue aberta com template + labels + assignee
→ branch criada: <id-issue>-<descricao-curta>
→ ./init.sh antes de começar (baseline limpo)
→ sync com master
→ desenvolvimento com commits semânticos
→ ./init.sh FAST antes de cada commit
→ ./init.sh FULL + verificação no browser antes do MR
→ sync com master antes de abrir MR
→ MR aberto com template + reviewer
→ code review → ajustes → aprovação
→ merge → issue fechada
→ fim de sessão do harness (feature_list, progress, handoff)
1. Recebimento da tarefa
Antes de abrir issue, você precisa conseguir responder as quatro perguntas. Se não conseguir, peça uma reunião curta (15 min) com quem trouxe a demanda.
| Pergunta | O que responder |
|---|---|
| Contexto | Por que essa tarefa existe? Que problema de negócio resolve? |
| Escopo | O que está dentro e fora dessa entrega? |
| Critérios de aceite | Como saberemos que está pronto? Quem valida? |
| Dependências | Algo precisa estar pronto antes? Outro time envolvido? |
Num projeto Curio há uma quinta pergunta, específica: o caso de uso do backend já existe? Se a tela depende de um caso de uso ainda não implementado, isso é dependência bloqueante — registre.
2. Issue no GitLab
Template
.gitlab/issue_templates/default.md:
## Contexto
<!-- Por que essa tarefa existe? O que motivou essa demanda? -->
## O que fazer
<!-- Descrição objetiva e clara do que precisa ser entregue. -->
## Critérios de aceite
- [ ]
- [ ]
## Notas técnicas
<!-- Id do caso de uso, nomes de request, impacto em outras telas.
Deixe em branco se não houver. -->
## Referências
<!-- Design no Figma, documentação, issue relacionada. -->
Título
Padrão [Tipo] Verbo + objeto:
[Feature] Adicionar tela de cadastro de fornecedor
[Bug] Corrigir data de expiração enviada sem sufixo de hora
[Chore] Atualizar dependências para versão LTS
[Refactor] Extrair filtro de busca para componente comum
Labels
Dois eixos:
| Eixo | Labels |
|---|---|
| Tipo |
type::feature type::bug type::chore type::refactor
|
| Prioridade |
priority::critical priority::high priority::medium priority::low
|
Assignee é obrigatório. Issue sem dono não entra no sprint.
Definition of Ready
Uma issue só entra no sprint quando:
3. Branch
Crie a partir da issue no GitLab — o id vem automaticamente. Sempre a partir da master atualizada.
<id-issue>-<descricao-curta>
123-cadastro-fornecedor
456-corrigir-data-expiracao
789-atualizar-dependencias
4. Antes de começar
Rode o Startup Workflow do harness (19):
git fetch origin && git merge origin/master
./init.sh
Se o baseline já estiver quebrado, corrija antes de começar a feature. Misturar conserto de baseline com feature nova produz um diff irrevisável.
Mantenha a branch sincronizada com a master ao menos uma vez por dia, e sempre antes de abrir o MR.
5. Commits
Conventional Commits:
<tipo>: descrição curta
| Tipo | Quando usar |
|---|---|
feat |
nova funcionalidade |
fix |
correção de bug |
refactor |
refatoração sem mudança de comportamento |
docs |
documentação |
chore |
manutenção, dependências |
perf |
melhoria de performance |
test |
testes — só se o projeto adotar suíte |
feat: adicionar tela de cadastro de fornecedor
fix: corrigir data de expiração enviada sem sufixo de hora
refactor: extrair filtro de busca para componente comum
Princípios
- Cada commit faz uma coisa só
- O baseline passa após cada commit
- A mensagem explica o porquê, não o o quê
Verificação antes de cada commit
./init.sh
O pre-commit já roda lint-staged, versão de Node e checagem de flags de debug
(14) — mas ele só vê os arquivos staged. O init.sh FAST vê o
projeto inteiro.
Não suba código que quebra o baseline. Isso bloqueia o time e polui o histórico.
Se este projeto for só frontend, ignore quaisquer instruções de build Java/Delphi herdadas de um processo combinado com backend — não se aplicam.
6. Merge Request
Template
.gitlab/merge_request_templates/default.md:
## O que foi feito
<!-- Breve descrição das mudanças. -->
## Issue relacionada
Closes #
## Como testar
1.
2.
## Screenshots (se aplicável)
<!-- Antes/depois para mudanças visuais. -->
## Checklist
- [ ] `./init.sh FULL` passando localmente
- [ ] Tela aberta no browser e fluxo verificado contra o backend real
- [ ] Sem `console.log` ou código de debug (`enableLogs: true`)
- [ ] Documentação atualizada (se necessário)
- [ ] Branch atualizada com a master
- [ ] `harness/state/feature_list.json` e `progress.md` atualizados
Ajuste o checklist ao que o projeto realmente tem. Um template herdado de outro processo pode pedir itens como "Testes escritos ou atualizados" e "Pipeline passando" mesmo quando o projeto não tem suíte de testes nem
.gitlab-ci.yml(15). Checklist que pede o inexistente treina o time a marcar caixa sem ler. Se o projeto novo adotar CI, acrescente a linha; se não, não a coloque.
Configuração no GitLab
- Aprovação mínima de 1 reviewer
- Pipeline obrigatória para habilitar o merge — se houver pipeline
7. Code review
Para quem revisa
- Leia a descrição do MR antes do código — entenda o contexto primeiro
- Comente com intenção explícita:
-
nit:— sugestão menor, não bloqueia -
suggestion:— sugestão de melhoria, bloqueia num primeiro momento -
blocking:— precisa ser resolvido antes do merge
-
- Questione o porquê, não só o como
- Priorize lógica e segurança; estilo é papel do linter
- Aprove se o código está correto e legível. Não exija que seja idêntico ao que você faria
Para quem abre
- Responda todos os comentários antes de pedir re-review
- Não faça force push depois de abrir o MR — quebra o histórico de revisão
- Marque como resolvido ao aplicar a correção
- Atenda ao que o revisor observa, mesmo sendo pequeno — exceto em demanda urgente
O que observar
| Categoria | O que verificar |
|---|---|
| Baseline |
./init.sh passa; sem warning novo |
| Lógica | Edge cases não cobertos, condições incorretas |
| Curio | Caso de uso aberto antes do request; guarda por useRef; sem reabrir |
| Erro | Sem try/catch só para exibir mensagem — o global já notifica (07) |
| Segurança | Dado exposto, validação ausente, segredo commitado |
| Performance | Chamada redundante, request em loop, lista sem virtualização |
| Legibilidade | Nome autoexplicativo, função com responsabilidade única |
| Convenções |
_Prefixo, barrel atualizado, alias nos dois configs (13) |
8. Merge
- Só com pipeline verde — se houver pipeline
-
Closes #123no MR fecha a issue automaticamente
9. Fim de sessão
Depois do merge, feche o ciclo no harness (19):
-
harness/state/feature_list.json— status + evidência - Se chegou a
passing, copiar paraharness/global_feature_list.json -
harness/state/progress.md— o que foi feito, bloqueios, próximo passo -
harness/state/session-handoff.md— resultado da verificação
A próxima sessão precisa conseguir rodar ./init.sh imediatamente.
22 — Testes E2E com Playwright
22 — Testes E2E com Playwright
Guia para configurar Playwright do zero em projeto Vite + React. Consolidado a partir de uma configuração real, testada de verdade — as armadilhas na seção 11 aconteceram de fato, não são hipotéticas.
Sumário
- Conceitos essenciais do Playwright
- Instalação
- Estrutura de pastas sugerida
- Configuração (
playwright.config.ts) - Variáveis de ambiente
- Setup de login (sessão reutilizável)
- Fixtures + Page Object Model
- Exemplo de spec genérico
- Seed/cleanup de dados via API (opcional, avançado)
.gitignoree scripts dopackage.json- Decisões e armadilhas comuns
1. Conceitos essenciais do Playwright
O Playwright Test é um framework de testes ponta a ponta (E2E) com test runner, asserções, isolamento, paralelização e um conjunto rico de ferramentas. Suporta Chromium, Firefox e WebKit (Windows/Linux/macOS/CI), com emulação móvel nativa.
-
Isolamento em 3 camadas:
Browser(processo do navegador, caro, 1 por worker) →BrowserContext(cookies/localStorage/sessionStorage/cache isolados, barato, 1 por teste) →Page(aba dentro do contexto, não isola nada sozinha). -
Auto-waiting: antes de qualquer ação (
click,fill, etc.) o Playwright verifica actionability checks (visível, estável, habilitado...) e repete a checagem até passar ou estourar timeout — elimina a necessidade desleep().expect(locator).toBeVisible()é polling, não uma foto única;expect(await x.count()).toBe(n)é. -
Fixtures: injeção de dependência do Playwright (
page,context,browser,requestjá vêm nativas). SubstituibeforeEachrepetido por "pedir só o que a receita precisa". - Workers: cada worker é um processo do SO inteiro, com seu próprio browser; se um teste falha, o worker inteiro é descartado e recriado.
-
Paralelismo: por padrão, arquivos diferentes rodam em workers diferentes; testes do mesmo arquivo rodam em sequência no mesmo worker, a menos que
fullyParallel: true. -
Projects: configuração nomeada e independente (pode ter seu próprio browser/device/storageState/
testMatch). É a base do padrão de setup/cleanup usado abaixo.
Referência oficial de boas práticas: https://playwright.dev/docs/best-practices
2. Instalação
npm init playwright@latest
O instalador pergunta:
- TypeScript ou JavaScript;
- Nome do diretório de testes;
- Adicionar GitHub Actions workflow;
- Instalar os browsers do Playwright.
Dependências relevantes (via devDependencies):
"@playwright/test": "^1.62.1",
"dotenv": "^16.4.5",
"@types/node": "^24.13.3"
dotenv é necessário porque o playwright.config.ts lê variáveis de um .env próprio dos testes (credenciais de login E2E), separado da config runtime da aplicação.
Comandos de execução:
npx playwright test # roda a suíte
npx playwright test --ui # interface gráfica (recomendada pela doc oficial)
Extensão VS Code: instale a extensão oficial "Playwright Test for VSCode" — permite gravar interações (Record new) e gera o código do teste automaticamente a partir de cliques reais na aplicação.
3. Estrutura de pastas sugerida
tests/
├── base.ts # fixtures customizadas + decorator @step
├── e2e/
│ └── <dominio>/
│ └── <feature>/
│ ├── <Feature>TestPage.ts # Page Object da tela
│ └── <feature>.spec.ts # spec que usa a Page Object
├── setup/
│ ├── login.setup.ts # roda antes de tudo, gera sessão reutilizável
│ ├── seed.setup.ts # opcional — garante dados de apoio via API
│ └── cleanup.teardown.ts # opcional — apaga dados de apoio via API
└── support/
├── apiClient.ts # cliente HTTP/RPC isolado, sem depender de src/
└── seeders/
├── types.ts # contrato Seeder
├── registry.ts # lista ordenada de seeders
└── <dominio>/
├── <dominio>.config.ts
└── <dominio>.seeder.ts
O que importa reter dessa organização: um Page Object por tela testada, testes de setup/seed/cleanup como projects isolados (não como beforeAll/afterAll dentro do spec), e o cliente de API de teste separado de qualquer código de src/.
4. Configuração (playwright.config.ts)
import { defineConfig, devices } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, ".env") });
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [["junit", { outputFile: "results.xml" }], ["html", { open: "never" }]]
: "html",
expect: { timeout: 10_000 },
use: {
baseURL: "http://localhost:5173", // ajuste para a porta real do `vite dev`
trace: "on-first-retry"
},
projects: [
{
name: "login",
use: { ...devices["Desktop Chrome"] },
testMatch: /login\.setup\.ts/
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["login"],
testMatch: /.*\.spec\.ts/
}
// Adicione "seed"/"cleanup" como projects extras só se o projeto
// realmente precisar de dados de apoio via API (ver seção 9).
],
webServer: {
command: "npm run dev",
url: "http://localhost:5173", // igual ao baseURL acima
reuseExistingServer: !process.env.CI
}
});
Atenção à porta: é um erro comum apontar essa configuração para uma porta padrão (
localhost:3000) quando o Vite do projeto usa outra. Confirme a porta real donpm run devdo seu projeto antes de fixarbaseURL/webServer.url— é fácil de repetir.
Os projects login/chromium com dependencies são o mecanismo central: o Playwright sempre roda o project de login primeiro, e os specs (chromium) só começam depois que ele termina — sem precisar refazer login em cada teste.
5. Variáveis de ambiente
Crie um .env.example na raiz do projeto de testes (versionado) e um .env real (ignorado pelo git):
# Credenciais usadas pelo setup de login dos testes E2E (Playwright)
E2E_USER="USUARIO_DE_TESTE"
E2E_PASSWORD="SENHA_DE_TESTE"
6. Setup de login (sessão reutilizável)
tests/setup/login.setup.ts:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { test as setup, expect } from "@playwright/test";
const user = process.env.E2E_USER;
const password = process.env.E2E_PASSWORD;
if (user === undefined) throw new Error("E2E_USER não definido");
if (password === undefined) throw new Error("E2E_PASSWORD não definido");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sessionFilePath = path.resolve(__dirname, "../../playwright/.auth/sessionStorage.json");
setup("Setup Inicial de Login do Sistema", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(user);
await page.getByLabel("Senha").fill(password);
await page.getByRole("button", { name: "Entrar" }).click();
await expect(page.getByText("Nome do Sistema")).toBeVisible();
// Se a app usa sessionStorage (não localStorage) para guardar o token,
// `storageState()` nativo do Playwright não serve — precisa salvar na mão:
const sessionStorageData = await page.evaluate(() => JSON.stringify(window.sessionStorage));
fs.mkdirSync(path.dirname(sessionFilePath), { recursive: true });
fs.writeFileSync(sessionFilePath, sessionStorageData, "utf-8");
});
Se a aplicação alvo usa
localStorageem vez desessionStorage, use ostorageState()nativo do Playwright (context.storageState({ path })) — é mais simples e não exige oaddInitScriptmanual do passo 7. O caminho manual só é necessário quando o token fica emsessionStorage.
7. Fixtures + Page Object Model
tests/base.ts — fixture customizada (createTestPage) que injeta a sessão salva e fornece um decorator @step para nomear passos no relatório do Playwright:
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { test as base, expect, Page } from "@playwright/test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sessionFilePath = path.resolve(__dirname, "../playwright/.auth/sessionStorage.json");
type PageObjectClass<T> = new (page: Page) => T;
export const test = base.extend<{
createTestPage: <T>(PageObject: PageObjectClass<T>) => T;
}>({
page: async ({ page }, use) => {
if (fs.existsSync(sessionFilePath)) {
const sessionStorageData = fs.readFileSync(sessionFilePath, "utf-8");
await page.addInitScript((storage) => {
const entries = JSON.parse(storage);
for (const [key, value] of Object.entries(entries)) {
window.sessionStorage.setItem(key, value as string);
}
}, sessionStorageData);
}
await page.goto("/");
await use(page);
},
createTestPage: async ({ page }, use) => {
await use((PageObject) => new PageObject(page));
}
});
export { expect };
/** Decorator que envolve um método de Page Object num `test.step` nomeado. */
export function step(stepName?: string) {
return function decorator<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
function replacementMethod(this: This, ...args: Args): Return {
const name = `${stepName || (context.name as string)}`;
return test.step(name, async () => {
return target.call(this, ...args);
}) as Return;
}
return replacementMethod;
};
}
Todo spec deve importar test/expect deste arquivo, nunca direto de @playwright/test — é o que garante que a sessão salva seja injetada.
Page Object — uma classe por tela, cada teste vira um método público:
export class ItemTestPage {
constructor(private page: Page) {}
private async fillForm(name: string) {
// ... lógica de preenchimento
}
async setup() {
// ... navegação até a tela, pré-condições
}
async testPageLoad() {
// ... asserção de que a página carregou
}
async testCreate() {
// ... teste de criação
}
async testEdit() {
// ... teste de edição
}
async testDelete() {
// ... teste de exclusão
}
}
8. Exemplo de spec genérico
import { test } from "../../base";
import { ItemTestPage } from "./ItemTestPage";
test.describe("Testes para Cadastro de Item", () => {
test("Carregamento da página", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testPageLoad();
});
/**
* Testes em série — dependem do estado deixado pelo teste anterior.
* Fluxo: criar → editar → excluir.
*/
test.describe.serial("Testes de Fluxo do Usuário", () => {
test("Cadastro de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testCreate();
});
test("Edição de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testEdit();
});
test("Exclusão de Item", async ({ createTestPage }) => {
const itemPage = createTestPage(ItemTestPage);
await itemPage.setup();
await itemPage.testDelete();
});
});
});
test.describe.serial é o que garante a ordem e compartilha falha em cadeia — se "Cadastro" falhar, os demais são pulados em vez de rodar contra um estado inconsistente.
9. Seed/cleanup de dados via API (opcional, avançado)
Só vale a pena se os testes dependem de dados de apoio (ex.: uma entidade pai que precisa existir antes da tela ser testável) e você quer evitar que cada spec crie/apague seus próprios dados.
Padrão recomendado:
-
tests/support/apiClient.ts: abre uma sessão direto contra o backend (sem passar pelo código desrc/), autenticando comE2E_USER/E2E_PASSWORD. Só existe porque o carregador ESM do Node (usado pelo Playwright) não resolve certos imports profundos sem extensão que o bundler do Vite tolera — se o seu backend for uma API REST comum, umfetch/client HTTP simples resolve sem essa complicação. -
tests/support/seeders/types.ts: contrato comumSeeder(name,seedAll(),teardownAll()). -
tests/support/seeders/registry.ts: lista ordenada de seeders — a ordem importa quando um seeder depende de dado criado por outro. -
tests/setup/seed.setup.ts(projectseed, roda antes dos specs): iteraSEEDERSchamandoseedAll(). -
tests/setup/cleanup.teardown.ts(projectcleanup, comteardown: "cleanup"no project principal): iteraSEEDERSna ordem inversa chamandoteardownAll(), para respeitar dependências ao apagar.
No playwright.config.ts, isso vira:
projects: [
{ name: "login", testMatch: /login\.setup\.ts/, use: { ...devices["Desktop Chrome"] } },
{ name: "seed", testMatch: /seed\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["login", "seed"],
teardown: "cleanup",
testMatch: /.*\.spec\.ts/
},
{ name: "cleanup", testMatch: /cleanup\.teardown\.ts/ }
]
Se o projeto novo não tiver essa necessidade de dados de apoio compartilhados, pule esta seção inteira — é a parte mais específica/avançada deste setup, não um requisito do Playwright em si.
10. .gitignore e scripts do package.json
Adicionar ao .gitignore:
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
É comum não haver um script dedicado ("test": "playwright test") no package.json, rodando os testes via npx playwright test diretamente. Vale considerar adicionar esse script no projeto novo desde o início:
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}
11. Decisões e armadilhas comuns
-
Porta do
webServer/baseURLprecisa bater exatamente com a porta dovite dev— é um erro fácil de cometer e só aparece depois de já commitado. Confirme antes de bater o martelo. -
sessionStoragevslocalStorage: se a app-alvo guarda token/sessão emsessionStorage, ostorageState()nativo do Playwright não funciona — é preciso capturar manualmente compage.evaluateno setup e reinjetar compage.addInitScriptna fixture (seções 6 e 7). Se a app usalocalStorage, prefira o mecanismo nativo, é mais simples. -
Nunca importar
test/expectdireto de@playwright/testnos specs — sempre dotests/base.tslocal, senão a fixture de sessão não é aplicada. - Setup/seed/cleanup como projects separados, não como hooks dentro dos specs — isso é o que permite rodar uma vez só (login) ou uma vez por suíte inteira (seed/cleanup), em vez de repetir por arquivo/teste.
-
Teste em série (
test.describe.serial) só quando o fluxo realmente depende de estado anterior (criar → editar → excluir da mesma entidade); testes independentes devem ficar fora do bloco serial para poderem paralelizar.
UI
Componentes, layout/menu, rotas, formulários e tema.
08 — Componentes
08 — Componentes
A regra dos três arquivos, o catálogo de
common/e quando criar em vez de reaproveitar. Componente não chama caso de uso. Recebe dados e callbacks por props. Catálogo verificado: os componentes abaixo foram portados e testados de verdade (tsc/lint/build), não apenas lidos de uma implementação de referência — onde algo mudou na travessia, está marcado.
A regra dos três arquivos — só quando há conteúdo real
Todo componente mora em sua própria pasta:
src/components/common/FornecedorCard/
├── FornecedorCard.tsx o componente
├── FornecedorCard.styles.ts objetos sx / styled — SÓ se houver estilo real
└── index.ts re-export
// src/components/common/FornecedorCard/index.ts
export { default } from "./FornecedorCard";
export type { FornecedorCardProps } from "./FornecedorCard";
Depois atualize o barrel da pasta pai no mesmo commit:
// src/components/common/index.ts
export { default as FornecedorCard } from "./FornecedorCard";
Esquecer o barrel é a falha mais comum — leva a importes inconsistentes (uns via @components, outros
via caminho completo). Ver 03.
Não crie um .styles.ts vazio "por consistência". É comum achar, em código de referência,
componentes com um arquivo de estilo que só contém um comentário e uma função retornando {} — não
porte esse boilerplate.
Se o componente usa só sx inline pontual ou nenhum estilo próprio, ele tem dois arquivos
(.tsx + index.ts), não três. O terceiro arquivo nasce quando há uma constante SxProps<Theme>
real para exportar — ver 12.
Por que arquivo de estilo separado (quando existe)
Mantém o .tsx legível. Um componente com 200 linhas de JSX e 120 de sx inline é ilegível em
revisão.
Onde colocar
O componente é usado por quantas telas?
├─ 1 → src/pages/<Tela>/ (componente local, sem promover)
└─ 2+ ─ é agnóstico de domínio?
├─ sim → src/components/common/
└─ não → src/components/<Nome>/
Compõe o shell da aplicação (barra, menu, abas, moldura de página)? → src/components/layout/.
Não promova por antecipação. Um componente só sai de pages/ quando a segunda tela precisar dele.
Abstrair cedo demais produz props que ninguém usa.
Catálogo de common/
Componentes já portados e verificados. Antes de criar qualquer coisa, verifique se um destes resolve.
Entrada de dados
| Componente | Para que serve |
|---|---|
FormField |
TextField integrado ao react-hook-form, com toggle de senha |
SelectInput |
Select controlado; recebe SelectOption[]
|
DatePickerInput |
Data via <input type="date"> — ver gap abaixo
|
CheckboxGroup |
Grupo de checkboxes; recebe CheckboxOption[]
|
RadioButtonGroup |
Grupo de radios; recebe RadioOption[]
|
MaskedInput |
Base de máscara — CnpjInput/PhoneInput/CurrencyInput se apoiam aqui |
CnpjInput |
CNPJ com máscara e validação (utils/validators/Cnpj.ts) |
PhoneInput |
Telefone com máscara |
CurrencyInput |
Moeda; exporta parseCurrencyValue para converter de volta a número |
Exibição
| Componente | Para que serve |
|---|---|
DataTable |
Tabela com paginação, seleção de linhas e menu de contexto por linha (ContextAction[]) |
ResultadosCard |
Card com título para agrupar resultado de busca |
FiltroBusca |
Bloco de filtro padrão de tela de busca |
TransferListCard |
Lista de transferência (mover itens entre dois lados) |
TransferActions |
Botões de ação do TransferListCard
|
LoadingButton |
Botão com spinner durante ação assíncrona |
ActionModal |
Diálogo de confirmação com ação primária/secundária e estado de loading |
WelcomeCard |
Card de boas-vindas simples (título + descrição) |
PageContainer (título, breadcrumbs, ações, maxWidth) mora em src/components/layout/, não em
common/ — é moldura de página, não peça de formulário/exibição.
DataTableé a fusão de duas referências. É comum encontrar, em bases de código que evoluíram organicamente, uma versão simples e uma versão "avançada" quase idênticas de um mesmo componente de tabela (uma com seleção e menu de contexto, outra sem). Neste padrão, existe um sóDataTable— o superconjunto, comselectable/contextActions/getRowIdopcionais. Quem não precisa dessas props simplesmente não as passa; não há razão para manter os dois.
Componente domínio-neutro fora de
common/. É comum achar um componente estruturalmente genérico (ex.: um modal de confirmação) vivendo fora decommon/só porque nasceu para resolver um caso específico primeiro. Pela própria regra deste guia ("Onde colocar"), ele pertence acommon/— é onde está aqui. Ver anti-padrões em 13.
DatePickerInput não usa o @mui/x-date-pickers
O projeto depende de @mui/x-date-pickers e configura LocalizationProvider + AdapterDateFns em
main.tsx (03) — mas nenhum componente do catálogo
usa o <DatePicker> real da biblioteca. DatePickerInput é só um <input type="date"> (ou
datetime-local/time) por dentro de um TextField, com máscara e validação escritas à mão.
Isso funciona, mas significa que a dependência @mui/x-date-pickers está instalada e configurada
sem nenhum uso de referência. Se a primeira tela real precisar de um seletor de calendário visual
(não só um input nativo do browser), será a primeira vez que alguém usa o <DatePicker> da biblioteca
neste projeto — não existe exemplo para copiar. Escreva-o consultando a documentação do
@mui/x-date-pickers diretamente, e considere promovê-lo a um novo componente de common/ depois.
Assinaturas de referência
FormField — o mais usado:
interface FormFieldProps<T extends FieldValues> extends Omit<TextFieldProps, "name" | "error" | "helperText"> {
name: FieldPath<T>;
control: Control<T>;
label: string;
rules?: ControllerProps<T>["rules"];
showPasswordToggle?: boolean;
}
Estende TextFieldProps, então aceita size, fullWidth, disabled etc. sem redeclarar. error e
helperText são removidos porque vêm do fieldState do react-hook-form.
DataTable:
export interface Column<T = unknown> {
id: string;
label: string;
minWidth?: number;
align?: "right" | "left" | "center";
format?: (value: unknown, row: T) => React.ReactNode;
sortable?: boolean;
}
export interface ContextAction<T = unknown> {
id: string;
label: string;
icon?: React.ReactNode;
onClick: (row: T, index: number) => void;
disabled?: (row: T) => boolean;
divider?: boolean;
}
interface DataTableProps<T = unknown> {
columns: Column<T>[];
data: T[];
loading?: boolean;
error?: string | null;
page?: number;
rowsPerPage?: number;
totalCount?: number;
onPageChange?: (page: number) => void;
onRowsPerPageChange?: (rowsPerPage: number) => void;
onRowClick?: (row: T, index: number) => void;
emptyMessage?: string;
rowsPerPageOptions?: number[];
stickyHeader?: boolean;
maxHeight?: number | string;
// selecao e menu de contexto — opcionais, ignore se nao precisar
selectable?: boolean;
selectedRows?: T[];
onSelectionChange?: (selectedRows: T[]) => void;
getRowId?: (row: T) => string | number;
contextActions?: ContextAction<T>[];
showContextMenu?: boolean;
}
loading, error e emptyMessage são tratados dentro do componente. Não envolva DataTable em
condicional de loading — passe a flag. Sem selectable/contextActions, a tabela se comporta como a
versão simples — as props extras custam zero quando omitidas.
Para os demais, leia a interface no próprio arquivo. Não presuma props a partir do nome.
Como escrever um componente
// src/components/common/FornecedorCard/FornecedorCard.tsx
import React from "react";
import { Card, CardContent, Typography } from "@mui/material";
import { cardStyle } from "./FornecedorCard.styles";
export interface FornecedorCardProps {
nome: string;
cnpj: string;
ativo: boolean;
onSelecionar?: (cnpj: string) => void;
}
const FornecedorCard: React.FC<FornecedorCardProps> = ({ nome, cnpj, ativo, onSelecionar }) => {
const handleClick = () => onSelecionar?.(cnpj);
return (
<Card sx={cardStyle(ativo)} onClick={handleClick}>
<CardContent>
<Typography variant="h6">{nome}</Typography>
<Typography variant="body2" color="text.secondary">
{cnpj}
</Typography>
</CardContent>
</Card>
);
};
export default FornecedorCard;
// src/components/common/FornecedorCard/FornecedorCard.styles.ts
import { SxProps, Theme } from "@mui/material";
// depende do estado "ativo" — por isso funcao, nao constante
export const cardStyle = (ativo: boolean): SxProps<Theme> => ({
opacity: ativo ? 1 : 0.6,
cursor: "pointer"
});
Regras aplicadas:
- Interface de props exportada — outras telas precisam dela para tipar wrappers.
-
React.FC<Props>com props destruturadas na assinatura. - Callback de prop nomeado
on{Ação}; handler internohandle{Ação}— ver 13. -
export defaultdo componente; o barrel dá o nome. - Zero import de
@curio/client,useAuth,useCurioMutation. - Estilo em constante nomeada exportada, não objeto
styles.algo— ver 12.
Componente não decide o próprio posicionamento externo
// ERRADO — o card decide seu proprio tamanho de grid; quem usa nao tem escolha
const WelcomeCard = ({ title, description }) => (
<Grid item xs={12} sm={6}>
<Card>...</Card>
</Grid>
);
// CERTO — o card so sabe renderizar a si mesmo; o layout e responsabilidade de quem chama
const WelcomeCard = ({ title, description }) => <Card>...</Card>;
// quem usa decide o grid:
<Grid item xs={12} sm={6}>
<WelcomeCard title="Bem-vindo!" />
</Grid>;
Um componente reutilizável que embute sua própria posição num grid externo (<Grid item xs={...}>)
só funciona no layout onde foi escrito primeiro. É um erro comum em cards de boas-vindas/destaque; a
versão deste catálogo não faz isso.
Componente não chama backend
// ERRADO — componente comum acoplado a caso de uso
const FornecedorCard = ({ oid }) => {
const { data } = useBuscaFornecedor();
return <Card>{data?.Fornecedor._Nome}</Card>;
};
// CERTO — a pagina busca, o componente exibe
const FornecedorCard = ({ nome, cnpj }: FornecedorCardProps) => <Card>{nome}</Card>;
Exceção: componentes de layout/ podem consumir Context (useAuth, useTabs, useNotification).
São o shell, não peças reutilizáveis.
Genéricos
Componentes que recebem coleção tipada usam genérico com default:
function DataTable<T = unknown>({ columns, data }: DataTableProps<T>) { ... }
Permite <DataTable<FornecedorXML> columns={cols} data={fornecedores} /> com format tipado, e segue
funcionando sem anotação.
O que não copiar de uma implementação existente
Ao investigar código de referência, é comum achar pastas em components/ que parecem genéricas
mas na verdade não pertencem a um catálogo de padrão:
| Sinal | Por quê |
|---|---|
Nome carrega uma entidade concreta de domínio (ex.: Empresa/, FiltroXyzForm/) |
É um formulário/listagem específico daquele domínio, não um padrão |
| Estruturalmente genérico mas usado só numa tela específica (ex.: um card de dashboard) | O valor dele está no acoplamento àquela tela específica, não é reutilizável de verdade |
| Não é um componente de UI, e sim uma tela de demonstração/catálogo manual | Não pertence ao catálogo de componentes reutilizáveis |
Se uma tela nova precisar de algo parecido, escreva-o de novo neste projeto — não é o mesmo esforço de
copiar um componente genuinamente genérico como DataTable, porque o valor de um componente acoplado
está justamente no acoplamento à tela original dele.
Antes de criar um componente
- Existe em
common/? Use. - Existe no MUI? Use o MUI direto — não envolva
Buttonsó para trocar a cor padrão (isso é tema, ver 12). - Só uma tela usa? Deixe em
pages/. - Nenhuma das anteriores? Crie em
common/, com os arquivos que realmente precisar e o barrel atualizado.
09 — Layout e menu lateral
09 — Layout e menu lateral
O shell da aplicação:
AppBar,Drawercom menu em árvore, área de conteúdo.menuTree.tsé a fonte única do menu — nenhum item é escrito em JSX. A navegação é híbrida: cada folha do menu abre por rota ou por aba, e o shell suporta as duas ao mesmo tempo.
O shell
<Main> ← elemento da rota protegida
<TabsProvider>
<MainContent>
<AppBar> título + sair
<Drawer> menu a partir de menuTree
<Box component="main">
<TabBar /> abas abertas (some se não houver)
<Box> ← <Outlet/>: rota atual, visível quando activeTabId === null
{tabs.map(...)} ← painéis de aba, todos montados, só o ativo visível
Main é elemento da rota protegida em AppRouter (10). Por isso
TabsProvider pode usar useNavigate: já está dentro do router.
Os dois modos de navegação
| Modo | O que acontece ao clicar | Estado da tela | URL muda? |
|---|---|---|---|
"route" |
navigate("/" + path) e zera a aba ativa |
Perdido ao sair | Sim |
"tab" |
openTab(node) — painel novo, fica montado |
Preservado enquanto aberta | Não |
Escolha por tela:
-
"route"para tela de entrada e telas que se quer linkáveis/favoritáveis. Dashboard sempre. -
"tab"para telas de trabalho: cadastro, consulta, movimentação — onde o usuário alterna entre várias sem perder o que preencheu.
Por que abas preservam estado
Todos os painéis de aba ficam montados; só o ativo é visível (display: none nos demais). Um
formulário meio preenchido continua lá ao voltar. Isso tem duas consequências que não são
opcionais:
- O
UseCaseManagerda página usaautoClose={false}— o caso de uso permanece aberto no servidor enquanto a aba existir (06). - Cleanup de
useEffectnão dispara ao fechar a aba, porque o componente não desmonta por conta disso. Fechar a aba precisa ser explícito — veruseTabCloseCallback.
menuTree.ts
// src/routes/menuTree.ts
import React from "react";
import type { Session } from "@/lib/curio";
import { PATHS } from "./paths";
/** Como a folha do menu abre: navegando pela rota, ou numa aba. */
export type MenuOpenMode = "route" | "tab";
/**
* Modo usado quando o no nao declara `mode`.
* Troque para "route" se o produto nao usar abas.
*/
export const DEFAULT_OPEN_MODE: MenuOpenMode = "tab";
export interface MenuNode {
label: string;
icon?: React.ComponentType;
/** Só em folha (sem children) — caminho de rota sem barra inicial */
path?: string;
/** Só em nó pai (sem path) — grupo expansível */
children?: MenuNode[];
/** Sobrescreve DEFAULT_OPEN_MODE nesta folha */
mode?: MenuOpenMode;
/**
* Só em folha — executa em vez de abrir tela (relatório, disparo pontual).
* Tem precedência sobre `mode`. Ver "Itens de menu que executam ação".
*/
action?: (session: Session | undefined) => Promise<void>;
}
export const menuTree: MenuNode[] = [
{
label: "Dashboard",
path: PATHS.DASHBOARD,
mode: "route" // tela inicial vive na URL, nao numa aba
},
{
label: "Cadastro",
children: [
{ label: "Fornecedor", path: PATHS.CADASTRO.FORNECEDOR }, // usa o default
{ label: "Relatório mensal", path: PATHS.RELATORIOS.MENSAL, mode: "route" }
]
}
];
Um projeto que não quer abas troca DEFAULT_OPEN_MODE para "route" e não declara mode em
lugar nenhum. TabsProvider, TabBar e TabPageRenderer continuam no código, inertes — TabBar
retorna null sem abas. Nada a remover.
Invariantes
| Regra | Por quê |
|---|---|
Nó tem path ou children, nunca ambos |
MenuTreeItem decide por if (node.children); pai nunca navega |
action dispensa path
|
Nó de ação não navega — não precisa de rota |
mode só em folha |
Nó pai não abre nada, só expande |
path sempre de PATHS, nunca literal |
Renomear a rota em um lugar só |
label único entre irmãos |
É usado como key do React |
Toda folha existe também em routes.ts
|
O TabPageRenderer resolve o path na mesma tabela de rotas |
A última é a que mais quebra: uma folha em menuTree sem entrada em routes.ts abre uma aba com
"Rota não encontrada".
MenuTreeItem
// src/components/layout/Main/MenuTreeItem.tsx
import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Collapse, List, ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
import { ExpandLess, ExpandMore } from "@mui/icons-material";
import { DEFAULT_OPEN_MODE, type MenuNode } from "@/routes";
import type { Session } from "@/lib/curio";
import { useAuth } from "@context/AuthProvider";
import { useTabs } from "@context/TabsContext";
import { useHookMutation } from "@hooks/useHookMutation";
const noopAction = async () => undefined;
// renderizacao recursiva do menuTree.
// folha: executa action, ou abre por rota (<Outlet/>) ou por aba — conforme node.mode.
const MenuTreeItem: React.FC<{ node: MenuNode; depth: number }> = ({ node, depth }) => {
const navigate = useNavigate();
const location = useLocation();
const { session } = useAuth();
const { openTab, setActiveTab, activeTabId } = useTabs();
const [open, setOpen] = useState(false);
// hook antes do early return de nó pai — ordem de hooks nao pode variar
const { mutateAsync: executeAction, isPending } = useHookMutation<void, Session | undefined>(
node.action ?? noopAction
);
const Icon = node.icon;
const pl = (depth + 1) * 2; // indenta por nivel
if (node.children) {
return (
<>
<ListItemButton sx={{ pl }} onClick={() => setOpen((prev) => !prev)}>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
<ListItemText primary={node.label} />
{open ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
<Collapse in={open} unmountOnExit>
<List disablePadding>
{node.children.map((child) => (
<MenuTreeItem key={child.label} node={child} depth={depth + 1} />
))}
</List>
</Collapse>
</>
);
}
const mode = node.mode ?? DEFAULT_OPEN_MODE;
const handleClick = async () => {
// action tem precedencia: executa e nao navega
if (node.action) {
await executeAction(session);
return;
}
if (mode === "tab") {
openTab(node);
return;
}
// rota: precisa zerar a aba ativa, senao o painel dela continua por cima do <Outlet/>
setActiveTab(null);
navigate(`/${node.path}`);
};
// so destaca a rota atual quando nenhuma aba esta ativa
const isSelected = !node.action && mode === "route" && activeTabId === null && location.pathname === `/${node.path}`;
return (
<ListItemButton sx={{ pl }} selected={isSelected} disabled={isPending} onClick={handleClick}>
{Icon && (
<ListItemIcon>
<Icon />
</ListItemIcon>
)}
<ListItemText primary={node.label} />
</ListItemButton>
);
};
export default MenuTreeItem;
O setActiveTab(null) ao navegar por rota não é detalhe. Sem ele, o painel da aba ativa continua
visível e o <Outlet/> fica escondido atrás — o clique parece não fazer nada.
TabsContext
Guarda as abas, a ativa, e o registro de callbacks de fechamento.
| Membro | Para que serve |
|---|---|
tabs / activeTabId
|
Estado das abas |
openTab(node) |
Abre o nó numa aba nova; ignora nó sem path
|
closeTab(id) |
Dispara o callback registrado, remove a aba, escolhe a próxima ativa |
setActiveTab(id \| null) |
null devolve a tela ao <Outlet/>
|
warningOpen / dismissWarning
|
Aviso de limite de abas atingido |
registerCloseCallback / unregisterCloseCallback
|
Base do useTabCloseCallback
|
Decisões embutidas:
-
MAX_TABS = 8. Passar disso vira aviso, não aba. Cada aba mantém um caso de uso aberto no servidor — o limite protege o backend, não só a interface. -
Persistência em
localStorage("<projeto>:tabs"). As abas sobrevivem ao refresh; o estado interno das telas não — elas remontam vazias. Só a lista de abas é restaurada. -
Rótulo duplicado vira
(2). Duas abas da mesma tela se distinguem. - Fechar a última aba navega para o dashboard, evitando tela em branco.
TabPageRenderer
Resolve o path da aba na mesma tabela routes.ts que o router usa — não há registro paralelo de
telas.
// src/components/layout/TabPageRenderer/TabPageRenderer.tsx
const TabPageRenderer: React.FC<{ path: string }> = ({ path }) => {
const route = routes.find((r) => r.path === path);
if (!route) return <Box sx={{ p: 3 }}>Rota não encontrada: {path}</Box>;
const PageComponent = route.element;
const Layout = route.layout as React.FC<{ children?: React.ReactNode }> | undefined;
const page = (
<Suspense fallback={loadingFallback}>
<PageComponent />
</Suspense>
);
// layout de rota recebe a pagina como children (fora de aba ele usa <Outlet/>)
const content = Layout ? <Layout>{page}</Layout> : page;
return <TabErrorBoundary>{content}</TabErrorBoundary>;
};
Dois cuidados:
-
Suspensepor aba. As páginas sãolazy(); sem isso, abrir uma aba suspenderia o shell inteiro. -
ErrorBoundarypor aba. Erro numa aba não pode derrubar as outras. O boundary oferece "Tentar novamente" em vez de tela branca.
Layout em aba recebe
children, não<Outlet/>. Se você usalayoutemroutes.ts(10), o componente de layout precisa renderizarchildrene funcionar com<Outlet/>fora de aba. O mais simples é aceitarchildrenopcional e cair para<Outlet/>quando ele não vier.
Fechando o caso de uso da aba
Como o componente não desmonta ao fechar a aba, o close() do caso de uso precisa ser explícito:
// src/pages/Fornecedor/Cadastro/IncluirFornecedorPage.tsx
import { useUseCaseControls, useTabCloseCallback } from "@hooks/index";
const IncluirFornecedorContent: React.FC = () => {
const { open, close, status } = useUseCaseControls();
useTabCloseCallback(close); // encerra o caso de uso quando a aba fechar
// ...
};
useTabCloseCallback é no-op quando a página veio pelo <Outlet/> (sem aba) — a mesma página serve
aos dois modos sem if.
Esquecer isso vaza caso de uso no servidor: o usuário fecha a aba, o front esquece dela, e o backend segue com a sessão do caso de uso aberta até expirar.
Para a página se fechar sozinha (botão "Sair"):
const { closeTab } = useTabs();
const tabId = useCurrentTabId();
const handleSair = () => {
if (tabId) closeTab(tabId);
};
Registrar uma tela nova
Quatro passos, mesmo commit:
-
paths.ts— constante do caminho -
routes.ts— rota comlazy()eguard -
menuTree.ts— nó apontando para a constante, commodese diferir do default - Permissão — se o projeto tiver controle de acesso
Pular o 3 dá rota acessível só por URL (às vezes é o que se quer). Pular o 2 dá item de menu que abre 404 no modo rota, ou "Rota não encontrada" no modo aba.
Ícones
import { Business } from "@mui/icons-material";
{ label: "Fornecedor", path: PATHS.CADASTRO.FORNECEDOR, icon: Business }
Passe o componente, não o elemento (Business, não <Business />). Se adotar ícones, use em
todos os itens de primeiro nível — meio caminho fica pior que nenhum.
Menu, rota e permissão
| Registro | Arquivo | Responde a |
|---|---|---|
| Caminho | paths.ts |
Qual é a URL |
| Rota | routes.ts |
Que componente, sob qual guard |
| Menu | menuTree.ts |
Onde aparece e como abre |
| Permissão | backend | Quem pode ver/usar |
Itens de menu que executam ação
Uma folha pode executar uma função em vez de abrir tela — relatório que só gera um PDF, disparo pontual sem interface própria.
// src/routes/menuTree.ts
{
label: "Relatório de caixas abertas",
action: handleAbrirRelatorioCaixas
}
// src/pages/Caixa/service/handlers.ts
const CAIXAS_ABERTAS = { USE_CASE_ID: "2702", RM: "RM_OBTEM_CAIXAS_ABERTAS" };
export const handleAbrirRelatorioCaixas = async (session: Session | undefined) => {
if (!session) return;
const uc = await session.openUseCase(CAIXAS_ABERTAS.USE_CASE_ID);
try {
const result: ObtemRelatorioResponse = await uc.sendRequest(CAIXAS_ABERTAS.RM);
await handleOpenReport(result.URIRelatorio._);
} finally {
uc.abort(); // sempre fecha — nao ha tela dona deste caso de uso
}
};
const noopAction = async () => undefined;
// hook antes do early return de no pai — ordem de hooks nao pode variar
const { mutateAsync: executeAction, isPending } = useHookMutation<void, Session | undefined>(
node.action ?? noopAction
);
const handleClick = async () => {
// action tem precedencia: executa e nao navega
if (node.action) {
await executeAction(session);
return;
}
// ... modo tab / route
};
Quatro pontos que não são opcionais:
-
O hook fica antes do
if (node.children). Hook depois de early return muda a ordem entre renders e o React quebra. Daí onoopActionpara nós que não têmaction. -
actiontem precedência sobremode. Um nó comactionnão navega nem abre aba. -
isPendingdesabilita o item enquanto executa, evitando disparo duplo. -
Erro vira notificação pelo
mutationCacheglobal (07) — o handler não precisa detry/catchpara exibir mensagem, só dofinallyque fecha o caso de uso.
Tipagem.
actioné(session: Session | undefined) => Promise<void>. Declarar como(params: unknown) => Promise<void>exige um cast em cada nó do menu — não replique esse padrão.
Verificação
Uma implementação que só usa abas (com o dashboard tratado à parte por um if sobre string literal)
é um ponto de partida comum; o modo híbrido com MenuOpenMode é a generalização adotada aqui.
10 — Rotas e proteção
10 — Rotas e proteção
Três arquivos de dados (
paths.ts,routes.ts,menuTree.ts) e um de comportamento (AppRouter.tsx). Toda rota é declarada em tabela, nunca em JSX espalhado. Todo componente de página é carregado comlazy().
Separação dados / comportamento
| Pasta | Contém | Pode ter JSX? |
|---|---|---|
src/routes/ |
Dados (constantes) | Não |
src/router/ |
Montagem do router | Sim |
Isso permite consumir a tabela de rotas para outros fins (menu, breadcrumb, verificação de permissão) sem arrastar o React Router junto.
paths.ts
// src/routes/paths.ts
export const PATHS = {
LOGIN: "login",
DASHBOARD: "dashboard",
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor",
TIPO_FORNECEDOR: {
INCLUIR: "cadastro/tipo-fornecedor/incluir",
ALTERAR: "cadastro/tipo-fornecedor/alterar",
EXCLUIR: "cadastro/tipo-fornecedor/excluir"
}
},
RELATORIOS: {
MENSAL: "relatorios/mensal",
ANUAL: "relatorios/anual"
}
} as const;
Regras:
-
Sem barra inicial.
AppRouterconcatena (path={`/${path}`}). Uma barra a mais gera//rota. -
as constno final — dá tipos literais e impede mutação acidental. - Chaves em
SCREAMING_SNAKE_CASE; valores emkebab-case. - Aninhamento espelha a hierarquia de URL, e normalmente a do menu.
- Português nos segmentos de URL (é sistema interno em pt-BR). Escolha um idioma e mantenha — ver os anti-padrões em 13.
routes.ts
// src/routes/routes.ts
import { lazy } from "react";
import { PATHS } from "./paths";
import { RelatoriosLayout } from "@pages/Relatorios";
export type RouteGuard = "public" | "protected" | "auth-only";
export interface AppRoute {
path: string;
element: React.LazyExoticComponent<React.ComponentType>;
guard: RouteGuard;
/** Layout intermediário opcional entre o shell e a página */
layout?: React.ComponentType;
}
export const routes: AppRoute[] = [
{
path: PATHS.LOGIN,
element: lazy(() => import("@pages/Login")),
guard: "auth-only"
},
{
path: PATHS.DASHBOARD,
element: lazy(() => import("@pages/Dashboard")),
guard: "protected"
},
{
path: PATHS.CADASTRO.FORNECEDOR,
element: lazy(() => import("@pages/Cadastro/Fornecedor")),
guard: "protected"
},
{
path: PATHS.RELATORIOS.MENSAL,
element: lazy(() => import("@pages/Relatorios/Mensal")),
guard: "protected",
layout: RelatoriosLayout
}
];
export const NotFoundPage = lazy(() => import("@pages/NotFound"));
Guards
| Guard | Comportamento | Uso |
|---|---|---|
auth-only |
Só para não autenticados. Autenticado é redirecionado ao dashboard | Login |
public |
Acessível sempre, sem shell | Termos, ajuda |
protected |
Exige sessão; renderiza dentro do shell (Main) |
Todo o resto |
Não existe default: guard é obrigatório. Isso é proposital — esquecer torna a rota pública por
acidente, e o compilador impede.
Lazy sempre
element: lazy(() => import("@pages/Cadastro/Fornecedor"));
Toda página é lazy. Sem exceção — até a de login. Cada tela vira um chunk próprio; o bundle inicial
não cresce com o sistema. Por isso pages/<Tela>/index.ts com export default é obrigatório.
Layouts intermediários
layout insere um componente entre o shell e a página, para grupos de telas que compartilham
sub-navegação (abas internas, cabeçalho de seção). O AppRouter agrupa rotas por layout
automaticamente. Omita quando não houver.
AppRouter.tsx
// src/router/AppRouter.tsx
import React, { Suspense } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "@context/AuthProvider";
import ProtectedRoute from "@components/ProtectedRoute";
import LoadingScreen from "@components/LoadingScreen";
import Main from "@components/layout/Main";
import { routes, NotFoundPage, type AppRoute } from "@/routes";
// agrupa por layout pra montar uma <Route> pai por layout
function groupByLayout(routeList: AppRoute[]) {
return routeList.reduce<Map<React.ComponentType | null, AppRoute[]>>((map, route) => {
const key = route.layout ?? null;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(route);
return map;
}, new Map());
}
const AppRouter: React.FC = () => {
const { isAuth } = useAuth();
const authOnlyRoutes = routes.filter((r) => r.guard === "auth-only");
const publicRoutes = routes.filter((r) => r.guard === "public");
const protectedRoutes = routes.filter((r) => r.guard === "protected");
const protectedByLayout = groupByLayout(protectedRoutes);
return (
<Suspense fallback={<LoadingScreen />}>
<Routes>
<Route path="/" element={<Navigate to={isAuth ? "/dashboard" : "/login"} replace />} />
{authOnlyRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={isAuth ? <Navigate to="/dashboard" replace /> : <Element />} />
))}
{publicRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={<Element />} />
))}
<Route
element={
<ProtectedRoute>
<Main />
</ProtectedRoute>
}>
{[...protectedByLayout.entries()].map(([Layout, layoutRoutes]) => {
const children = layoutRoutes.map(({ path, element: Element }) => (
<Route key={path} path={`/${path}`} element={<Element />} />
));
if (!Layout) return <React.Fragment key="__root">{children}</React.Fragment>;
return (
<Route key={Layout.displayName ?? Layout.name} element={<Layout />}>
{children}
</Route>
);
})}
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
);
};
export default AppRouter;
Pontos que não são acidentais:
-
Um
<Suspense>na raiz cobre todos oslazy(). Não envolva página por página. -
Rotas protegidas ficam sob uma única
<Route>pai comProtectedRoute+Main— o shell não remonta ao navegar entre telas protegidas. -
path="*"por último captura o 404. -
/redireciona conformeisAuth.
ProtectedRoute
// src/components/ProtectedRoute/ProtectedRoute.tsx
import React from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "@context/AuthProvider";
import LoadingScreen from "@components/LoadingScreen";
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuth, isLoading } = useAuth();
const location = useLocation();
// enquanto reconecta por token, nao decidir ainda
if (isLoading) return <LoadingScreen />;
if (!isAuth) return <Navigate to="/login" state={{ from: location }} replace />;
return <>{children}</>;
};
export default ProtectedRoute;
O isLoading é essencial. Sem ele, um refresh de página redireciona para o login antes da
reconexão por token terminar. Foi por isso que App.tsx também exibe LoadingScreen enquanto
isLoading — ver 05.
state={{ from: location }} preserva o destino para redirecionar depois do login.
Adicionar uma rota
// 1. src/routes/paths.ts
CADASTRO: {
FORNECEDOR: "cadastro/fornecedor",
CLIENTE: "cadastro/cliente" // novo
}
// 2. src/routes/routes.ts
{
path: PATHS.CADASTRO.CLIENTE,
element: lazy(() => import("@pages/Cadastro/Cliente")),
guard: "protected"
}
// 3. src/routes/menuTree.ts
{ label: "Cliente", path: PATHS.CADASTRO.CLIENTE }
E crie src/pages/Cadastro/Cliente/index.ts com export default, senão o lazy() falha em runtime
sem erro de compilação.
Rotas com parâmetro
O padrão declarativo suporta parâmetro na string:
{
path: "cadastro/fornecedor/:oid",
element: lazy(() => import("@pages/Cadastro/Fornecedor/Detalhe")),
guard: "protected"
}
Na página, useParams(). Não coloque rota parametrizada no menuTree — não há valor de parâmetro
para navegar a partir do menu.
Com navegação por abas, rotas parametrizadas costumam ser dispensáveis: o contexto passa pelo
TabsContextem vez de pela URL. Se o projeto novo dispensar abas, rotas parametrizadas voltam a ser o caminho natural.
11 — Formulários e validação
11 — Formulários e validação
react-hook-form + zod, sempre via
useValidatedForm. Schema é a fonte única do formato: o tipo do formulário é inferido dele, nunca escrito à mão. Campos do formulário usam os mesmos nomes_Prefixodo backend.
useValidatedForm
// src/hooks/useValidatedForm.ts
import { useForm, UseFormProps, UseFormReturn } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
interface UseValidatedFormProps<T extends z.ZodType> extends Omit<UseFormProps<z.infer<T>>, "resolver"> {
schema: T;
}
export const useValidatedForm = <T extends z.ZodType>({
schema,
...formProps
}: UseValidatedFormProps<T>): UseFormReturn<z.infer<T>> =>
useForm<z.infer<T>>({
resolver: zodResolver(schema),
mode: "onChange", // valida em tempo real
...formProps
});
Use sempre este hook, nunca useForm direto. Ele garante o resolver e o mode: "onChange" uniformes.
mode: "onChange" valida a cada digitação. Para formulário grande e caro de validar, sobrescreva
para "onBlur" — o spread permite.
schemas.ts
Um arquivo por tela, ao lado do componente.
// src/pages/Fornecedor/Cadastro/schemas.ts
import { z } from "zod";
export const fornecedorSchema = z.object({
_Nome: z.string().min(1, "Informe o nome").max(120, "Máximo de 120 caracteres"),
_CNPJ: z
.string()
.min(1, "Informe o CNPJ")
.regex(/^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/, "CNPJ inválido"),
_Email: z.string().email("E-mail inválido").optional().or(z.literal("")),
_Ativo: z.boolean(),
_DataCadastro: z.string().min(1, "Informe a data")
});
export type FornecedorFormData = z.infer<typeof fornecedorSchema>;
export const filtroFornecedorSchema = z.object({
_Nome: z.string(),
_Ativo: z.boolean()
});
export type FiltroFornecedorData = z.infer<typeof filtroFornecedorSchema>;
Regras:
-
Nunca escreva a interface do formulário à mão. Sempre
z.infer<typeof schema>. Escrever as duas cria divergência silenciosa. - Toda regra tem mensagem em português — ela vai direto para a tela.
- Nomes de campo espelham o backend (
_Nome), evitando mapeamento no submit. - Um schema por formulário. Tela com dois formulários independentes tem dois schemas.
Campo opcional que aceita vazio
_Email: z.string().email("E-mail inválido").optional().or(z.literal(""));
.optional() sozinho não basta: um TextField limpo devolve "", não undefined, e "" falha na
validação de e-mail.
O formulário
Duas formas, ambas válidas.
Com FormField (preferível)
import { FormField } from "@components/common";
<FormField name="_Nome" control={form.control} label="Nome" size="small" fullWidth />;
Encapsula o Controller e liga error/helperText ao fieldState. Use quando o campo é um
TextField comum.
Com Controller (quando precisa do componente MUI cru)
<Controller
name="_Nome"
control={form.control}
render={({ field, fieldState }) => (
<TextField
{...field}
label="Nome"
size="small"
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
Mais verboso, mas necessário quando o componente não é um TextField ou exige props que o FormField
não repassa.
Evite usar
Controllerdireto quandoFormFieldbastaria — prefiraFormFielde reserveControllerpara os casos que realmente precisam do componente MUI cru.
Submit
const form = useValidatedForm({
schema: fornecedorSchema,
defaultValues: { _Nome: "", _CNPJ: "", _Ativo: true, _DataCadastro: dataHoje }
});
const handleSalvar = async (data: FornecedorFormData) => {
await salvarAsync({
Fornecedor: {
_OID: String(fornecedorData?.Fornecedor._OID ?? ""),
_Nome: data._Nome,
_CNPJ: data._CNPJ
},
msgSucesso: "Fornecedor salvo com sucesso!"
});
};
<Button onClick={form.handleSubmit(handleSalvar)}>Salvar</Button>;
form.handleSubmit(fn) só chama fn se a validação passar. Não valide manualmente antes — é o
que o resolver faz.
defaultValues é obrigatório
Sempre forneça defaultValues com todos os campos. Sem ele, o campo nasce não-controlado e vira
controlado na primeira digitação — o React emite warning e o reset() não limpa direito.
Reset
const handleNovoRegistro = () => {
form.reset(); // volta aos defaultValues
filtroForm.reset({ _Nome: "", _Ativo: true }); // valores explicitos
};
Inputs com máscara
CnpjInput, PhoneInput, CurrencyInput e DatePickerInput de common/ — ver
08.
Moeda precisa de conversão no submit:
import { CurrencyInput, parseCurrencyValue } from "@components/common";
const handleSalvar = (data: FormData) => {
salvar({ Fornecedor: { _Limite: parseCurrencyValue(data._Limite) } });
};
O campo guarda o texto formatado ("1.234,56"); o backend quer número. parseCurrencyValue faz a
ponte. Esquecer isso envia string e o backend rejeita.
Datas
O backend Curio espera data com tempo:
const dataFormatada = `${data._DataExpiracao}T00:00:00.0`;
O campo do formulário guarda "2026-08-18"; o request precisa de "2026-08-18T00:00:00.0". Faça a
conversão no handler, não no schema — o schema descreve o formulário, não o payload.
Para gerar a data de hoje no formato do input:
const hoje = new Date();
const dataHoje = `${hoje.getFullYear()}-${String(hoje.getMonth() + 1).padStart(2, "0")}-${String(hoje.getDate()).padStart(2, "0")}`;
Não use toISOString().split("T")[0] — converte para UTC e retorna o dia anterior à noite no
fuso brasileiro.
Validação vinda do servidor
O backend valida de novo. Um ValidationError do Curio traz detail: string[], já convertido em
notificação pelo queryClient (07).
Não tente espelhar toda regra de servidor no zod. O zod cobre o que dá para verificar no cliente (obrigatório, formato, tamanho); regra de negócio é do servidor.
Erros comuns
| Sintoma | Causa |
|---|---|
| "changing an uncontrolled input" | Faltou o campo em defaultValues
|
reset() não limpa |
Idem |
| Submit não dispara e nada aparece | Validação falhou num campo sem FormField/helperText visível |
| Backend rejeita a data | Faltou o sufixo T00:00:00.0
|
| Backend recebe moeda como texto | Faltou parseCurrencyValue
|
| Tipo do form diverge do schema | Interface escrita à mão em vez de z.infer
|
12 — Tema e estilo
12 — Tema e estilo
Um
createThemecentral, locale pt-BR, e a regra de quando usarsxversusstyled. Estilo repetido em duas telas vira tema; estilo repetido entre 2+ componentes viratheme/commonStyles.ts. Sem dark mode na referência — se o projeto precisar, é decisão do início.
O tema
// src/theme/index.ts
import { createTheme } from "@mui/material/styles";
import { ptBR } from "@mui/material/locale";
const palette = {
primary: { main: "#1976d2", light: "#42a5f5", dark: "#1565c0", contrastText: "#ffffff" },
secondary: { main: "#dc004e", light: "#ff5983", dark: "#9a0036", contrastText: "#ffffff" },
error: { main: "#f44336", light: "#e57373", dark: "#d32f2f", contrastText: "#ffffff" },
warning: { main: "#ff9800", light: "#ffb74d", dark: "#f57c00", contrastText: "#000000" },
info: { main: "#2196f3", light: "#64b5f6", dark: "#1976d2", contrastText: "#ffffff" },
success: { main: "#4caf50", light: "#81c784", dark: "#388e3c", contrastText: "#ffffff" }
};
export const theme = createTheme(
{
palette,
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif'
// escala h1..overline definida explicitamente — ver arquivo de referencia
},
shape: { borderRadius: 8 },
spacing: 8,
components: {
MuiButton: {
styleOverrides: {
root: { textTransform: "none", borderRadius: 8, fontWeight: 500, padding: "8px 16px" },
contained: {
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
"&:hover": { boxShadow: "0 4px 8px rgba(0,0,0,0.15)" }
}
}
},
MuiCard: {
styleOverrides: { root: { boxShadow: "0 2px 8px rgba(0,0,0,0.1)", borderRadius: 12 } }
},
MuiTextField: {
styleOverrides: { root: { "& .MuiOutlinedInput-root": { borderRadius: 8 } } }
},
MuiPaper: {
// remove gradiente de elevacao do MUI
styleOverrides: { root: { backgroundImage: "none" } }
}
}
},
ptBR // locale dos componentes MUI
);
export default theme;
O segundo argumento ptBR traduz textos internos do MUI (paginação, date pickers). É separado do
adapterLocale={ptBR} de date-fns em main.tsx — os dois são necessários e vêm de pacotes
diferentes:
import { ptBR } from "@mui/material/locale"; // textos dos componentes
import { ptBR } from "date-fns/locale"; // formatação de datas
sx vs styled vs arquivo de estilo
| Situação | Use |
|---|---|
| 1–2 propriedades pontuais |
sx inline |
| Bloco de estilo de um componente | Constante nomeada em Componente.styles.ts
|
| Estilo que depende de prop/estado | Função em .styles.ts que recebe o valor |
| Componente novo com estilo próprio e complexo |
styled() em .styles.ts
|
| Estilo repetido em 2+ componentes | src/theme/commonStyles.ts |
| Estilo repetido em 2+ telas via MUI slot | Tema (components.styleOverrides) |
sx inline — pouco e óbvio
<Typography variant="subtitle2" sx={{ mr: 2 }}>
Aceitável até ~2 propriedades. Acima disso, vai para o arquivo de estilos.
.styles.ts — uma constante nomeada por estilo
Convenção do padrão: cada estilo é uma constante exportada e nomeada, não uma propriedade de
um objeto styles. O nome descreve o que o estilo faz, sufixado com Style.
// src/pages/Cadastro/Fornecedor/FornecedorPage.styles.ts
import { SxProps, Theme } from "@mui/material";
export const outerBoxStyle: SxProps<Theme> = {
flexGrow: 1,
p: 3,
height: "calc(100vh - 64px)",
overflow: "hidden"
};
export const listLoadingStyle: SxProps<Theme> = {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%"
};
export const unselectedItemStyle: SxProps<Theme> = {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%"
};
import { listLoadingStyle, outerBoxStyle, unselectedItemStyle } from "./FornecedorPage.styles";
<Box sx={outerBoxStyle}>
<Box sx={listLoadingStyle}>...</Box>
</Box>;
Por que constantes nomeadas e não um objeto styles = { root, header, ... }:
-
O import fica explícito —
import { outerBoxStyle }mostra exatamente o que a tela usa; um objetostylesobriga a abrir o arquivo para saber quais chaves existem. -
Renomear é seguro. Renomear
styles.rootpara outra coisa exige revisar todo uso destyles.no arquivo; renomearouterBoxStyleé um rename de símbolo, com suporte de qualquer editor. -
Facilita promover para
commonStyles.ts— mover uma constante exportada para outro arquivo é um corte-e-cola; extrair uma chave de dentro de um objeto exige reescrever os dois lados.
Tipar como SxProps<Theme> dá autocomplete e valida os tokens.
Estilo dependente de estado — função nomeada
// src/components/layout/Main/Main.styles.ts
import { SxProps, Theme } from "@mui/material";
const DRAWER_WIDTH = 260;
export const mainRootStyle: SxProps<Theme> = {
display: "flex",
minHeight: "100vh"
};
// depende do estado do drawer — por isso funcao, nao constante
export const drawerStyle = (open: boolean): SxProps<Theme> => ({
width: open ? DRAWER_WIDTH : 0,
flexShrink: 0,
"& .MuiDrawer-paper": { width: DRAWER_WIDTH, boxSizing: "border-box" }
});
<Drawer sx={drawerStyle(drawerOpen)} />
Mesma regra: nome exportado, não uma chave de objeto. A única diferença é que o valor é uma função.
styled()
import { styled } from "@mui/material/styles";
import { Box } from "@mui/material";
export const PainelDestacado = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.grey[100]
}));
Use quando o resultado é um componente reutilizável, não um conjunto de props de estilo.
Sempre use tokens do tema
// ERRADO — valor solto
<Box sx={{ padding: "16px", color: "#1976d2", borderRadius: "8px" }} />
// CERTO — tokens
<Box sx={{ p: 2, color: "primary.main", borderRadius: 1 }} />
spacing: 8 significa que p: 2 = 16px. Mudar o espaçamento base reajusta o app inteiro; valores
soltos não acompanham.
Atalhos comuns: p/m (padding/margin), px/py, mt/mb/ml/mr, gap.
Estilo repetido vira commonStyles.ts ou tema
Uma constante de .styles.ts nasce local ao componente. Quando a segunda tela precisar do mesmo
estilo, promova-a — não copie a definição.
// src/theme/commonStyles.ts
import { SxProps, Theme } from "@mui/material";
// Estilos reaproveitados por mais de uma tela.
// Regra: nasce em <Componente>.styles.ts; move pra ca quando a 2a tela precisar.
/** Centraliza o conteudo nos dois eixos ocupando toda a altura disponivel. */
export const centeredFillStyle: SxProps<Theme> = {
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%"
};
/** Area de conteudo de uma tela dentro do shell — desconta a AppBar. */
export const outerBoxStyle: SxProps<Theme> = {
flexGrow: 1,
p: 3,
height: "calc(100vh - 64px)",
overflow: "hidden"
};
Quando o repetido é uma propriedade de um slot do MUI (todo TextField da aplicação, todo
Button), o lugar certo é o tema, não commonStyles.ts:
// src/theme/index.ts
components: {
MuiTextField: {
styleOverrides: { root: { "& .MuiOutlinedInput-root": { borderRadius: 8 } } }
}
}
O mesmo vale para defaultProps:
components: {
MuiTextField: { defaultProps: { size: "small", fullWidth: true } }
}
Isso elimina size="small" fullWidth repetido em cada campo. Defina defaultProps desde o
início — evita repetir as mesmas props em toda tela.
Regra de decisão:
| O que se repete | Vai para |
|---|---|
| Um layout específico (centralizar, área de conteúdo) | theme/commonStyles.ts |
| Uma propriedade de todo componente MUI de um tipo | theme/index.ts |
Tipografia
Use variant, não fontSize:
// ERRADO
<Typography sx={{ fontSize: "1.25rem", fontWeight: 400 }}>Título</Typography>
// CERTO
<Typography variant="h3">Título</Typography>
A escala está definida no tema. Se um tamanho não existe lá, ou você quer a variante errada, ou falta uma variante no tema.
Cores
Só da paleta:
<Typography color="text.secondary" />
<Box sx={{ bgcolor: "background.paper", borderColor: "divider" }} />
<Button color="primary" />
Nenhum hex fora de src/theme/index.ts. Se precisar de uma cor nova, adicione à paleta.
Dark mode
A referência não tem. Se o projeto novo precisar:
- Extraia a paleta para
lightedark. - Crie o tema com
modevindo de estado (useMediaQuery("(prefers-color-scheme: dark)")+ preferência do usuário emlocalStorage). - Envolva com
useMemopara não recriar a cada render. -
Auditar todo
sx— qualquer hex solto quebra no modo escuro.
Adotar depois é caro exatamente pelo passo 4. Decida no início.
Responsividade
Breakpoints do MUI dentro do sx:
<Box sx={{ display: { xs: "none", md: "block" }, p: { xs: 1, md: 3 } }} />
Se o projeto precisar de suporte a mobile, trate desde o começo — retrofitar layout responsivo é reescrever telas.
Este documento adota constantes nomeadas como padrão — uma constante por estilo, em vez de um objeto
styles = { root, drawer, ... }por arquivo — ver a comparação em ".styles.ts— uma constante nomeada por estilo" acima.
Backend (Curio)
Conexão e sessão Curio, caso de uso e React Query.
05 — Curio: conexão e sessão
05 — Curio: conexão e sessão
Como o front autentica, mantém e encerra a sessão com o backend Curio. Toda a integração com
@curio/clientestá confinada emsrc/lib/curio/esrc/api/session.ts. Nenhuma página importa@curio/clientpara configurar transporte.
Camadas
Página
└─ useAuth() src/context/AuthProvider.tsx ← o que a página usa
└─ useAuthQuery() src/hooks/useAuthQuery.ts estado via React Query
└─ login/connect src/api/session.ts operações de sessão
└─ getSessionManager() src/lib/curio/index.ts transporte
└─ @curio/client
Componentes usam useAuth(). Nunca useAuthQuery diretamente, nunca getSessionManager().
src/lib/curio/
Quatro arquivos.
index.ts — fábrica do transporte
// src/lib/curio/index.ts
import { V3RequestParser } from "@curio/client/parsers";
import { Session } from "./Session";
import { SessionManager } from "./SessionManager";
import { createV3Validator } from "@curio/client/validators";
import { FetchLink } from "@curio/client/links";
export interface Config {
service: { url: string; server: string; system: string; port: string };
accessToken: string;
resources: { get: string; put: string; viewer: string };
logs: boolean;
}
const createSessionManager = (configuration: Config) => {
const link = new FetchLink(configuration.service);
const requestParser = new V3RequestParser(configuration.service);
// pipeline: request -> parse -> json -> post -> valida
return new SessionManager(configuration.service, (request) =>
Promise.resolve(request)
.then(requestParser.parse)
.then(JSON.stringify)
.then(link.parse)
.then(link.post)
.then((response) => response.json())
.then(createV3Validator(request).validate)
);
};
export const getSessionManager = async () => {
const CONFIG_PATH = "./config.json";
const cnfg = await (await fetch(CONFIG_PATH)).json();
return createSessionManager(cnfg);
};
export type { Service as SV } from "./SessionManager";
export { Session, SessionManager };
export { isAuthError } from "./isAuthError";
export * from "./SessionManager";
getSessionManager()cria uma instância nova a cada chamada — não é singleton, apesar do nome. Isso é aceitável porque só é chamado emlogin,connecte em requests anônimos. Não chame dentro de componente ou hook de página. Para falar com o backend a partir de uma tela, use a sessão douseAuth()— ver 06.
Session.ts — a sessão do app
// src/lib/curio/Session.ts
import { MainUseCase } from "@curio/client";
export class Session extends MainUseCase {
module: number | undefined;
storageToken: string | undefined;
}
Estende MainUseCase só para carregar module e storageToken. Se o projeto precisar de mais estado
por sessão, é aqui.
SessionManager.ts — detecção de sessão morta
// src/lib/curio/SessionManager.ts
import { MainUseCase, RequestDriver, SecurityManager, UseCaseMessageType } from "@curio/client";
import { Session } from "./Session";
import { isAuthError } from "./isAuthError";
import { ABORT } from "@curio/client/utils/constants";
export interface ConfigService {
url: string;
server: string;
system: number;
port: number;
module: number;
version: number;
}
// chave unica por app — nunca reaproveitar de outro projeto
export const STORAGEKEY = "br.com.nomedoprojeto";
export class SessionManager extends SecurityManager {
public session: MainUseCase | undefined;
private _service: ConfigService;
private _driver: RequestDriver;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @curio nao tipa o service
constructor(service: any, driver: RequestDriver) {
super(service, driver);
this._service = service;
this._driver = driver;
}
public async openMainUseCase<U extends typeof MainUseCase>(username: string, password: string, constructor?: U) {
this.session = await super.openMainUseCase(username, password, constructor);
this._attachListeners();
return this.session! as InstanceType<U>;
}
public connectMainUseCase(mainUseCase: MainUseCase) {
this.session = mainUseCase;
this._attachListeners();
return super.connectMainUseCase(mainUseCase);
}
// sessao anonima: pre-login (ex. obter versao)
public anonymousSession() {
return new Session(0, this._service, this._driver);
}
private _attachListeners() {
this.session!.addListener(ABORT, () => this._unauthenticate(false));
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @curio nao tipa a mensagem
this.session!.addListener(UseCaseMessageType.RESPONSE, (message: any) => {
if (isAuthError(message.error)) this._unauthenticate(true);
});
}
private _unauthenticate(expired: boolean) {
this.session = undefined;
if (expired) localStorage.removeItem(STORAGEKEY);
}
}
STORAGEKEY identifica a sessão no storage. Troque por um valor próprio do projeto novo.
Cuidado com storage inconsistente. Se o token é escrito em
sessionStoragemas a limpeza remove delocalStorage(ou vice-versa), a limpeza não limpa nada — são storages diferentes. Escolha um e use o mesmo nos dois lugares. O snippet acima e o desession.tsabaixo usamsessionStoragede forma consistente — token de sessão não deve sobreviver ao fechamento da aba.
isAuthError.ts — o que conta como falha de autenticação
// src/lib/curio/isAuthError.ts
import { DestinataryNotFoundError } from "@curio/client/errors";
// code -1 = sessao inexistente no servidor
export const isAuthError = (error: unknown): boolean =>
error instanceof DestinataryNotFoundError || String((error as { code?: unknown } | null | undefined)?.code) === "-1";
Ponto único de decisão. Se o backend introduzir outro código de sessão inválida, muda só aqui.
src/api/session.ts
// src/api/session.ts
import { ConnectionError, DestinataryNotFoundError } from "@curio/client/errors";
import { Session, getSessionManager } from "../lib/curio";
import { STORAGEKEY } from "../lib/curio/SessionManager";
export interface LoginParams {
email: string;
password?: string;
}
export async function login(params: LoginParams) {
const { email, password } = params;
try {
const sessionManager = await getSessionManager();
const session = await sessionManager.openMainUseCase(email, password ?? "", Session);
session.module = 0;
session.storageToken = STORAGEKEY;
sessionStorage.setItem(STORAGEKEY, session.token.toString());
return session;
} catch (error) {
return error as Error;
}
}
// reconecta a partir do token guardado (refresh de pagina)
export async function connect(token: string) {
try {
const sessionManager = await getSessionManager();
sessionManager.connectMainUseCase(new Session(token, sessionManager.service, sessionManager.driver));
await sessionManager.session!.timeoutCheck();
const session = sessionManager.session! as Session;
session.module = 0;
session.storageToken = STORAGEKEY;
sessionStorage.setItem(STORAGEKEY, session.token.toString());
return session;
} catch (error) {
if (error instanceof DestinataryNotFoundError) return error;
if (error instanceof ConnectionError) return error;
return error as Error;
}
}
export async function logoutSession(session?: Session) {
if (session) session.abort();
sessionStorage.removeItem(STORAGEKEY);
}
logineconnectretornam o erro em vez de lançar. Quem chama precisa testarresult instanceof Error. É contraintuitivo e fácil de errar — se o projeto novo puder, prefira deixar lançar e tratar no hook.Cuidado com
localStorage.clear()no logout — ele apaga preferências de UI não relacionadas à sessão. O snippet acima remove só a chave da sessão.
Requests anônimos (pré-login)
Para chamar o backend antes de autenticar — por exemplo, exibir a versão do servidor na tela de login:
// src/api/session.ts
export const VERSION = { useCase: "3916", getVersion: "RM_OBTER_VERSAO" };
export async function obterVersaoRequest() {
const sessionManager = await getSessionManager();
const anonymousSession = sessionManager.anonymousSession();
const uc = await anonymousSession?.openUseCase(VERSION.useCase);
const response = await uc?.sendRequest(VERSION.getVersion);
await uc?.abort(); // sempre fechar — sessao anonima nao tem dono
return response.Versao._ as string;
}
AuthProvider
// src/context/AuthProvider.tsx (trecho essencial)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { notification } = useNotification();
const navigate = useNavigate();
const { session, isLoading, isAuth, login: authLogin, logout: authLogout, refetchConnection } = useAuthQuery();
// reconecta no boot se ha token guardado
useEffect(() => {
if (sessionStorage.getItem(STORAGEKEY)) refetchConnection();
}, []);
const logout = useCallback(() => {
authLogout();
navigate("/", { replace: true });
}, [authLogout, navigate]);
// fonte unica de logout: reage a notificacao de erro de auth.
// ref evita disparar 2x — logout muda de identidade a cada render.
const handledNotificationRef = useRef<NotificationType | null>(null);
useEffect(() => {
if (!notification) return;
const expiredSession = notification.message === "Sessão expirada!";
if (!notification.isAuthError && !expiredSession) return;
if (handledNotificationRef.current === notification) return;
handledNotificationRef.current = notification;
logout();
}, [notification, logout]);
const login = async (params: LoginCredentials) => {
await authLogin(params);
navigate("/dashboard");
};
return (
<AuthContext.Provider value={{ isAuth, session, isLoading, login, logout }}>{children}</AuthContext.Provider>
);
};
Ciclo de vida
| Evento | O que acontece |
|---|---|
| Boot com token |
refetchConnection() → connect(token) → sessão restaurada |
| Login |
login() → token no sessionStorage → navega para /dashboard
|
| Request com sessão morta |
isAuthError → notificação com isAuthError: true → AuthProvider desloga |
ABORT do servidor |
Listener no SessionManager limpa a sessão |
| Logout manual |
session.abort() → limpa storage → navega para /
|
Detecção de expiração por string
const expiredSession = notification.message === "Sessão expirada!";
Comparar mensagem literal é frágil: muda a tradução no backend, o auto-logout para de funcionar sem
erro visível. Preferível é o backend sinalizar com um código que isAuthError reconheça. Se
precisar manter, isole a string numa constante e documente a dependência.
06 — Caso de uso
06 — Caso de uso
Como uma tela fala com o backend Curio. Padrão vigente:
UseCaseManagerenvolve a página,useUseCaseControlsabre/fecha,useCurioMutationenvia requests. Não usesession.openUseCase()direto numa página. Esse é o caminho de baixo nível.
O modelo mental
O Curio não é REST. Não há endpoint por recurso. Há:
- Um caso de uso, identificado por um id numérico (
"2544"). Ele tem estado no servidor: você o abre, interage, e fecha. -
Requests nomeados dentro dele (
"RM_INCLUI_OBJETO"), que recebem e devolvem objetos.
Um caso de uso é aproximadamente "uma tela do sistema" do lado do servidor. Por isso o casamento natural é um caso de uso por página.
Página (UseCaseManager useCaseId="2544")
├─ useUseCaseControls() → open() / close() / status
└─ useCurioMutation("RM_INCLUI_OBJETO") → mutate() / mutateAsync()
Cadeia de chamada
Toda chamada ao backend segue a mesma cadeia de três camadas, sempre nesta ordem:
Componente (.tsx) → hook específico da feature (service/hooks.ts) → useCurioMutation
-
Componente — chama o hook da feature, nunca
useCurioMutationdireto. - Hook específico da feature — uma função de uma linha por request, tipada, nomeada pelo verbo do RM. É a única coisa que sabe qual string vai para o backend.
-
useCurioMutation— genérico, não sabe nada sobre Fornecedor. Ver 21.
// service/hooks.ts — o hook específico é a ÚNICA camada que conhece o nome do RM
export const useSalvaFornecedor = () => useCurioMutation<void, SalvaFornecedorRequest>(FORNECEDOR_RMS.SALVAR);
// Page.tsx — o componente so conhece o hook, nunca a string do RM
const { mutateAsync: salvar, isPending } = useSalvaFornecedor();
Nunca pule uma camada. Chamar useCurioMutation("RM_SALVA_OBJETO") direto no componente espalha o
nome do request por várias telas — renomear o RM no backend exige grep no projeto inteiro em vez de
editar uma linha.
Convenção _RMS: use case e requests num só objeto
Cada feature declara um objeto de constantes com o id do caso de uso e o nome de todos os seus requests. Um arquivo, uma fonte da verdade.
// src/pages/Cadastro/Fornecedor/service/constants.ts
export const FORNECEDOR_RMS = {
USE_CASE: "4821",
OBTEM_DADOS: "RM_OBTEM_DADOS_FORNECEDOR",
SALVAR: "RM_SALVAR_DADOS_FORNECEDOR",
REMOVER: "RM_REMOVER_FORNECEDOR"
};
Regras:
-
Nome do objeto:
{ENTIDADE}_RMS, maiúsculo, no plural de "requests do módulo" — não é o plural da entidade. -
USE_CASEé sempre a primeira chave. É o único valor que oUseCaseManagerda página consome. - Os demais valores são os nomes exatos dos requests, em
SCREAMING_SNAKE_CASEcom prefixoRM_no valor (a chave do objeto pode ser mais curta e legível —SALVAR, nãoSALVAR_DADOS_FORNECEDOR). -
Nenhuma string de RM ou de id de caso de uso literal fora deste arquivo. Nem em
hooks.ts, nem na página.
hooks.ts importa a constante em vez de repetir a string:
// service/hooks.ts
import { useCurioMutation } from "@/hooks";
import { FORNECEDOR_RMS } from "./constants";
import type {
SalvaFornecedorRequest,
BuscaFornecedoresRequest,
BuscaFornecedoresResponse,
FornecedorInicialResponse
} from "./interfaces";
export const useIncluiFornecedor = () => useCurioMutation<FornecedorInicialResponse, void>(FORNECEDOR_RMS.OBTEM_DADOS);
export const useBuscaFornecedores = () =>
useCurioMutation<BuscaFornecedoresResponse, BuscaFornecedoresRequest>(FORNECEDOR_RMS.SALVAR);
export const useRemoveFornecedor = () => useCurioMutation<unknown, { Fornecedor: string }>(FORNECEDOR_RMS.REMOVER);
E a página importa a mesma constante para o useCaseId do UseCaseManager — ver
"A página" abaixo. Um único arquivo muda quando o backend renumerar o caso de uso ou
renomear um request.
Anatomia de uma tela
Uma tela que fala com o backend tem quatro arquivos:
src/pages/Fornecedor/Cadastro/
├── IncluirFornecedorPage.tsx componente + UseCaseManager
├── schemas.ts validação zod
└── service/
├── constants.ts FORNECEDOR_RMS — useCaseId + nomes dos requests
├── interfaces.ts tipos de request/response
└── hooks.ts um hook por request
service/interfaces.ts
Tipa o que entra e sai do backend. Convenção _Prefixo — ver 13.
// src/pages/Fornecedor/Cadastro/service/interfaces.ts
export interface FornecedorXML {
_OID: string;
_Nome: string;
_CNPJ: string;
_Ativo: boolean;
Endereco?: EnderecoXML;
}
export interface EnderecoXML {
_OID: string;
_Logradouro: string;
_Cidade: string;
}
// resposta de abertura: backend devolve o objeto recem-criado
export interface FornecedorInicialResponse {
Fornecedor: FornecedorXML;
}
export interface SalvaFornecedorRequest {
Fornecedor: {
_OID: string;
_Nome: string;
_CNPJ: string;
Endereco?: { _OID: string };
};
}
export interface BuscaFornecedoresRequest {
OBJECTID: { _Nome: string; _Ativo: boolean };
}
export interface BuscaFornecedoresResponse {
Response: FornecedorXML[];
}
service/hooks.ts
Um hook por request. Uma linha cada.
// src/pages/Fornecedor/Cadastro/service/hooks.ts
import { useCurioMutation } from "@/hooks/useCurioMutation";
import { FORNECEDOR_RMS } from "./constants";
import {
BuscaFornecedoresRequest,
BuscaFornecedoresResponse,
FornecedorInicialResponse,
SalvaFornecedorRequest
} from "./interfaces";
export const useIncluiFornecedor = () => useCurioMutation<FornecedorInicialResponse, void>(FORNECEDOR_RMS.OBTEM_DADOS);
export const useBuscaFornecedores = () =>
useCurioMutation<BuscaFornecedoresResponse, BuscaFornecedoresRequest>(FORNECEDOR_RMS.SALVAR);
export const useSalvaFornecedor = () => useCurioMutation<void, SalvaFornecedorRequest>(FORNECEDOR_RMS.SALVAR);
Assinatura: useCurioMutation<TResposta, TParametros>(nomeDoRequest). Use void quando não há
parâmetros ou não há resposta útil.
A página
// src/pages/Fornecedor/Cadastro/IncluirFornecedorPage.tsx
import React, { useEffect, useRef } from "react";
import { Box, Button } from "@mui/material";
import { UseCaseManager } from "@curio/client/react";
import { useAuth } from "@/context/AuthProvider";
import { useUseCaseControls, useValidatedForm } from "@/hooks";
import { fornecedorSchema, type FornecedorFormData } from "./schemas";
import { FORNECEDOR_RMS } from "./service/constants";
import { useIncluiFornecedor, useSalvaFornecedor } from "./service/hooks";
const IncluirFornecedorContent: React.FC = () => {
const { open, status } = useUseCaseControls();
const { data: fornecedorData, mutate: incluirFornecedor, isPending: isIncluindo } = useIncluiFornecedor();
const { mutateAsync: salvarAsync, isPending: isSalvando } = useSalvaFornecedor();
// refs evitam reabrir/reinicializar em re-render
const hasAttemptedOpenRef = useRef(false);
const hasInitializedRef = useRef(false);
useEffect(() => {
if (status === "idle" && !hasAttemptedOpenRef.current) {
hasAttemptedOpenRef.current = true;
open();
} else if (status === "open" && !hasInitializedRef.current) {
hasInitializedRef.current = true;
incluirFornecedor();
}
}, [status, open, incluirFornecedor]);
const form = useValidatedForm({
schema: fornecedorSchema,
defaultValues: { _Nome: "", _CNPJ: "" }
});
const handleSalvar = async (data: FornecedorFormData) => {
await salvarAsync({
Fornecedor: {
_OID: String(fornecedorData?.Fornecedor._OID ?? ""),
_Nome: data._Nome,
_CNPJ: data._CNPJ
},
msgSucesso: "Fornecedor salvo com sucesso!"
});
};
return (
<Box>
<Button onClick={form.handleSubmit(handleSalvar)} disabled={isIncluindo || isSalvando}>
Salvar
</Button>
{/* campos — ver 11 */}
</Box>
);
};
const IncluirFornecedorPage: React.FC = () => {
const { session } = useAuth();
return (
<UseCaseManager session={session} useCaseId={FORNECEDOR_RMS.USE_CASE} autoClose={false} openOnMount={false}>
<IncluirFornecedorContent />
</UseCaseManager>
);
};
export default IncluirFornecedorPage;
Por que dois componentes
UseCaseManager é um provider. Os hooks useUseCaseControls e useCurioMutation consomem o contexto
dele, então precisam estar em um componente filho. O componente externo só faz o wiring; o conteúdo
real vive no Content.
Isso não é opcional. Chamar useCurioMutation no mesmo componente que renderiza UseCaseManager
falha em runtime.
Props do UseCaseManager
| Prop | Valor usual | Efeito |
|---|---|---|
session |
useAuth() |
Sessão autenticada |
useCaseId |
FORNECEDOR_RMS.USE_CASE |
Id do caso de uso no servidor |
autoClose |
false |
true fecha ao desmontar. Use false com navegação por abas |
openOnMount |
false |
false dá controle explícito da abertura via open()
|
Com openOnMount={false}, a página é responsável por chamar open() — daí o useEffect com o
hasAttemptedOpenRef.
useUseCaseControls
// src/hooks/useUseCaseControls.ts
export const useUseCaseControls = (options: UseUseCaseControlsOptions = {}) => {
const { enableLogs = false } = options;
const { open, close, status, error, triggersFromCurrentState } = useUseCaseManager();
const { setNotification } = useNotification();
// open() do curio lanca; aqui vira notificacao
const openSafe = useCallback(
async (openOptions?: OpenOptions) => {
try {
await open(openOptions);
} catch (err) {
const message = err instanceof Error ? err.message : "Erro ao abrir o caso de uso";
setNotification({ message, type: "error", isAuthError: isAuthError(err) });
}
},
[open, setNotification]
);
// ...
return { open: openSafe, close, status, error, logCurrentStateTriggers: logCurrentState };
};
status assume "idle" → "open". A máquina de estados típica da página é exatamente o useEffect
mostrado acima: abrir quando idle, inicializar quando open.
Descobrir os requests disponíveis
Passe enableLogs: true para logar, em desenvolvimento, os triggers que o caso de uso expõe no estado
atual:
const { open, status } = useUseCaseControls({ enableLogs: true });
Útil quando você não sabe o nome exato do request. Remova antes de commitar.
useCurioMutation
Envolve useMutation do React Query sobre o sendRequest do caso de uso.
const { data, mutate, mutateAsync, isPending, isError } = useCurioMutation<TData, TParams>("RM_NOME");
Mensagens de sucesso e erro
msgSucesso, msgErro e msgErroFallback são passados junto dos parâmetros e removidos antes de
chegar ao backend:
salvar({
Fornecedor: { _OID: "123", _Nome: "ACME" },
msgSucesso: "Fornecedor salvo com sucesso!",
msgErro: "Não foi possível salvar."
});
msgSucesso dispara notificação verde no sucesso. Para erro, a mensagem exibida segue uma ordem de
prioridade, decidida uma única vez no mutationCache.onError global (07) — não
no hook:
-
msgErro, se informado — sempre prevalece, mesmo que o backend tenha devolvido mensagem própria. É o dev pedindo explicitamente para sobrescrever. - Mensagem do backend (
error.message, ouerror.detailse forValidationError), se houver. -
msgErroFallback, se informado — só é usado quando o backend não devolveu mensagem alguma. - Fallback genérico interno do hook ("Ocorreu um erro ao processar a requisição.").
salvar({
Fornecedor: { _OID: "123", _Nome: "ACME" },
msgErroFallback: "Não foi possível salvar o fornecedor."
// se o backend devolver mensagem, ela aparece; msgErroFallback só entra se ele nao devolver nada
});
Omitir os três não silencia o erro — o queryClient global sempre notifica, no mínimo com o
fallback genérico.
Não trate erro dentro de um hook específico da feature. A prioridade acima é resolvida uma vez, no
mutationCache.onErrorglobal. UmonErrorlocal emuseCurioMutationduplicaria a notificação — foi exatamente esse bug que motivou consolidar a lógica num só lugar.
Callbacks
onSuccess, onError, onSettled e onMutate vão no mesmo objeto dos parâmetros, não num
segundo argumento:
salvar({
Fornecedor: { _OID: "123", _Nome: "ACME" },
onSuccess: () => setModalAberto(true),
onError: (error) => console.error(error)
});
Difere do React Query puro. useCurioMutation separa os callbacks dos parâmetros internamente.
Buscas genéricas: useSearcher
Para telas de busca sobre entidades que o backend expõe via operações genéricas (120 = buscar,
134 = obter contexto), sem caso de uso dedicado:
const { getcontext, search } = useSearcher<FiltrosBusca, ResultadoBusca>("465");
const contexto = await getcontext.mutateAsync();
const resultado = await search.mutateAsync({ _Nome: "ACME", _Ativo: true });
Usa a sessão do useAuth() diretamente, sem UseCaseManager. Use quando a tela é só filtro +
lista. Se houver estado no servidor (incluir, alterar, salvar), use UseCaseManager.
Contrato com o backend
O que o front precisa saber — o resto é responsabilidade de quem escreve o caso de uso no servidor.
| Aspecto | Contrato |
|---|---|
| Identificação | Id numérico como string ("4821") |
| Request | Nome em SCREAMING_SNAKE_CASE, prefixo RM_ (RM_SALVA_OBJETO) |
| Parâmetros | Objeto aninhado espelhando a entidade ({ Fornecedor: { _OID, _Nome } }) |
| Filtros | Frequentemente sob a chave OBJECTID
|
| Resposta | Objeto com a entidade na raiz ({ Fornecedor: {...} }) ou { Response: [...] }
|
| Primitivos | Prefixo _ (_Nome, _OID) |
| Objetos | Sem prefixo (Endereco, Documentos) |
| Datas | String ISO com tempo: "2026-08-18T00:00:00.0"
|
| Erro |
Error lançado pelo transporte; ValidationError traz detail: string[]
|
| Sessão morta |
DestinataryNotFoundError ou code === "-1" — ver 05
|
Não invente nomes de request. Eles vêm do caso de uso do servidor. Confirme com quem o implementou
ou use enableLogs.
Erros comuns
| Sintoma | Causa |
|---|---|
| Hook lança "fora do contexto" |
useCurioMutation no mesmo componente que renderiza o UseCaseManager
|
| Request roda antes do caso de uso abrir | Faltou aguardar status === "open"
|
| Caso de uso reabre a cada render | Faltou o useRef de guarda |
msgSucesso chega ao backend |
Versão do useCurioMutation sem o delete params.msgSucesso
|
| Data rejeitada pelo backend | Faltou o sufixo T00:00:00.0
|
07 — React Query
07 — React Query
Cache, tratamento global de erro e convenção de query keys. Versão 5.x (01) —
cacheTimechama-segcTimedesde a v5. Regra central: nenhuma página trata erro de request para exibir mensagem — e nenhum hook específico trata, também. A prioridade da mensagem é resolvida uma única vez, nomutationCache.onErrorglobal.
O QueryClient
// src/api/queryClient.ts
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import { ValidationError } from "@curio/client/errors";
import { isAuthError } from "@/lib/curio";
import { NotificationType } from "@context/NotificationProvider";
// mensagem do backend, se houver — sem fallback, quem chama decide o que fazer na ausencia
const getErrorMessage = (error: unknown): string | undefined => {
// ValidationError do curio traz lista de problemas
if (error instanceof ValidationError) return error.detail.join("\n");
return error instanceof Error && error.message ? error.message : undefined;
};
interface MutationVarsWithMessages {
msgErro?: string;
msgErroFallback?: string;
}
export const createQueryClient = (setNotification: (notification: NotificationType) => void) =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, // v5: cacheTime foi renomeado para gcTime
retry: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true
},
mutations: {
retry: false,
gcTime: 3 * 60 * 1000
}
},
queryCache: new QueryCache({
onError: (error) => {
const message = getErrorMessage(error) ?? "Ocorreu um erro ao buscar os dados.";
setNotification({ message, type: "error", isAuthError: isAuthError(error) });
}
}),
mutationCache: new MutationCache({
// fonte unica da mensagem de erro de mutation — useCurioMutation nao trata isso no
// proprio onError, senao a notificacao dispara duas vezes (aqui + lá).
onError: (error, variables) => {
const vars = variables as MutationVarsWithMessages | undefined;
// msgErro: o dev pediu pra sobrescrever qualquer mensagem do backend — sempre prevalece.
// sem msgErro: mensagem do backend; sem essa, msgErroFallback; sem essa, fallback interno.
const message =
vars?.msgErro ?? getErrorMessage(error) ?? vars?.msgErroFallback ?? "Ocorreu um erro ao processar a requisição.";
setNotification({ message, type: "error", isAuthError: isAuthError(error) });
}
})
});
export default createQueryClient;
Por que é uma fábrica e não uma constante
O QueryClient precisa do setNotification, que só existe dentro do NotificationProvider. Daí o
createQueryClient(setNotification) chamado em main.tsx dentro de useState(() => ...) — ver
03.
useState com inicializador de função, não useMemo: garante instância única mesmo sob StrictMode.
Defaults e o porquê
| Opção | Valor | Razão |
|---|---|---|
staleTime |
5 min | Dados corporativos mudam devagar; evita refetch a cada navegação |
gcTime |
10 min | Mantém dados ao voltar para uma tela (era cacheTime até a v4) |
retry |
false |
Retry sobre caso de uso com estado no servidor pode duplicar efeito |
refetchOnWindowFocus |
false |
Refetch ao alternar janela é ruído em app interno |
refetchOnReconnect |
true |
Voltar de queda de rede deve revalidar |
retry: false é a decisão mais importante. Não mude sem entender que um RM_SALVA_OBJETO
repetido pode gravar duas vezes.
Erro é global
O onError do QueryCache/MutationCache captura toda falha e converte em notificação —
inclusive as de useCurioMutation e useHookMutation. Isso vale tanto para a página quanto para o
hook específico da feature: nenhum dos dois deve ter seu próprio onError de notificação. Um
onError local que também chama setNotification dispara duas notificações para o mesmo erro — uma
do global, outra do local — porque o mutationCache.onError roda para toda mutation, sempre.
Consequência prática:
// ERRADO — duplica a mensagem: a global ja apareceu
const handleSalvar = async () => {
try {
await salvarAsync(payload);
} catch (error) {
setNotification({ type: "error", message: "Erro ao salvar" });
}
};
// CERTO — deixa o erro subir; global notifica
const handleSalvar = async () => {
await salvarAsync(payload);
setModalSucesso(true);
};
// CERTO — try/catch so pra controlar fluxo, sem notificar
const handleSalvar = async () => {
try {
await salvarAsync(payload);
setModalSucesso(true);
} catch {
// notificacao ja veio do global; aqui so nao abre o modal
}
};
Use try/catch para controlar fluxo, nunca para exibir mensagem de erro de request.
Para customizar a mensagem, use msgErro (sobrescreve sempre) ou msgErroFallback (só quando o
backend não devolve mensagem) no useCurioMutation (06) em vez de capturar.
Query keys
Array, do mais genérico ao mais específico:
["fornecedores"] // dominio inteiro
["fornecedores", "lista"] // uma colecao
["fornecedores", "lista", { ativo: true }] // colecao com filtro
["fornecedores", "detalhe", oid] // um item
Regras:
- Primeiro elemento é o domínio, sempre string.
- Segundo é a operação (
"lista","detalhe"). - Parâmetros que afetam o resultado entram na key. Se não entrarem, o cache serve dado errado.
- Nunca interpole (
`fornecedores-${oid}`) — impede invalidação por prefixo.
Centralize por domínio:
// src/pages/Fornecedor/service/queryKeys.ts
export const fornecedorKeys = {
all: ["fornecedores"] as const,
listas: () => [...fornecedorKeys.all, "lista"] as const,
lista: (filtros: FiltrosFornecedor) => [...fornecedorKeys.listas(), filtros] as const,
detalhe: (oid: string) => [...fornecedorKeys.all, "detalhe", oid] as const
};
Invalidar tudo do domínio: queryClient.invalidateQueries({ queryKey: fornecedorKeys.all }).
Query vs mutation
O Curio inverte a intuição de REST.
| Situação | Use | Por quê |
|---|---|---|
Request dentro de um UseCaseManager
|
mutation | Tem efeito no estado do caso de uso, mesmo "só lendo" |
| Busca disparada por botão | mutation | Ação do usuário, não dado que se auto-revalida |
| Dado que carrega sozinho e revalida | query | Ex.: lista do dashboard |
| Reconexão de sessão | query | Ver useAuthQuery
|
Por isso useCurioMutation e useSearcher usam useMutation, não useQuery. Não é engano.
Invalidação
const queryClient = useQueryClient();
const handleSalvar = async (data: FornecedorFormData) => {
await salvarAsync({ Fornecedor: { ...data }, msgSucesso: "Salvo!" });
await queryClient.invalidateQueries({ queryKey: fornecedorKeys.listas() });
};
Em telas com UseCaseManager, o padrão mais comum é refazer o request no próprio caso de uso
(buscarFornecedores() de novo) em vez de invalidar cache — o estado vive no servidor, não no cache.
Devtools
<ReactQueryDevtools initialIsOpen={false} /> está em main.tsx. É removido automaticamente do
bundle de produção pela própria biblioteca. Não condicione a import.meta.env.DEV manualmente.
Nomenclatura
| Tipo | Padrão | Exemplo |
|---|---|---|
| Query de coleção | use{Entidade}s |
useFornecedores |
| Query de item | use{Entidade} |
useFornecedor |
| Mutation de caso de uso | use{Verbo}{Entidade} |
useSalvaFornecedor |
| Mutation genérica | use{Verbo}{Entidade}Mutation |
useCreateFornecedorMutation |
Hooks de caso de uso usam o verbo em português, espelhando o nome do request do backend
(RM_SALVA_OBJETO → useSalvaFornecedor). Isso torna rastreável qual hook corresponde a qual request.