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:
parent
4db15db09a
commit
9eafe6081c
11 changed files with 264 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
32
src/components/campaign/dashboard/HomeSearchDemandGroup.tsx
Normal file
32
src/components/campaign/dashboard/HomeSearchDemandGroup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -83,5 +83,6 @@ export const loadHomeSearchSuggestions = async (
|
|||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
demands: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
100
src/utilities/homeSearch/searchHomeDemands.ts
Normal file
100
src/utilities/homeSearch/searchHomeDemands.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
105
tests/int/homeSearchDemands.int.spec.ts
Normal file
105
tests/int/homeSearchDemands.int.spec.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,7 @@ const searchResultsValue = (
|
|||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
demands: [],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue