Pular para conteúdo

0023 — Padronizar os filtros das listas

Status CONCLUÍDA
Branch spec/0023-padronizar-filtros-das-listas
Criada em 2026-08-07
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

1.1 A Imobiliárias filtra errado, e a contagem mente

O filtro de status é client-side: agencies.filter((a) => a.status === statusFilter) sobre a página já carregada. Medido no dev-remote, com 2.357 imobiliárias:

Filtro Corpo da tabela Faixa da paginação
Todos 25 registros 1–25 de 2.357
Inativa vazio 1–25 de 2.357

A tabela não mostra nada e o rodapé diz 2.357. O usuário não tem como distinguir "não há inativas" de "não há inativas nesta página" — e a diferença importa, porque o filtro varre só os 25 registros carregados.

Correção de uma afirmação da primeira redação. Escrevi que a tela mostrava "1 linha" ao filtrar por Inativa, e que essa seria a única inativa da página 1. Medindo de novo depois de implementar: o seed não tem nenhuma imobiliária inativa (?status=INACTIVE devolve total=0), e a linha que contei era a de estado vazio da tabela. O defeito permanece o mesmo — filtro sobre a página carregada, contagem que não acompanha —, mas a ilustração estava errada.

É o mesmo defeito que o properties/index.tsx registra ter corrigido no próprio código: "Antes o tipo era filtrado no cliente, sobre a página carregada: a lista mostrava só os residenciais daquela página e a contagem mentia."

E piorou com a spec 0022. Antes o mobile carregava incrementalmente e acumulava registros, então filtrar no cliente varria mais dados; com paginação de 25, varre 25.

1.2 A Imobiliárias é a única lista sem busca

As outras três têm campo de busca; ela não. A API já suporta — /partner-agencies aceita search por nome ou CNPJ, documentado no próprio router. É a tela que não usa.

1.3 Cada tela monta a barra de filtros à sua maneira

Medido nos dois viewports:

Tela Busca Controle de filtro Barra
Imobiliárias não ToggleButtonGroup (Todos/Ativa/Inativa) própria, inline
Inquilinos sim — FilterBar / MobileFilterBar
Imóveis sim 2 FilterSelect (tipo, imobiliária) FilterBar / MobileFilterBar
Análise de Crédito sim 1 FilterSelect (status) FilterBar / MobileFilterBar

Três telas usam a peça compartilhada; a quarta tem desenho próprio. No mobile a diferença é visível: as três mostram busca de largura total, pílula de filtro e a contagem abaixo; a Imobiliárias mostra três botões segmentados com a contagem ao lado, e nenhuma busca.

2. Objetivo

Que filtrar uma lista seja a mesma operação em qualquer tela — mesmo lugar, mesmo controle, mesmo comportamento nos dois viewports — e que o filtro filtre a base, não a página que por acaso está carregada.

3. Fora de escopo

  • Definir quais filtros cada tela oferece. Isso é decisão de produto por tela. A spec padroniza o recipiente e o comportamento; Inquilinos continua só com busca, sem ganhar filtro para preencher espaço.
  • Filtro de tipo (PF/PJ) em Inquilinos. Exigiria renter_type como filtro no servidor — a API hoje só aceita search. Fica registrado como possibilidade, não como entrega.
  • Migrar a tabela da Imobiliárias para DataGrid. Ela ainda usa <Table> antigo; a convergência do desktop é a pendência que o CLAUDE.md do web já registra. Aqui muda a barra de filtros, não a tabela.
  • Filtros salvos, favoritos ou por URL. Nenhuma tela tem hoje; acrescentar é feature nova.
  • As listas de Equipe e Perfis de Acesso. Não estavam no pedido e usam <Table> antigo.

4. Regras de negócio

Comportamento

  • RN1 — Todo filtro filtra no servidor. Nenhuma lista filtra sobre a página carregada. Filtro client-side sobre lista paginada não é filtro: é um recorte do acaso, e faz a contagem mentir.

  • RN2 — A contagem reflete o filtro aplicado. Com "Inativa" selecionado, o total é o de inativas — não o da base inteira. É o que torna a RN1 verificável por quem usa.

  • RN3 — Trocar filtro volta à primeira página. Estar na página 4 e filtrar não pode abrir uma lista vazia. As três telas padronizadas já fazem; a regra passa a valer para as quatro.

