B142 — filtrar assessores por município da carteira na omnibox

Adiciona dimensão municipality na URL e omnibox da lista de assessores,
espelhando o precedente de lideranças: chips removíveis, busca tipada no
catálogo e filtro server OR via municipality.advisors.

Closes #310

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:05:33 +00:00
parent d0cd103b90
commit 4d4764893e
No known key found for this signature in database
10 changed files with 432 additions and 37 deletions

View file

@ -0,0 +1,69 @@
# Impl: Assessores — filtrar por município da carteira na omnibox
Status: aprovado
Atualizado em: 2026-08-02
Issue: #310
Intenção: docs/plans/assessores-omnibox-municipio-carteira.md
Appetite restante: ~0,5 dia eng
## Leitura da intenção
- **Outcome:** Coordenador/Candidato filtra assessores pela carteira (município administrado) via omnibox, com busca por nome/e-mail preservada e chips removíveis.
- **O que NÃO negociar:** rota continua `unrestricted`; sem filtro “sem carteira”; sem saved filters; sem mudança de RBAC.
- **O que reavaliar:** sair de `searchOnlyListOmnibox` — confirmado; relação assessor↔município é inversa (`municipality.advisors`), não campo em `campaignUser`.
## Abordagem recomendada
```mermaid
flowchart LR
URL["URL ?municipality=&q="] --> Parse["advisorListUrl"]
Parse --> Load["loadAdvisorListPageData"]
Load --> Resolve["advisorIds via municipality.advisors"]
Resolve --> Query["campaignUser role=advisor"]
Index["municipalityPortfolioIndex + catalog"] --> Omnibox["advisorOmnibox"]
Omnibox --> UI["AdvisorFilters"]
```
**Opções consideradas:**
- A) Estender `searchOnlyListOmnibox` com hooks — rejeitada (não escala; leadership/supporter já têm adapter próprio).
- B) Adapter `advisorOmnibox` + `advisorListFilters` espelhando lideranças (só dimensão município + busca) — **recomendada**.
- C) Facet server-side de municípios com assessor — rejeitada nesta fatia (busca tipada no índice de 435 já atende; página já carrega `loadMunicipalityPortfolioIndex`).
**Recomendação:** B — reusa contrato URL multi-`municipality`, toggles OR, grupo omnibox “Município (carteira)”, filtro server via união de `municipality.advisors`.
### Componentes / mudanças
- **`advisorListUrl.ts`:** `municipalities?: number[]`; parse/serialize `municipality` repetível.
- **`advisorListFilters.ts` (novo):** toggle/clear município; `clearAdvisorListFilters`.
- **`advisorOmnibox.ts` (novo):** chips, seeds, apply/remove — grupo “Município (carteira)”.
- **`advisorData.ts`:** resolver IDs de assessor a partir dos municípios selecionados (OR); short-circuit lista vazia.
- **`AdvisorFilters.tsx`:** props `municipalityFilterOptions`; placeholder/label atualizados.
- **`assessores/page.tsx`:** montar opções do índice + catalog; `hasQuery` inclui municípios.
- **Testes:** parser/href unit; omnibox toggle unit; int `loadAdvisorListPageData` com filtro município.
### Dados → forma
- Opções omnibox: `loadMunicipalityPortfolioIndex` + nome do `municipalityCatalog` (já na página) — busca tipada, sem facet extra.
## Fases verificáveis
1. **URL + server** — parse, href, filtro Payload, testes unit/int.
2. **UI** — `AdvisorFilters` + omnibox adapter.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Filtro “sem carteira” / cobertura estadual.
- Saved filters / coluna head filter.
- Facet dinâmico restrito a municípios com assessor (defer: gatilho se omnibox ficar ruidosa).
## Riscos e mitigação
- **Performance:** uma query extra `municipality` só quando há filtro; índice omnibox client-side já cacheado.
- **OR vs AND:** toggles multi-valor + `id in union(advisors)` — alinhado a lideranças.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit/int) onde access/write paths mudam

View file

