feat(campanha): add Demandas group to home global search (B53)

Staff can find demands by title from the Início omnibox with municipality and status in the secondary line, scoped by campaignDemand access.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 12:39:35 -03:00
parent 4db15db09a
commit 9eafe6081c
11 changed files with 264 additions and 4 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B53 entregue em código (2026-07-30):** sexto grupo da busca global do Início — **Demandas** via `searchHomeDemands` + `HomeSearchDemandGroup` (após Dobradinhas); `POST /campanha/home-search` agrega `demands`; match word-start no `title`; secundário `"{município} · {status}"`; staff access com `overrideAccess: false`; cap 25; int `homeSearchDemands.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-demandas.md`](../../../docs/plans/busca-global-resultados-demandas.md). Próximo na cadeia busca: **B54** grid/cap.
- **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md).
- **B63 entregue em código (2026-07-30):** wizard registrar sinal (UX-1) — duas etapas no chassis B59: `WizardSignalTypeStep` (grid 2×3 de tiles transparentes + borda, ícone Lucide, Drawer bottom com `infoContent` provisório, `contentAlign="end"` no mobile) e `WizardSignalBodyStep` (textarea + Salvar explícito via `createMunicipalityListSignalFormAction` → `createMunicipalityUpdate` `kind: 'sinal'`). Contrato URL: `?municipio=` (B60) → `?signalType=` (tipo) → body; `?entry=` opcional — skip "Pular registro de sinal" visível só quando embutido (`entry` presente e ≠ `register-signal`), href skip → `/campanha` na v1. Helpers: `wizardSignalHref`, `resolveWizardSignalTypeParam`, `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; `municipalitySignalTypeMeta.ts`; `wizardSignalUi.ts`. B63+ alinha `flowTitle`/`skip` ao chrome B75. Sem migration/Consent. Unit `campaignActionRoutes` + `wizardSignalUi`; e2e standalone + info não avança + skip embutido. Plano: [`wizard-registro-sinal.md`](../../../docs/plans/wizard-registro-sinal.md). Desbloqueia prefill de **B64** (soft).
- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `<main>` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único.

View file

@ -1,7 +1,7 @@
# Busca global — resultados Demandas
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30 — as-built: `searchHomeDemands` (`isStaffCampaignRole` gate; word-start no `title`; access `campaignDemand` com `overrideAccess: false`; cap 25; desempate `createdAt` desc); `HomeSearchDemandHit` + campo `demands` no `POST /campanha/home-search`; `HomeSearchDemandGroup` após Dobradinhas; secundário `"{município} · {status}"`; empty via `homeSearchHasAnyHits`; int `homeSearchDemands.int.spec.ts`; sem migration.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B53 — busca global)
Impeccable: B — grupo de hits no slot B47
Appetite: ~0,5 dia eng; loader + grupo; share = B55

View file

@ -14,6 +14,7 @@ import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute
import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions'
import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities'
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands'
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
@ -43,13 +44,14 @@ export const POST = campaignJsonMutationRoute(
})
}
const [municipalityResult, advisors, leaderships, activities, stateDeputies] =
const [municipalityResult, advisors, leaderships, activities, stateDeputies, demands] =
await Promise.all([
searchHomeMunicipalities(payload, actor, body.query),
searchHomeAdvisors(payload, actor, body.query),
searchHomeLeaderships(payload, actor, body.query),
searchHomeActivities(payload, actor, body.query),
searchHomeStateDeputies(payload, actor, body.query),
searchHomeDemands(payload, actor, body.query),
])
return NextResponse.json<HomeSearchSuccessResponse>({
...municipalityResult,
@ -57,6 +59,7 @@ export const POST = campaignJsonMutationRoute(
leaderships,
activities,
stateDeputies,
demands,
})
},
)

View file

@ -8,6 +8,7 @@ import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/Campaig
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
@ -54,6 +55,7 @@ export default async function CampaignHomePage() {
<HomeSearchAdvisorGroup />
<HomeSearchActivityGroup />
<HomeSearchStateDeputyGroup />
<HomeSearchDemandGroup />
</HomeSearchResultsShell>
}
summarySlot={summarySlot}

View file

@ -0,0 +1,32 @@
'use client'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { homeSearchDemandGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
export const HomeSearchDemandGroup = () => {
const { results } = useHomeSearchResults()
if (results.status !== 'success') return null
if (!homeSearchDemandGroupHasHits(results.data)) return null
const { demands } = results.data
return (
<section aria-label="Demandas" className="flex flex-col gap-0.5">
<h2 className={HOME_SEARCH_GROUP_HEADING_CLASS}>Demandas</h2>
<ul className={HOME_SEARCH_GROUP_LIST_CLASS}>
{demands.map((hit) => (
<li key={hit.id}>
<HomeSearchHitRow
href={`/campanha/demandas/${hit.slug}`}
primary={hit.title}
secondary={hit.secondary}
/>
</li>
))}
</ul>
</section>
)
}

View file

@ -46,6 +46,13 @@ export type HomeSearchActivityHit = {
secondary: string
}
export type HomeSearchDemandHit = {
id: number
slug: string
title: string
secondary: string
}
type HomeSearchResultKind = 'search' | 'suggest'
export type HomeSearchSuccessResponse = {
@ -57,6 +64,7 @@ export type HomeSearchSuccessResponse = {
leaderships: HomeSearchLeadershipHit[]
stateDeputies: HomeSearchStateDeputyHit[]
activities: HomeSearchActivityHit[]
demands: HomeSearchDemandHit[]
}
export const homeSearchMunicipalityGroupHasHits = (
@ -75,6 +83,10 @@ export const homeSearchActivityGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'activities'>,
): boolean => data.activities.length > 0
export const homeSearchDemandGroupHasHits = (
data: Pick<HomeSearchSuccessResponse, 'demands'>,
): boolean => data.demands.length > 0
export const formatHomeSearchMunicipalityCount = (count: number): string => {
if (count === 1) return '1 município'
return `${count} municípios`
@ -99,7 +111,8 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
data.advisors.length > 0 ||
homeSearchLeadershipGroupHasHits(data) ||
homeSearchStateDeputyGroupHasHits(data) ||
homeSearchActivityGroupHasHits(data)
homeSearchActivityGroupHasHits(data) ||
homeSearchDemandGroupHasHits(data)
export type WizardMunicipalitySearchSuccessResponse = {
status: 'success'

View file

@ -83,5 +83,6 @@ export const loadHomeSearchSuggestions = async (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
}
}

View file

@ -0,0 +1,100 @@
import 'server-only'
import type { Payload } from 'payload'
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
import type { HomeSearchDemandHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { isPopulatedRelationship } from '@/lib/relationship'
import { campaignDemandStatusLabels, type CampaignDemandStatus } from '@/lib/schemas/campaignDemand'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignDemand, CampaignUser, Municipality } from '@/payload-types'
const HOME_SEARCH_DEMAND_HIT_CAP = 25
export const formatDemandHomeSearchSecondary = (
municipalityName: string | null,
status: CampaignDemandStatus,
): string => {
const statusLabel = campaignDemandStatusLabels[status]
if (!municipalityName) return statusLabel
return `${municipalityName} · ${statusLabel}`
}
export const searchHomeDemands = async (
payload: Payload,
user: CampaignUser,
rawQuery: string,
): Promise<HomeSearchDemandHit[]> => {
if (!isStaffCampaignRole(user.role)) {
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
}
const query = normalizeHomeSearchRaw(rawQuery)
if (!homeSearchQueryIsActive(query)) {
return []
}
const normalizedQuery = normalizeHomeSearchName(query)
const result = await payload.find({
collection: 'campaignDemand',
where: { title: { contains: query } },
depth: 1,
limit: 0,
pagination: false,
select: {
id: true,
title: true,
slug: true,
status: true,
municipality: true,
createdAt: true,
},
user,
overrideAccess: false,
})
const candidates: Array<{
normalizedName: string
tieBreakDesc: number
hit: HomeSearchDemandHit
}> = []
for (const doc of result.docs as CampaignDemand[]) {
const normalizedName = normalizeHomeSearchName(doc.title)
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
continue
}
const municipalityName = isPopulatedRelationship<Municipality>(doc.municipality)
? doc.municipality.name
: null
const tieBreakDesc = doc.createdAt ? Date.parse(doc.createdAt) : 0
candidates.push({
normalizedName,
tieBreakDesc: Number.isFinite(tieBreakDesc) ? tieBreakDesc : 0,
hit: {
id: doc.id,
slug: doc.slug,
title: doc.title,
secondary: formatDemandHomeSearchSecondary(
municipalityName,
doc.status as CampaignDemandStatus,
),
},
})
}
return candidates
.sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery))
.slice(0, HOME_SEARCH_DEMAND_HIT_CAP)
.map((row) => row.hit)
}

View file

@ -29,6 +29,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
})
export const searchHomeMunicipalities = async (
@ -91,5 +92,6 @@ export const searchHomeMunicipalities = async (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
}
}

View file

@ -0,0 +1,105 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import config from '@/payload.config'
import { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('searchHomeDemands (B53)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('returns empty hits when query is shorter than the minimum', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const result = await searchHomeDemands(payload, coordinator, 'a')
expect(result).toEqual([])
})
it('matches demands by word-start on the title for staff', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality('cairu')
const title = fixtures.value('Material Zeca Centro')
const demand = await fixtures.createCampaignDemand({
municipality,
title,
createdBy: coordinator,
})
const result = await searchHomeDemands(payload, coordinator, 'Material')
const hit = result.find((row) => row.id === demand.id)
expect(hit?.title).toBe(title)
expect(hit?.slug).toBe(demand.slug)
expect(hit?.secondary).toContain('Cairu')
expect(hit?.secondary).toContain('Aberta')
})
it('does not match mid-word without word-start boundary', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality('cairu')
const title = fixtures.value('Transporte Comunitário')
const demand = await fixtures.createCampaignDemand({
municipality,
title,
createdBy: coordinator,
})
const result = await searchHomeDemands(payload, coordinator, 'ransporte')
expect(result.some((row) => row.id === demand.id)).toBe(false)
})
it('scopes demand hits to the advisor portfolio by municipality', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality('cairu')
const other = await fixtures.getMunicipality('feira-de-santana')
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const scopeToken = fixtures.value('scope-demanda')
const inScope = await fixtures.createCampaignDemand({
municipality: administered,
title: `Material ${scopeToken} Cairu`,
createdBy: coordinator,
})
const outOfScope = await fixtures.createCampaignDemand({
municipality: other,
title: `Material ${scopeToken} Feira`,
createdBy: coordinator,
})
const advisorResult = await searchHomeDemands(payload, advisor, scopeToken)
expect(advisorResult.map((hit) => hit.id)).toEqual([inScope.id])
const coordinatorResult = await searchHomeDemands(payload, coordinator, scopeToken)
expect(coordinatorResult.map((hit) => hit.id).sort((a, b) => a - b)).toEqual(
[inScope.id, outOfScope.id].sort((a, b) => a - b),
)
})
it('rejects leaders from home search', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
await expect(searchHomeDemands(payload, leader, 'Material')).rejects.toThrow(
/equipe de campanha/i,
)
})
})

View file

@ -26,6 +26,7 @@ const searchResultsValue = (
leaderships: [],
stateDeputies: [],
activities: [],
demands: [],
...data,
},
},