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

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

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.

Fundação

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:

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:

  1. @hookform/resolvers v3 só aceita Zod ^3 — subir o Zod exige subir os resolvers para v5 (que por sua vez exige react-hook-form >= 7.55.0). Não é uma escolha independente.
  2. O hook genérico useValidatedForm<T extends z.ZodType> deixou de compilar: o output de z.ZodType em v4 não fecha automaticamente em FieldValues. A correção é dupla — apertar o bound do genérico para z.ZodType<FieldValues>, e no ponto de chamada do zodResolver, usar um cast de tipo (as any justificado + as unknown as Resolver<...>) porque o tipo interno Zod4Type do resolver não compõe com um schema genérico sem perder a inferência. Ver src/hooks/useValidatedForm.ts — o cast é só de tipo; zodResolver continua 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
Fundação

02 — Bootstrap

02 — Bootstrap

Do repositório vazio até npm run dev servindo 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 fnm não estiver avaliado no perfil do shell (fnm env), o Node ativo pode ser uma versão antiga e o setEnvironment.js falha em process.loadEnvFile. GUIs de Git tipicamente não herdam o perfil — por isso o pre-commit valida 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@^26 com Node >=24 em runtime é uma folga deliberada. A linha 26.x dos tipos é a mais recente disponível hoje; a linha 24.x parou em 24.13.3 (sem novas patches). Não houve conflito de tsc/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" }
}

prepare depende 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ó no tsconfig.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:

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.

Fundação

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:

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;
Fundação

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)
  1. npm run dev executa npm run env:dev antes do Vite.
  2. setEnvironment.js dev lê config/dev.json, aplica overrides do .env e escreve public/config.json.
  3. Em runtime, getSessionManager() faz fetch("./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 system e port como string mas a interface Config declarar number, o TypeScript não acusa nada — o JSON é lido via fetch, 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_TOKEN no 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:

Adicionar um ambiente novo

  1. Crie config/{nome}.json com todas as chaves.
  2. Adicione ao package.json:
    "env:{nome}": "node ./setEnvironment.js {nome}",
    "start:{nome}": "npm run env:{nome} && vite"
    
  3. Se o ambiente tiver build próprio, adicione "build:{nome}".

Segurança

Fundação

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:

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 logout precisa chamar logoutSession(session). Limpar sessionStorage e o cache não encerra a sessão no servidor — só session.abort() faz isso. É comum uma implementação de useAuthQuery omitir 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:

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:

  1. msgSucesso, msgErro, msgErroFallback viram notificação e são removidos do payload antes de ir ao backend (delete params.msgSucesso, etc.).
  2. A mensagem de erro segue prioridade fixa, resolvida no mutationCache.onError global (07), não dentro deste hook: msgErro (sempre prevalece) → mensagem do backend → msgErroFallback → fallback genérico interno. useCurioMutation não tem onError próprio — se tivesse, a notificação apareceria duas vezes.
  3. Callbacks no mesmo objeto dos parâmetros — onSuccess, onError, onSettled, onMutate convivem com os params num argumento só. Difere do React Query puro, onde seriam um segundo argumento.
  4. 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/msgErro dentro dos parâmetros ({ options: { ... } }) com o parâmetro tipado como unknown. Isso quebra quando o parâmetro é uma instância de classe como Session, e obriga a um cast em cada nó do menu. Aqui as mensagens são argumento de criação do hook e TParams é 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.

Qualidade e Processo

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:

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

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.

Qualidade e Processo

14 — Qualidade e pré-commit

14 — Qualidade e pré-commit

Três gates: vite-plugin-checker durante o dev, pre-commit no commit, npm run build no 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:

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/recommended do formato antigo (.eslintrc.json) desligava no-undef internamente ao resolver o extends; pegando o objeto de regras direto (tsPlugin.configs.recommended.rules) em flat config, esse desligamento não vem junto — sem repetir explicitamente, no-undef acusa falso positivo em tipos ambient do TS (EventListener, React usado só como tipo em .ts). O tsc já 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-deps como "warn", não "off". Os useEffect de 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-line justificado nesses poucos casos, não desligando a regra inteira.

eslint-plugin-react-hooks v7 trouxe regras novas — decida o alcance

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:

--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:

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 em package.json, rode npm run prepare e confirme com git 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.

  1. Os comandos usam . (projeto inteiro) em vez dos arquivos staged. O lint-staged passa a lista de arquivos como argumento, que aqui é ignorada. Funciona, mas fica lento e formata arquivos que você não tocou.
  2. 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 ., o lint-staged anexa 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.

Qualidade e Processo

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:

  1. lint — ESLint com zero warnings
  2. env:prod — gera public/config.json a partir de config/prod.json
  3. tsc — type-check completo (noEmit)
  4. vite build — bundle em dist/

build:homolog não roda o lint. É inconsistente com o build. 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:

  1. npm run build (ou build:homolog)
  2. Copiar o conteúdo de dist/ para o diretório publicado no IIS
  3. Ajustar dist/config.json se o destino diferir do ambiente do build
  4. 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

Qualidade e Processo

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.

Qualidade e Processo

17 — Primeira tela

17 — Primeira tela

Receita end-to-end: uma tela de busca + cadastro de Fornecedor, tocando todas as camadas. Copie, troque Fornecedor pela 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:

  1. Abre um caso de uso no backend
  2. Busca fornecedores por filtro
  3. Exibe o resultado em tabela
  4. Salva um fornecedor novo

Camadas tocadas: constants → interfaces → hooks → schemas → página → paths → routes → menuTree.

Pré-requisitos

Do backend, você precisa saber:

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
Qualidade e Processo

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:

  1. Contexto se perde entre sessões. Cada sessão redescobre o que a anterior já sabia.
  2. Dois devs pisam no mesmo trabalho. Ninguém vê o que o outro está fazendo.
  3. "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

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/

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/ e docs/superpowers/specs/, de um uso anterior da skill superpowers), esse conteúdo passa a viver em harness/<branch>/plans/ e harness/<branch>/specs/.