@ -5,6 +5,7 @@ import { getPayload } from 'payload'
import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters'
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import {
CampaignListPendingBoundary,
CampaignListResults,
@ -46,16 +47,23 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
loadMunicipalityPortfolioIndex(),
])
const municipalityFilterOptions = municipalityIndex.map((entry) => ({
value: String(entry.id),
label: getMunicipalityCatalogEntry(entry.slug)?.name ?? entry.slug,
}))
const hasActiveFilters = Boolean(state.q || state.municipalities?.length)
return (
<CampaignPageShell>
<CampaignListPendingBoundary>
<AdvisorFilters state={state} />
<AdvisorFilters state={state} municipalityFilterOptions={municipalityFilterOptions} />
<CampaignListResults>
<AdvisorsTable
rows={rows}
municipalityIndex={municipalityIndex}
hasQuery={Boolean(state.q)}
hasQuery={hasActiveFilters}
updateProfileAction={updateAdvisorProfileFormAction}
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
createAction={createAdvisorFormAction}

View file

@ -5,29 +5,55 @@ import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import {
applySearchOnlyOmniboxSuggestion,
buildSearchOnlyOmniboxChips,
buildSearchOnlyOmniboxSuggestions,
clearSearchOnlyOmnibox,
removeSearchOnlyOmniboxChip,
type SearchOnlyOmniboxAction,
} from '@/lib/searchOnlyListOmnibox'
applyAdvisorOmniboxSuggestion,
buildAdvisorOmniboxChips,
buildAdvisorOmniboxSuggestionSeeds,
clearAdvisorOmnibox,
filterAdvisorOmniboxSuggestions,
removeAdvisorOmniboxChip,
type AdvisorOmniboxAction,
} from '@/utilities/advisor/advisorOmnibox'
import type { AdvisorFilterOption } from '@/utilities/advisor/advisorListFilters'
import { advisorListHrefForPage, type AdvisorListState } from '@/utilities/advisor/advisorListUrl'
export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => {
export const AdvisorFilters = ({
state,
municipalityFilterOptions,
}: {
state: AdvisorListState
municipalityFilterOptions: AdvisorFilterOption[]
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => advisorListHrefForPage(next, 1),
})
const [query, setQuery] = useState('')
const withPageReset = (next: AdvisorListState): AdvisorListState => ({ ...next, page: 1 })
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityFilterOptions) {
const id = Number(option.value)
if (Number.isSafeInteger(id) && id > 0) map.set(id, option.label)
}
return map
}, [municipalityFilterOptions])
const chips = useMemo(() => buildSearchOnlyOmniboxChips(state), [state])
const chips = useMemo(
() => buildAdvisorOmniboxChips({ state, municipalityLabelsById }),
[state, municipalityLabelsById],
)
const suggestions = useMemo(() => buildSearchOnlyOmniboxSuggestions(query), [query])
const suggestionSeeds = useMemo(
() => buildAdvisorOmniboxSuggestionSeeds({ municipalityFilterOptions }),
[municipalityFilterOptions],
)
const runAction = (action: SearchOnlyOmniboxAction<AdvisorListState>) => {
const suggestions = useMemo(
() => filterAdvisorOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: AdvisorOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
navigate(action.state)
@ -45,41 +71,24 @@ export const AdvisorFilters = ({ state }: { state: AdvisorListState }) => {
>
<CampaignListOmnibox
id="advisor-omnibox"
label="Buscar assessor por nome ou e-mail"
placeholder="Digite para buscar por nome ou e-mail…"
label="Filtrar assessores"
placeholder="Digite para buscar por nome, e-mail ou município da carteira…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId,
withPageReset,
}),
)
runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId }))
}}
onCommitQuery={(text) => {
runAction(
applySearchOnlyOmniboxSuggestion({
state,
suggestionId: `q:${text}`,
withPageReset,
}),
)
runAction(applyAdvisorOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
}}
onRemoveChip={(chipId) => {
runAction(removeSearchOnlyOmniboxChip({ state, chipId, withPageReset }))
runAction(removeAdvisorOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(
clearSearchOnlyOmnibox({
state,
cleared: { page: 1 },
}),
)
runAction(clearAdvisorOmnibox(state))
}}
/>
</form>

View file

@ -0,0 +1,53 @@
/**
* Advisor list filter affordances: município (carteira) toggles and clear.
*/
import { strictDecimalInteger } from '@/utilities/campaignListUrl'
import {
advisorListHrefForPage,
advisorListStateToRawParams,
parseAdvisorListParams,
type AdvisorListState,
} from '@/utilities/advisor/advisorListUrl'
export type AdvisorFilterOption = {
value: string
label: string
}
const setAdvisorMunicipalityFilterValues = (
state: AdvisorListState,
values: string[],
): AdvisorListState =>
parseAdvisorListParams({
...advisorListStateToRawParams({ ...state, page: 1 }, 1),
municipality: values,
})
const getAdvisorMunicipalityFilterValues = (state: AdvisorListState): string[] =>
(state.municipalities ?? []).map(String)
export const toggleAdvisorMunicipalityFilter = (
state: AdvisorListState,
value: string,
): AdvisorListState => {
if (strictDecimalInteger(value) === undefined) return state
const current = getAdvisorMunicipalityFilterValues(state)
return setAdvisorMunicipalityFilterValues(
state,
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
)
}
export const clearAdvisorMunicipalityFilter = (state: AdvisorListState): AdvisorListState =>
setAdvisorMunicipalityFilterValues(state, [])
/** Drop every filter and the search; keep pagination at page 1. */
export const clearAdvisorListFilters = (): AdvisorListState => ({
page: 1,
})
export const buildAdvisorFilterHref = (next: AdvisorListState): string =>
advisorListHrefForPage(next, 1)
export const isAdvisorMunicipalityFilterActive = (state: AdvisorListState): boolean =>
Boolean(state.municipalities?.length)

View file

@ -1,5 +1,6 @@
import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths'
import {
allParamValues,
buildListHref,
firstValue,
normalizedText,
@ -10,21 +11,40 @@ import {
export type AdvisorListState = {
page: number
q?: string
municipalities?: number[]
}
export type AdvisorListSearchParams = RawSearchParams
export const advisorListStateToRawParams = (
state: AdvisorListState,
page = state.page,
): AdvisorListSearchParams => ({
page: String(page),
q: state.q,
municipality: state.municipalities?.map(String),
})
export const parseAdvisorListParams = (searchParams: RawSearchParams): AdvisorListState => {
const q = normalizedText(firstValue(searchParams.q))
const page = strictDecimalInteger(firstValue(searchParams.page)) ?? 1
const municipalities = allParamValues(searchParams.municipality)
.map((token) => strictDecimalInteger(token))
.filter((id): id is number => typeof id === 'number' && id > 0)
return {
page,
...(q ? { q } : {}),
...(municipalities.length ? { municipalities } : {}),
}
}
const buildAdvisorListSearchParams = (state: AdvisorListState, page = state.page) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
for (const municipality of state.municipalities ?? []) {
params.append('municipality', String(municipality))
}
if (page > 1) params.set('page', String(page))
return params
}

View file

@ -0,0 +1,121 @@
/**
* Advisor list omnibox adapter (B142). Pure / client-safe.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import {
clearAdvisorListFilters,
toggleAdvisorMunicipalityFilter,
type AdvisorFilterOption,
} from '@/utilities/advisor/advisorListFilters'
import type { AdvisorListState } from '@/utilities/advisor/advisorListUrl'
export type AdvisorOmniboxAction =
| { kind: 'url'; state: AdvisorListState }
| { kind: 'clear'; state: AdvisorListState }
const MUNICIPALITY_GROUP = 'Município (carteira)'
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
const withPageReset = (state: AdvisorListState): AdvisorListState => ({ ...state, page: 1 })
export const buildAdvisorOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: AdvisorListState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
for (const municipalityId of state.municipalities ?? []) {
chips.push({
id: `municipality:${municipalityId}`,
label: chipLabel(
MUNICIPALITY_GROUP,
municipalityLabelsById.get(municipalityId) ?? `Município #${municipalityId}`,
),
})
}
return chips
}
export const buildAdvisorOmniboxSuggestionSeeds = ({
municipalityFilterOptions,
}: {
municipalityFilterOptions: readonly AdvisorFilterOption[]
}) => {
const seeds = []
for (const option of municipalityFilterOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: MUNICIPALITY_GROUP,
label: option.label,
keywords: ['municipio', 'carteira'],
}),
)
}
return seeds
}
export const filterAdvisorOmniboxSuggestions = (
seeds: ReturnType<typeof buildAdvisorOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
export const applyAdvisorOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: AdvisorListState
suggestionId: string
}): AdvisorOmniboxAction => {
if (suggestionId.startsWith('q:')) {
const q = suggestionId.slice(2)
return { kind: 'url', state: withPageReset({ ...state, q: q || undefined }) }
}
if (suggestionId.startsWith('municipality:')) {
return {
kind: 'url',
state: toggleAdvisorMunicipalityFilter(state, suggestionId.slice(13)),
}
}
return { kind: 'url', state }
}
export const removeAdvisorOmniboxChip = ({
state,
chipId,
}: {
state: AdvisorListState
chipId: string
}): AdvisorOmniboxAction => {
if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
if (chipId.startsWith('municipality:')) {
return {
kind: 'url',
state: toggleAdvisorMunicipalityFilter(state, chipId.slice(13)),
}
}
return { kind: 'url', state }
}
export const clearAdvisorOmnibox = (_state: AdvisorListState): AdvisorOmniboxAction => ({
kind: 'clear',
state: clearAdvisorListFilters(),
})

View file

@ -40,6 +40,36 @@ export type AdvisorDetailViewModel = AdvisorAccountViewModel & {
const advisorPageSize = 25
/** Union of advisors linked on any of the given municipalities (OR semantics). */
const advisorIdsForMunicipalityFilter = async (
payload: Payload,
municipalityIds: number[],
): Promise<number[]> => {
if (municipalityIds.length === 0) return []
// Intentional admin bypass: same rationale as `municipalityIdsByAdvisorIds` — the
// unrestricted route gate already asserted staff; this read only resolves filter ids.
const municipalities = await payload.find({
collection: 'municipality',
where: { id: { in: municipalityIds } },
depth: 0,
limit: 0,
pagination: false,
select: { advisors: true },
overrideAccess: true,
})
const advisorIds = new Set<number>()
for (const municipality of municipalities.docs) {
for (const advisor of municipality.advisors ?? []) {
const id = relationshipId(advisor)
if (id !== null) advisorIds.add(id)
}
}
return [...advisorIds]
}
/** Fan a município out to each of the advisors the caller asked about. */
const collectByAdvisor = <T>(
advisors: readonly unknown[] | null | undefined,
@ -133,11 +163,20 @@ export const loadAdvisorListPageData = async (
payload: Payload,
state: AdvisorListState,
): Promise<{ rows: AdvisorRowViewModel[]; totalDocs: number; totalPages: number }> => {
const municipalityAdvisorIds = state.municipalities?.length
? await advisorIdsForMunicipalityFilter(payload, state.municipalities)
: undefined
if (municipalityAdvisorIds && municipalityAdvisorIds.length === 0) {
return { rows: [], totalDocs: 0, totalPages: 0 }
}
const result = await payload.find({
collection: 'campaignUser',
where: {
and: [
{ role: { equals: 'advisor' } },
...(municipalityAdvisorIds ? [{ id: { in: municipalityAdvisorIds } }] : []),
...(state.q
? [
{

View file

@ -465,4 +465,34 @@ describe('loadAdvisorListPageData', () => {
// Ids only: the list's chips resolve their labels from the static catalog.
expect(result.rows[0]!.municipalityIDs).toEqual([municipality.id])
})
it('narrows by municipality portfolio with OR semantics', async () => {
const fixtures = campaignFixtures()
const advisorA = await fixtures.createCampaignUser('advisor')
const advisorB = await fixtures.createCampaignUser('advisor')
const municipalityA = await fixtures.getMunicipality()
const municipalityB = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipalityA.id, [advisorA.id])
await fixtures.assignMunicipalityAdvisors(municipalityB.id, [advisorB.id])
const onlyA = await loadAdvisorListPageData(payload, {
page: 1,
municipalities: [municipalityA.id],
})
expect(onlyA.totalDocs).toBe(1)
expect(onlyA.rows[0]?.id).toBe(advisorA.id)
const either = await loadAdvisorListPageData(payload, {
page: 1,
municipalities: [municipalityA.id, municipalityB.id],
})
expect(either.totalDocs).toBe(2)
expect(either.rows.map((row) => row.id).sort()).toEqual([advisorA.id, advisorB.id].sort())
const none = await loadAdvisorListPageData(payload, {
page: 1,
municipalities: [999_999],
})
expect(none.totalDocs).toBe(0)
})
})

View file

@ -31,6 +31,12 @@ describe.each(qAndPageParsers)('%s list parser (q + page)', (_name, parse) => {
expect(parse({ q: ' ' }).q).toBeUndefined()
expect(parse({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
})
it('parses repeatable municipality ids and drops invalid tokens', () => {
expect(parseAdvisorListParams({ municipality: ['12', 'bad', '0'] }).municipalities).toEqual([12])
expect(parseAdvisorListParams({ municipality: ['3', '7'] }).municipalities).toEqual([3, 7])
expect(parseAdvisorListParams({}).municipalities).toBeUndefined()
})
})
describe('parseOrganizationListParams', () => {
@ -68,4 +74,13 @@ describe('advisorListHrefForPage', () => {
'/campanha/assessores?q=edi&page=3',
)
})
it('serializes repeatable municipality filters', () => {
expect(advisorListHrefForPage({ page: 1, municipalities: [4, 9] }, 1)).toBe(
'/campanha/assessores?municipality=4&municipality=9',
)
expect(advisorListHrefForPage({ page: 1, q: 'ana', municipalities: [2] }, 2)).toBe(
'/campanha/assessores?q=ana&municipality=2&page=2',
)
})
})

View file

@ -9,6 +9,13 @@ import {
buildActivityOmniboxChips,
clearActivityOmnibox,
} from '@/utilities/activityOmnibox'
import { parseAdvisorListParams } from '@/utilities/advisor/advisorListUrl'
import {
applyAdvisorOmniboxSuggestion,
buildAdvisorOmniboxChips,
buildAdvisorOmniboxSuggestionSeeds,
filterAdvisorOmniboxSuggestions,
} from '@/utilities/advisor/advisorOmnibox'
import { parseActivityListParams } from '@/utilities/activityUi'
import { parseDemandListParams } from '@/utilities/demand/demandListUrl'
import {
@ -145,4 +152,28 @@ describe('list omnibox adapters (B128)', () => {
})
expect(applied).toEqual({ kind: 'url', state: { page: 1, q: 'ana' } })
})
it('advisor toggles municipality portfolio filter and suggests by name', () => {
const base = parseAdvisorListParams({})
const applied = applyAdvisorOmniboxSuggestion({
state: base,
suggestionId: 'municipality:42',
})
expect(applied.kind).toBe('url')
if (applied.kind === 'url') {
expect(applied.state.municipalities).toEqual([42])
}
const chips = buildAdvisorOmniboxChips({
state: parseAdvisorListParams({ municipality: '42' }),
municipalityLabelsById: new Map([[42, 'Feira de Santana']]),
})
expect(chips[0]?.label).toContain('Feira de Santana')
const seeds = buildAdvisorOmniboxSuggestionSeeds({
municipalityFilterOptions: [{ value: '42', label: 'Feira de Santana' }],
})
const suggestions = filterAdvisorOmniboxSuggestions(seeds, 'feira')
expect(suggestions.some((entry) => entry.group === 'Município (carteira)')).toBe(true)
})
})