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=INACTIVEdevolvetotal=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_typecomo filtro no servidor — a API hoje só aceitasearch. 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 oCLAUDE.mddo 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
FilterBarno desktop eMobileFilterBarno 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. UmToggleButtonGroupcresce com o número de opções e não cabe em 390px além de três; oFilterSelectmostra 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.mdjá descreve para aFilterBar; 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-agenciesaceitastatuscomo filtro, aplicado no banco junto comsearche a paginação. Sem isso a RN1 não se cumpre nessa tela. -
RN10 — Filtro ausente não filtra.
statusomitido 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 | NonecomoQueryopcional noGET /partner-agencies, descendo até o repositório e entrando noWHEREjunto desearch(RN9/RN10). Ototalpassa a ser o do resultado filtrado, que é o que a RN2 exige. - Migration: nenhuma. O campo
statusjá existe no modelo. - Swagger:
descriptiondo parâmetro e menção no docstring do endpoint, como osearchjá tem. - Testes:
apps/api/tests/— filtro por status devolve só o status pedido;totalacompanha; omitir o parâmetro devolve tudo; combinarstatuscomsearchfunciona.
apps/web¶
pages/agencies/index.tsx— a maior parte da mudança:FilterBar+SearchFieldno desktop,MobileFilterBarno mobile (RN4)ToggleButtonGroup→FilterSelectde 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-inputeagencies-status-filter— este último já existe noToggleButtonGroupe é preservado noFilterSelect, 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¶
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=INACTIVEdevolve só inativas, e ototalé o das inativas. - [x] CA4. Omitir
statusdevolve o mesmo que antes da spec. - [x] CA5. As quatro listas têm campo de busca, nos dois viewports.
- [x] CA6. As quatro usam
FilterBarno desktop eMobileFilterBarno mobile. - [x] CA7. Nenhuma lista usa
ToggleButtonGroupcomo 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-testidagencies-status-filtercontinua respondendo. - [x] CA11. Nenhuma tela rola na horizontal em 375px.
- [x] CA12.
make testpassa, com os testes novos do filtro por status. - [x] CA13.
tsclimpo, sem apontamento novo.
8. Plano de implementação¶
Ordem obrigatória no monorepo: api → web → e2e.
- [x]
apps/api—statusno router → service → repository, com ototalfiltrado - [x]
apps/api— docstrings e Swagger do parâmetro novo - [x]
apps/api— testes emtests/: filtra, conta, omite, combina comsearch - [x]
apps/web—agencies/indexcomFilterBar/MobileFilterBar, busca eFilterSelect; 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 testetsc - [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
statusno 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¶
pytestdentro do container: 212 passaram (incluindo os 6 novos)tsclimpo;ruffsem apontamento novo nos arquivos tocados (os 4 restantes são pré-existentes, em linhas que a spec não encostou)/devremote-rebuilddeapieweb, e medição contrahttps://dev.garantora.com.br/app: filtrar "Inativa" leva a contagem de 2.357 a 0 nos dois viewports, e as quatro listas mostram buscalist-filters.spec.ts: 5 cenários, todos verdes