Apresentação

  • RN4 — As quatro listas usam FilterBar no desktop e MobileFilterBar no mobile. São as peças compartilhadas que Inquilinos, Imóveis e Análise de Crédito já usam, e a referência do padrão de lista do design system.

  • RN5 — Filtro de opção é FilterSelect, não botão segmentado. Um ToggleButtonGroup cresce com o número de opções e não cabe em 390px além de três; o FilterSelect mostra o rótulo quando vazio e o valor quando escolhido, com largura constante.

  • RN6 — A busca vem primeiro e é o que cresce. É a regra que o ui-patterns.md já descreve para a FilterBar; passa a valer nas quatro.

  • RN7 — A contagem fica abaixo da barra, com o rótulo no singular quando for 1. "1 imobiliária", não "1 imobiliárias".

  • RN8 — A Imobiliárias ganha busca, por nome ou CNPJ. A API já entrega; era só a tela não pedir.

API

  • RN9 — /partner-agencies aceita status como filtro, aplicado no banco junto com search e a paginação. Sem isso a RN1 não se cumpre nessa tela.

  • RN10 — Filtro ausente não filtra. status omitido devolve todas, como hoje. O parâmetro é opcional e não muda o comportamento de quem já chama o endpoint.

5. Impacto por app

apps/api

  • Onde: api/v1/routers/partner_agencies.py, services/partner_agency_service.py, repositories/partner_agency_repository.py.
  • O quê: status: str | None como Query opcional no GET /partner-agencies, descendo até o repositório e entrando no WHERE junto de search (RN9/RN10). O total passa a ser o do resultado filtrado, que é o que a RN2 exige.
  • Migration: nenhuma. O campo status já existe no modelo.
  • Swagger: description do parâmetro e menção no docstring do endpoint, como o search já tem.
  • Testes: apps/api/tests/ — filtro por status devolve só o status pedido; total acompanha; omitir o parâmetro devolve tudo; combinar status com search funciona.

apps/web

  • pages/agencies/index.tsx — a maior parte da mudança:
  • FilterBar + SearchField no desktop, MobileFilterBar no mobile (RN4)
  • ToggleButtonGroup → FilterSelect de status (RN5)
  • a busca passa a ser enviada à API, com o mesmo debounce das outras telas
  • o agencies.filter(...) client-side sai; o status vai como parâmetro (RN1)
  • trocar filtro ou busca volta à página 0 (RN3)
  • pages/renters, pages/properties, pages/credit-analyses — conferir o alinhamento às RN3/RN6/RN7; a estrutura já é a certa.
  • data-testid: agencies-filter-bar, agencies-search-input e agencies-status-filter — este último já existe no ToggleButtonGroup e é preservado no FilterSelect, para não quebrar a suíte.
  • Responsividade & PWA: é o assunto da spec. Sem impacto no service worker além do rebuild.

apps/e2e

  • Spec: cenário que afere, nas quatro listas e nos dois viewports, que a barra existe com busca (RN4/RN6/RN8) e que a contagem acompanha o filtro (RN2). O cenário central é o defeito da seção 1.1: filtrar por "Inativa" na Imobiliárias e conferir que o total muda — hoje ele fica em 2.357.
  • Seeds: o seed atual tem imobiliárias ativas e inativas; confirmar na implementação que há ao menos uma inativa fora da primeira página, senão o cenário passa por acaso.

6. Contrato de API

GET /api/v1/partner-agencies?skip=0&limit=25&search=&status=INACTIVE
Authorization: Bearer <token>
{ "items": [ { "id": "uuid", "name": "…", "status": "INACTIVE" } ], "total": 42 }

status é opcional e aceita ACTIVE ou INACTIVE. Valor não reconhecido devolve 422, como os demais parâmetros validados. Omitido, não filtra (RN10).