Nota de compatibilidade: a skill superpowers pode assumir o caminho antigo docs/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

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:

  1. Criar harness/guides/ e harness/_examples/ (nomes exatos acima — não invente variação).
  2. Copiar os templates para dentro de _examples/ (state/, handoffs/, user_preferences.example.md).
  3. Adicionar harness/user_preferences.md ao .gitignore — só ele.
  4. Escrever os guias a partir do código real do projeto.
  5. Gerar o init.sh com os comandos reais — ver 19.
  6. Inserir a seção de harness no CLAUDE.md — ver 16, incluindo o passo de resolver a branch atual e criar harness/<branch>/state/ a partir dos _examples/.
  7. 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

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.

Qualidade e Processo

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.sh que 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:

  1. pwd — confirme o diretório.
  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 mecânico, não preenchimento de conteúdo).
  3. Leia harness/user_preferences.md (se existir).
  4. Leia harness/<branch>/state/feature_list.json e progress.md (se existirem).
  5. git log --oneline -5.
  6. ./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

  1. Atualizar harness/<branch>/state/feature_list.json (status + evidência): se algo chegou a passing, mover a entrada de features para completed no mesmo arquivo — sem cópia para nenhum arquivo global, não existe mais (ver 18).
  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 repositório 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.

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:

  1. Adicione npm test ao bloco FULL do init.sh.
  2. Adicione "testes passando" à Definition of Done.
  3. Mantenha o item da verificação no browser — teste unitário não prova integração com o Curio.
Qualidade e Processo

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

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

7. Code review

Para quem revisa

Para quem abre

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

9. Fim de sessão

Depois do merge, feche o ciclo no harness (19):

  1. harness/state/feature_list.json — status + evidência
  2. Se chegou a passing, copiar para harness/global_feature_list.json
  3. harness/state/progress.md — o que foi feito, bloqueios, próximo passo
  4. harness/state/session-handoff.md — resultado da verificação

A próxima sessão precisa conseguir rodar ./init.sh imediatamente.

Qualidade e Processo

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

  1. Conceitos essenciais do Playwright
  2. Instalação
  3. Estrutura de pastas sugerida
  4. Configuração (playwright.config.ts)
  5. Variáveis de ambiente
  6. Setup de login (sessão reutilizável)
  7. Fixtures + Page Object Model
  8. Exemplo de spec genérico
  9. Seed/cleanup de dados via API (opcional, avançado)
  10. .gitignore e scripts do package.json
  11. 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.

Referência oficial de boas práticas: https://playwright.dev/docs/best-practices

2. Instalação

npm init playwright@latest

