# Tasks: Filtro de Busca Avançada — FilterSidebar **Input**: Design documents from `/specs/024-filtro-busca-avancada/` **Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/api-catalog-enhancements.md ✅ **Branch**: `024-filtro-busca-avancada` ## Format: `[ID] [P?] [Story?] Description` - **[P]**: pode ser executada em paralelo (arquivos distintos, sem dependência de tarefa incompleta) - **[Story]**: a qual user story pertence (`US1`, `US2`, `US3`) - Caminhos exatos incluídos em cada tarefa --- ## Phase 1: Foundational — Backend `property_count` **Purpose**: Enriquecer os três endpoints de catálogo com `property_count` calculado via COUNT dinâmico (SQLAlchemy subquery). Sem migration — campo somente-leitura calculado em tempo de execução. Este é o pré-requisito de US3; US1 e US2 podem prosseguir em paralelo independentemente desta fase. **⚠️ BLOQUEANTE para US3**: US3 não pode ser iniciada até T004 estar completo. - [X] T001 Adicionar `property_count: int = 0` às classes `PropertyTypeOut`, `CityOut` e `NeighborhoodOut` em `backend/app/schemas/catalog.py` - **Done**: Os três schemas Pydantic possuem o campo `property_count: int = 0` como atributo opcional de saída; `PropertyTypeOut.model_rebuild()` continua presente após a mudança; testes de serialização passam com o campo default. - [X] T002 Atualizar `list_property_types()` em `backend/app/routes/catalog.py` para calcular `property_count` por subtype via subquery SQLAlchemy (`func.count + outerjoin + group_by`) e injetar no dict serializado - **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; query plana de subtypes (`parent_id IS NOT NULL`) com `outerjoin(Property, (Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True)))` + `group_by(PropertyType.id)`; construir `count_map: dict[int, int]`; substituir o `jsonify` atual por `serialize_category()` que injeta `property_count` em cada subtype via `count_map.get(sub["id"], 0)`; tipos pai mantêm `property_count: 0`. - **Done**: `GET /api/v1/property-types` retorna cada subtype com `property_count >= 0`; tipos pai retornam `property_count: 0`; resposta é válida com ou sem imóveis ativos. - [X] T003 [P] Atualizar `list_cities()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by` - **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; substituir `City.query.order_by(...)` por `db.session.query(City, func.count(Property.id).label("cnt")).outerjoin(Property, (Property.city_id == City.id) & (Property.is_active.is_(True))).group_by(City.id).order_by(City.state, City.name).all()`; retornar `{**CityOut.model_validate(city).model_dump(), "property_count": cnt}`. - **Done**: `GET /api/v1/cities` retorna cada cidade com `property_count >= 0`; cidades sem imóveis retornam `0`; ordenação `state ASC, name ASC` mantida. - [X] T004 [P] Atualizar `list_neighborhoods()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by` - **Detalhes**: substituir `Neighborhood.query` por `db.session.query(Neighborhood, func.count(Property.id).label("cnt")).outerjoin(Property, (Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))).group_by(Neighborhood.id)`; preservar filtro `?city_id` existente aplicado antes de `.all()`; retornar `{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}`. - **Done**: `GET /api/v1/neighborhoods` e `GET /api/v1/neighborhoods?city_id=N` retornam bairros com `property_count >= 0`; filtro `city_id` ainda funciona. **Checkpoint Phase 1**: Os três endpoints retornam `property_count` correto. Validar manualmente com `curl http://localhost:5000/api/v1/cities` e confirmar campo presente na resposta JSON. --- ## Phase 2: Foundational — Frontend Types **Purpose**: Espelhar o campo `property_count` do backend nos tipos TypeScript. Backward-compatible (campo opcional). Pré-requisito de US3 no frontend. - [X] T005 [P] Adicionar `property_count?: number` às interfaces `PropertyType`, `City` e `Neighborhood` em `frontend/src/types/catalog.ts` - **Detalhes**: campo opcional (`?`) para backward-compatibility — componentes que não usam o campo continuam compilando sem alteração. - **Done**: `frontend/src/types/catalog.ts` compila sem erros (`tsc --noEmit`); as três interfaces possuem `property_count?: number`; nenhum componente existente quebra. **Checkpoint Phase 2**: `tsc --noEmit` passa. T005 pode ser executada em paralelo com a Phase 1 inteira. --- ## Phase 3: US2 — Estado Inicial Controlado das Seções (Priority: P1) **Goal**: Ao carregar `/imoveis`, apenas a seção "Preço" está expandida. Seções com filtros ativos na URL são auto-expandidas. Estado não persistido entre sessões. **Independent Test**: Carregar `/imoveis` sem parâmetros de URL e verificar que somente a seção "Preço" está expandida; todas as demais (Imobiliária, Localização, Tipo, Quartos, Área, Comodidades) estão colapsadas. **Dependências**: nenhuma tarefa anterior é bloqueante para US2. - [X] T006 [US2] Declarar o tipo `SectionKey` localmente em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: adicionar antes da definição de `Section`; valores: `'imobiliaria' | 'localizacao' | 'tipo' | 'preco' | 'quartos' | 'area' | 'comodidades'`; não exportar. - **Done**: tipo `SectionKey` declarado no arquivo; sem erro de compilação. - [ ] T007 [US2] Implementar a função pura `initOpenSections(filters: PropertyFilters): Record` localmente em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: `preco: true` sempre; `imobiliaria: filters.imobiliaria_id != null`; `localizacao: filters.city_id != null || filters.neighborhood_id != null`; `tipo: filters.subtype_id != null`; `quartos: filters.bedrooms_min != null || filters.bathrooms_min != null || filters.parking_min != null`; `area: filters.area_min != null || filters.area_max != null`; `comodidades: (filters.amenity_ids?.length ?? 0) > 0`. - **Done**: função declarada antes do componente; retorna objeto com todas as 7 chaves; `preco` sempre `true`. - [X] T008 [US2] Converter o sub-componente `Section` para suportar modo controlled em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: adicionar props opcionais `open?: boolean` e `onToggle?: () => void` à interface do componente; quando `open !== undefined`, usar `open` como state do accordion e chamar `onToggle` no click do botão; quando `open === undefined`, manter comportamento atual via `useState(defaultOpen)` (fallback uncontrolled); nenhum caller externo é quebrado. - **Done**: `Section` aceita `open` e `onToggle` opcionais; modo uncontrolled continua funcionando; modo controlled expande/colapsa corretamente ao passar `open={true/false}` + `onToggle`. - [X] T009 [US2] Adicionar estado `openSections` e helper `toggleSection` no componente principal `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: `const [openSections, setOpenSections] = useState>(() => initOpenSections(filters))`; `function toggleSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: !prev[key] })) }`; adicionar também `function expandSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: true })) }` (usado por US1 na T015). - **Done**: estado `openSections` inicializado corretamente; `toggleSection` alterna o valor da chave; `expandSection` garante `true` sem alterar demais. - [X] T010 [US2] Conectar todas as seções ao estado `openSections` em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: cada `
` deve receber `open={openSections['']}` e `onToggle={() => toggleSection('')}` — aplicar nas seções: Imobiliária (`imobiliaria`), Localização (`localizacao`), Tipo de imóvel (`tipo`), Preço (`preco`), Quartos e vagas (`quartos`), Área (`area`), Comodidades (`comodidades`); remover `defaultOpen` props onde `open` é passado. - **Done**: todas as seções do sidebar são controlled; ao carregar sem URL params, apenas "Preço" está aberta; seções com filtros ativos na URL são abertas automaticamente; toggle manual funciona em cada seção. **Checkpoint US2**: Carregar `/imoveis` — somente seção "Preço" expandida ✓. Carregar `/imoveis?city_id=1` — seções "Preço" e "Localização" expandidas ✓. Toggle manual colapsa/expande corretamente ✓. --- ## Phase 4: US1 — Campo de Busca Cross-Categoria (Priority: P1) **Goal**: Campo de busca no topo do sidebar que filtra todos os itens do catálogo instantaneamente (debounce 200 ms), exibe sugestões agrupadas por categoria com highlight, seleciona ao clicar ou pressionar Enter, expande a seção relevante e limpa o campo. **Independent Test**: Digitar "Copa" no campo de busca do sidebar e verificar que aparece sugestão "Copacabana" sob o grupo "Bairro"; clicar na sugestão e confirmar que o filtro de bairro é aplicado, o campo é limpo e a seção "Localização" é expandida. **Dependências**: T008 e T009 (US2) devem estar completos para que `expandSection` esteja disponível. - [X] T011 [US1] Declarar a interface `FilterSuggestion` e implementar a função `computeSuggestions()` localmente em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: interface `FilterSuggestion { category: string; sectionKey: SectionKey; label: string; filterKey: keyof PropertyFilters; value: number | string | undefined; isAmenity?: boolean; amenityId?: number }`; função `computeSuggestions(query: string, propertyTypes: PropertyType[], cities: City[], neighborhoods: Neighborhood[], amenities: Amenity[]): FilterSuggestion[]`; normalização: `text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()`; varrer `propertyTypes[*].subtypes` → categoria `"Tipo de imóvel"`, `sectionKey: 'tipo'`, `filterKey: 'subtype_id'`; `cities` → `"Cidade"`, `sectionKey: 'localizacao'`, `filterKey: 'city_id'`; `neighborhoods` → `"Bairro"`, `sectionKey: 'localizacao'`, `filterKey: 'neighborhood_id'`; `amenities` → `"Comodidade"`, `sectionKey: 'comodidades'`, `isAmenity: true`; retornar array vazio se `query.trim() === ''`. - **Done**: `computeSuggestions('copa', [...], [...], [...], [...])` retorna ao menos uma entrada com `label: 'Copacabana'` e `category: 'Bairro'`; busca é case-insensitive e ignora acentos; query vazia retorna `[]`. - [X] T012 [US1] Criar sub-componente local `SidebarSearchInput` em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: props `{ value: string; onChange: (v: string) => void; disabled?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void }`; renderiza `` com placeholder `"Buscar filtro…"`, ícone de lupa (SVG inline), classes Tailwind: `w-full text-xs`; quando `disabled`: `cursor-not-allowed opacity-50`; `aria-label="Buscar filtro"`. - **Done**: campo renderiza com placeholder correto; estado `disabled` visualmente diferenciado; sem dependências externas. - [X] T013 [US1] Criar sub-componente local `SuggestionList` com navegação por teclado em `frontend/src/components/FilterSidebar.tsx` - **Detalhes**: props `{ suggestions: FilterSuggestion[]; onSelect: (s: FilterSuggestion) => void; activeIndex: number }`; agrupar por `category` e renderizar cabeçalhos de grupo (`text-[10px] font-semibold text-textTertiary uppercase`); cada item: `