7. Critérios de aceite

  • [x] CA1. Na Imobiliárias, filtrar por "Inativa" muda o total exibido — não fica em 2.357.
  • [x] CA2. O resultado do filtro inclui registros que não estavam na primeira página.
  • [x] CA3. GET /partner-agencies?status=INACTIVE devolve só inativas, e o total é o das inativas.
  • [x] CA4. Omitir status devolve o mesmo que antes da spec.
  • [x] CA5. As quatro listas têm campo de busca, nos dois viewports.
  • [x] CA6. As quatro usam FilterBar no desktop e MobileFilterBar no mobile.
  • [x] CA7. Nenhuma lista usa ToggleButtonGroup como filtro.
  • [x] CA8. Trocar filtro ou busca, estando na página 2 ou mais, volta à primeira.
  • [x] CA9. A contagem usa singular quando for 1.
  • [x] CA10. O data-testid agencies-status-filter continua respondendo.
  • [x] CA11. Nenhuma tela rola na horizontal em 375px.
  • [x] CA12. make test passa, com os testes novos do filtro por status.
  • [x] CA13. tsc limpo, sem apontamento novo.

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — status no router → service → repository, com o total filtrado
  • [x] apps/api — docstrings e Swagger do parâmetro novo
  • [x] apps/api — testes em tests/: filtra, conta, omite, combina com search
  • [x] apps/web — agencies/index com FilterBar/MobileFilterBar, busca e FilterSelect; sai o filtro client-side
  • [x] apps/web — conferir RN3/RN6/RN7 nas outras três
  • [x] docs/ui-patterns.md — a barra de filtros como padrão das listas
  • [x] apps/e2e — spec das quatro listas nos dois viewports
  • [x] make test e tsc
  • [x] /devremote-rebuild + medição nos dois viewports

9. Decisões em aberto

Nenhuma. As duas que abriam a spec foram resolvidas antes da escrita:

  • A mudança de API entra. Sem status no servidor, a padronização seria só aparência e o filtro continuaria mentindo — dentro de uma tela que passaria a parecer correta, o que é pior que o estado atual.
  • Padroniza-se o recipiente, não a lista de filtros. Quais filtros cada tela oferece segue sendo decisão de produto; Inquilinos continua só com busca.

10. Registro

O que entrou

apps/api — o filtro que faltava. status como Query opcional em GET /partner-agencies, descendo por service.list() até repo.search_with_count(), onde entra no WHERE do stmt e do count_stmt — é o segundo que faz a RN2 valer, e é o que se esquece. Validado como enum PartnerAgencyStatus, então valor desconhecido devolve 422 sem chegar ao banco. Seis testes novos em tests/test_partner_agency_status_filter.py.

apps/web — a barra compartilhada na quarta tela. agencies/index.tsx trocou o ToggleButtonGroup por FilterBar+SearchField+FilterSelect (desktop) e MobileFilterBar+FilterPill (mobile), ganhou busca com o mesmo debounce das outras, e perdeu o agencies.filter(...) client-side. As outras três não precisaram de mudança: RN3 (useEffect que zera a página), RN6 (busca primeiro) e RN7 (singular em 1) já valiam nas três — conferido arquivo por arquivo, não presumido.

docs/ui-patterns.md ganhou a seção Filtros das listas, com o motivo de o filtro nunca ser client-side sobre lista paginada.

O que divergiu

A ilustração do problema estava errada, e a correção está na seção 1.1. Escrevi que filtrar por "Inativa" mostrava "1 linha". Medindo depois de implementar: o seed não tem nenhuma imobiliária inativa — ?status=INACTIVE devolve total=0 — e a linha que contei era a de estado vazio. O defeito é o mesmo; a ilustração não era.

Isso mudou o e2e: um cenário que só filtrasse passaria com a lista vazia por qualquer motivo, inclusive pelo defeito. O teste passou a criar uma imobiliária inativa pela API antes de filtrar (POST /onboarding/partner-agencies + PATCH para INACTIVE), o que também cobre a CA2: recém-criada, ela fica fora da primeira página sem filtro.

Um data-testid fora do padrão apareceu no caminho. A Análise de Crédito publicava credit-analyses-search enquanto as outras três publicam <recurso>-search-input. Padronizado, com os 6 usos em credit-analyses-list.spec.ts e credit-analyses-mobile.spec.ts atualizados no mesmo passo. O agencies-status-filter foi preservado, como a CA10 exigia.

Duas armadilhas do próprio teste, ambas do tipo que passa despercebido: getByRole('option', { name: 'Ativa' }) casa também "Inativa" por substring — o Playwright recusou o seletor ambíguo, corretamente. E o viewport móvel subiu de 390 para 375px, que é o que a CA11 pede; medir em 390 e afirmar 375 seria afirmar o que não se mediu.

Verificação

  • pytest dentro do container: 212 passaram (incluindo os 6 novos)
  • tsc limpo; ruff sem apontamento novo nos arquivos tocados (os 4 restantes são pré-existentes, em linhas que a spec não encostou)
  • /devremote-rebuild de api e web, e medição contra https://dev.garantora.com.br/app: filtrar "Inativa" leva a contagem de 2.357 a 0 nos dois viewports, e as quatro listas mostram busca
  • list-filters.spec.ts: 5 cenários, todos verdes