O instalador pergunta:

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 do npm run dev do seu projeto antes de fixar baseURL/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 localStorage em vez de sessionStorage, use o storageState() nativo do Playwright (context.storageState({ path })) — é mais simples e não exige o addInitScript manual do passo 7. O caminho manual só é necessário quando o token fica em sessionStorage.

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:

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

UI

Componentes, layout/menu, rotas, formulários e tema.

UI

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, com selectable/contextActions/getRowId opcionais. 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 de common/ só porque nasceu para resolver um caso específico primeiro. Pela própria regra deste guia ("Onde colocar"), ele pertence a common/ — é 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:

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

  1. Existe em common/? Use.
  2. Existe no MUI? Use o MUI direto — não envolva Button só para trocar a cor padrão (isso é tema, ver 12).
  3. Só uma tela usa? Deixe em pages/.
  4. Nenhuma das anteriores? Crie em common/, com os arquivos que realmente precisar e o barrel atualizado.
UI

09 — Layout e menu lateral

09 — Layout e menu lateral

O shell da aplicação: AppBar, Drawer com 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:

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:

  1. O UseCaseManager da página usa autoClose={false} — o caso de uso permanece aberto no servidor enquanto a aba existir (06).
  2. Cleanup de useEffect não dispara ao fechar a aba, porque o componente não desmonta por conta disso. Fechar a aba precisa ser explícito — ver useTabCloseCallback.

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:

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:

Layout em aba recebe children, não <Outlet/>. Se você usa layout em routes.ts (10), o componente de layout precisa renderizar children e funcionar com <Outlet/> fora de aba. O mais simples é aceitar children opcional 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:

  1. paths.ts — constante do caminho
  2. routes.ts — rota com lazy() e guard
  3. menuTree.ts — nó apontando para a constante, com mode se diferir do default
  4. 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

O menu não filtra por permissão. Todos os nós aparecem para qualquer usuário autenticado e o backend recusa a operação. Para esconder itens, acrescente permissao?: string ao MenuNode e filtre na renderização — é trabalho novo, não vem pronto.

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
  }
};

O MenuTreeItem executa via useHookMutation (21):

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:

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.

UI

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 com lazy().

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:

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:

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 TabsContext em vez de pela URL. Se o projeto novo dispensar abas, rotas parametrizadas voltam a ser o caminho natural.

UI

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 _Prefixo do 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:

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 Controller direto quando FormField bastaria — prefira FormField e reserve Controller para 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
UI

12 — Tema e estilo

12 — Tema e estilo

Um createTheme central, locale pt-BR, e a regra de quando usar sx versus styled. Estilo repetido em duas telas vira tema; estilo repetido entre 2+ componentes vira theme/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, ... }:

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:

  1. Extraia a paleta para light e dark.
  2. Crie o tema com mode vindo de estado (useMediaQuery("(prefers-color-scheme: dark)") + preferência do usuário em localStorage).
  3. Envolva com useMemo para não recriar a cada render.
  4. 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.

Backend (Curio)

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/client está confinada em src/lib/curio/ e src/api/session.ts. Nenhuma página importa @curio/client para 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 em login, connect e 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 do useAuth() — 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 sessionStorage mas a limpeza remove de localStorage (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 de session.ts abaixo usam sessionStorage de 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);
}

login e connect retornam o erro em vez de lançar. Quem chama precisa testar result 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.

Backend (Curio)

06 — Caso de uso

06 — Caso de uso

Como uma tela fala com o backend Curio. Padrão vigente: UseCaseManager envolve a página, useUseCaseControls abre/fecha, useCurioMutation envia requests. Não use session.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á:

  1. Um caso de uso, identificado por um id numérico ("2544"). Ele tem estado no servidor: você o abre, interage, e fecha.
  2. 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
// 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:

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:

  1. msgErro, se informado — sempre prevalece, mesmo que o backend tenha devolvido mensagem própria. É o dev pedindo explicitamente para sobrescrever.
  2. Mensagem do backend (error.message, ou error.detail se for ValidationError), se houver.
  3. msgErroFallback, se informado — só é usado quando o backend não devolveu mensagem alguma.
  4. 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.onError global. Um onError local em useCurioMutation duplicaria 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
Backend (Curio)

07 — React Query

07 — React Query

Cache, tratamento global de erro e convenção de query keys. Versão 5.x (01) — cacheTime chama-se gcTime desde 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, no mutationCache.onError global.

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:

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.