diff --git a/docs/plans/atividades-omnibox-presets-busca-impl.md b/docs/plans/atividades-omnibox-presets-busca-impl.md
new file mode 100644
index 00000000..1acf9343
--- /dev/null
+++ b/docs/plans/atividades-omnibox-presets-busca-impl.md
@@ -0,0 +1,67 @@
+# Impl: Atividades — presets (Próximos/…) e busca na omnibox; remover abas (B138)
+
+Status: aprovado
+Atualizado em: 2026-08-02
+Issue: #306
+Intenção: docs/plans/atividades-omnibox-presets-busca.md
+Appetite restante: herdado (~0,5–1 dia eng)
+
+## Leitura da intenção
+
+- **Outcome:** Uma barra omnibox na lista de atividades — presets de janela (Próximos/Todos/Realizados/Rascunhos) e busca por texto como chips/sugestões; seletor de abas acima some; default Próximos intacto.
+- **O que NÃO negociar:** semântica de Próximos (planejado/confirmado + startAt ≥ agora); leader lockdown inalterado; deep-links `?tab=` preservados; tipo/município/status (em Todos) na omnibox.
+- **O que reavaliar:** hipótese de mover preset para dentro da omnibox — confirmada; não precisa de mudança no chassis `CampaignListOmnibox`.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ URL["URL ?tab=&q=&kind=&…"] --> parse["parseActivityListParams"]
+ parse --> where["buildActivityListWhere"]
+ parse --> omnibox["activityOmnibox adapter"]
+ omnibox --> UI["ActivityFilters → CampaignListOmnibox"]
+```
+
+**Opções consideradas:**
+
+- A) Tab como chip de apresentação (não-URL, como scenario em municípios)
+- B) Tab permanece em `?tab=` com chip/sugestões na omnibox
+- C) Tab vira filtro de status composto (rejeitado pelo produto)
+
+**Recomendação:** B — mantém deep-links, reusa `activityUi` existente; só muda superfície.
+**Rejeitadas:** A (quebra bookmarks `?tab=realizados`); C (perde modo diário nomeado).
+
+### Componentes / mudanças
+
+- **`activityUi.ts`**: adicionar `q` ao estado/URL; `buildActivityListWhere` com OR `title` + `responsible.name`.
+- **`activityOmnibox.ts`**: seeds grupo `Janela` (`tab:*`); chip de janela quando `tab !== proximos`; chip `Busca:`; apply/remove/clear para `q` e `tab`.
+- **`ActivityFilters.tsx`**: remover `ActivityTabSwitch`; wire `onCommitQuery`; placeholder atualizado.
+- **Migration:** sem migration.
+- **Access / Consent:** inalterado (staff-only lista).
+- **UI:** Impeccable B — espelhar `SupporterFilters` (uma barra, chips, pending boundary existente).
+
+### Dados → forma
+
+- Chip de janela mostra `activityTabLabels[tab]` (ex. "Realizados"), não status cru.
+- Busca: título + nome do responsável (já no card; precedente `contact.name` em leadership).
+
+## Fases verificáveis
+
+1. **Server/URL** — `activityUi` + testes unitários (`q`, where OR).
+2. **Omnibox adapter + UI** — `activityOmnibox`, `ActivityFilters`; testes B128 estendidos.
+3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Saved filters B18; intervalo de datas genérico; mudanças em outras listas.
+- Novo arquivo `activityOmnibox.unit.spec.ts` — estender `listOmniboxB128` + `activityUi` (evitar twin de test file).
+
+## Riscos e mitigação
+
+- **Tab + status em Todos:** ao mudar tab, limpar `status` se sair de `todos` (mesmo comportamento do antigo `buildTabHref`).
+- **`q` + tab:** filtros AND — busca restringe dentro da janela ativa.
+
+## Débitos deferidos (simplify B138)
+
+- **DRY municipality/setExclusiveField helpers** (score 3, `defer_trigger`): extrair quando 3º domínio omnibox precisar do mesmo padrão — já ledgerado em B128; não bloqueia B138.
+- **Int test `responsible.name` contains** (score 3, `defer_trigger`): where JSON coberto em unit; int espelhando supporter quando houver fixture de activity com responsável.
diff --git a/src/components/campaign/activity/ActivityFilters.tsx b/src/components/campaign/activity/ActivityFilters.tsx
index d2a47f8c..8049065f 100644
--- a/src/components/campaign/activity/ActivityFilters.tsx
+++ b/src/components/campaign/activity/ActivityFilters.tsx
@@ -1,12 +1,10 @@
'use client'
-import Link from 'next/link'
import { useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
-import { Button } from '@/components/ui/button'
import {
applyActivityOmniboxSuggestion,
buildActivityOmniboxChips,
@@ -16,46 +14,7 @@ import {
removeActivityOmniboxChip,
type ActivityOmniboxAction,
} from '@/utilities/activityOmnibox'
-import {
- activityTabLabels,
- activityTabs,
- buildActivityListHref,
- buildActivityListSearchParams,
- type ActivityListState,
- type ActivityTab,
-} from '@/utilities/activityUi'
-
-const buildTabHref = (state: ActivityListState, tab: ActivityTab): string => {
- const params = buildActivityListSearchParams({
- page: 1,
- tab,
- kind: state.kind,
- status: tab === 'todos' ? state.status : undefined,
- municipality: state.municipality,
- })
- const query = params.toString()
- return query ? `/campanha/atividades?${query}` : '/campanha/atividades'
-}
-
-const ActivityTabSwitch = ({ state }: { state: ActivityListState }) => (
-
-)
+import { buildActivityListHref, type ActivityListState } from '@/utilities/activityUi'
export const ActivityFilters = ({
state,
@@ -116,35 +75,34 @@ export const ActivityFilters = ({
}
return (
-
+ onCommitQuery={(text) => {
+ runAction(applyActivityOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
+ }}
+ onRemoveChip={(chipId) => {
+ runAction(removeActivityOmniboxChip({ state, chipId }))
+ }}
+ onClearAll={() => {
+ runAction(clearActivityOmnibox(state))
+ }}
+ />
+
)
}
diff --git a/src/utilities/activityOmnibox.ts b/src/utilities/activityOmnibox.ts
index 071c3a57..daaa1598 100644
--- a/src/utilities/activityOmnibox.ts
+++ b/src/utilities/activityOmnibox.ts
@@ -1,6 +1,6 @@
/**
- * Activity list omnibox adapter (B128). Pure / client-safe.
- * Tab preset stays outside the omnibox (product map).
+ * Activity list omnibox adapter (B128 + B138). Pure / client-safe.
+ * Window presets and text search live inside the omnibox.
*/
import {
createOmniboxSuggestionSeed,
@@ -8,8 +8,11 @@ import {
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
+import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
import {
+ activityTabLabels,
+ activityTabs,
buildActivityListSearchParams,
parseActivityListParams,
type ActivityListState,
@@ -52,6 +55,24 @@ const setExclusiveField = (
return parseStateFromParams(raw)
}
+const tabKeywords: Record = {
+ proximos: ['proximos', 'próximos', 'proximo', 'próximo'],
+ todos: ['todos', 'todas'],
+ realizados: ['realizados', 'realizado'],
+ rascunhos: ['rascunhos', 'rascunho'],
+}
+
+const setTab = (state: ActivityListState, tab: ActivityTab): ActivityListState => {
+ const params = buildActivityListSearchParams(withPageReset({ ...state, tab }))
+ const raw: Record = Object.fromEntries(params.entries())
+
+ if (tab !== 'todos') {
+ delete raw.status
+ }
+
+ return parseStateFromParams(raw)
+}
+
export const buildActivityOmniboxChips = ({
state,
municipalityLabelsById,
@@ -61,6 +82,15 @@ export const buildActivityOmniboxChips = ({
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
+ if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
+
+ if (state.tab !== 'proximos') {
+ chips.push({
+ id: `tab:${state.tab}`,
+ label: activityTabLabels[state.tab],
+ })
+ }
+
if (state.kind) {
chips.push({
id: `kind:${state.kind}`,
@@ -97,6 +127,20 @@ export const buildActivityOmniboxSuggestionSeeds = ({
}) => {
const seeds = []
+ for (const tabValue of activityTabs) {
+ seeds.push(
+ createOmniboxSuggestionSeed(
+ {
+ id: `tab:${tabValue}`,
+ group: 'Janela',
+ label: activityTabLabels[tabValue],
+ keywords: ['janela', ...tabKeywords[tabValue]],
+ },
+ { emptyQueryVisible: true },
+ ),
+ )
+ }
+
for (const [value, label] of Object.entries(activityKindLabels)) {
seeds.push(
createOmniboxSuggestionSeed(
@@ -153,6 +197,19 @@ export const applyActivityOmniboxSuggestion = ({
state: ActivityListState
suggestionId: string
}): ActivityOmniboxAction => {
+ if (suggestionId.startsWith('q:')) {
+ const { trimmed } = normalizeContactSearchQuery(suggestionId.slice(2))
+ const q = isContactSearchQueryReady(trimmed) ? trimmed : undefined
+ return { kind: 'url', state: withPageReset({ ...state, q }) }
+ }
+
+ if (suggestionId.startsWith('tab:')) {
+ const value = suggestionId.slice(4) as ActivityTab
+ if (!activityTabs.includes(value)) return { kind: 'url', state }
+ const next = state.tab === value ? setTab(state, 'proximos') : setTab(state, value)
+ return { kind: 'url', state: next }
+ }
+
if (suggestionId.startsWith('kind:')) {
const value = suggestionId.slice(5)
const next =
@@ -190,6 +247,12 @@ export const removeActivityOmniboxChip = ({
state: ActivityListState
chipId: string
}): ActivityOmniboxAction => {
+ if (chipId === 'q') return { kind: 'url', state: withPageReset({ ...state, q: undefined }) }
+
+ if (chipId.startsWith('tab:')) {
+ return { kind: 'url', state: setTab(state, 'proximos') }
+ }
+
if (chipId.startsWith('kind:')) {
return { kind: 'url', state: setExclusiveField(state, 'kind', undefined) }
}
diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts
index b8dd55ce..a42236fa 100644
--- a/src/utilities/activityUi.ts
+++ b/src/utilities/activityUi.ts
@@ -1,9 +1,11 @@
import type { Where } from 'payload'
+import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
+ normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
@@ -26,6 +28,7 @@ export const activityTabLabels: Record = {
export type ActivityListState = {
page: number
tab: ActivityTab
+ q?: string
kind?: ActivityKind
status?: ActivityStatus
municipality?: number
@@ -33,7 +36,7 @@ export type ActivityListState = {
type RawSearchParams = CampaignListRawSearchParams
-const activityListParamNames = ['tab', 'kind', 'status', 'municipality', 'page'] as const
+const activityListParamNames = ['q', 'tab', 'kind', 'status', 'municipality', 'page'] as const
const activityListParamNameSet = new Set(activityListParamNames)
@@ -46,8 +49,15 @@ const isActivityKind = (value: string | undefined): value is ActivityKind =>
const isActivityStatus = (value: string | undefined): value is ActivityStatus =>
activityStatuses.includes(value as ActivityStatus)
+const isActivityListSearchReady = (raw: string | undefined): string | undefined => {
+ if (!raw) return undefined
+ const { trimmed } = normalizeContactSearchQuery(raw)
+ return isContactSearchQueryReady(trimmed) ? trimmed : undefined
+}
+
export const parseActivityListParams = (params: RawSearchParams): ActivityListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
+ const q = isActivityListSearchReady(normalizedText(firstValue(params.q)))
const rawTab = firstValue(params.tab)
const tab = isActivityTab(rawTab) ? rawTab : 'proximos'
const rawKind = firstValue(params.kind)
@@ -59,6 +69,7 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
return {
page: rawPage ?? 1,
tab,
+ ...(q ? { q } : {}),
...(kind ? { kind } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
@@ -68,6 +79,15 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
export const buildActivityListWhere = (state: ActivityListState, now: Date): Where => {
const filters: Where[] = []
+ if (state.q) {
+ const q = isActivityListSearchReady(state.q)
+ if (q) {
+ filters.push({
+ or: [{ title: { contains: q } }, { 'responsible.name': { contains: q } }],
+ })
+ }
+ }
+
if (state.kind) filters.push({ kind: { equals: state.kind } })
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
@@ -91,6 +111,7 @@ export const buildActivityListSearchParams = (
): URLSearchParams => {
const canonicalState = parseActivityListParams({
page: String(page),
+ q: state.q,
tab: state.tab,
kind: state.kind,
status: state.status,
@@ -98,6 +119,7 @@ export const buildActivityListSearchParams = (
})
const params = new URLSearchParams()
+ if (canonicalState.q) params.set('q', canonicalState.q)
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
if (canonicalState.kind) params.set('kind', canonicalState.kind)
if (canonicalState.status) params.set('status', canonicalState.status)
diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts
index a1b87609..e91cab15 100644
--- a/tests/unit/activityUi.unit.spec.ts
+++ b/tests/unit/activityUi.unit.spec.ts
@@ -39,6 +39,12 @@ describe('parseActivityListParams', () => {
expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12)
expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined()
})
+
+ it('parses and trims q', () => {
+ expect(parseActivityListParams({ q: ' comício ' }).q).toBe('comício')
+ expect(parseActivityListParams({ q: ' ' }).q).toBeUndefined()
+ expect(parseActivityListParams({ q: 'a' }).q).toBeUndefined()
+ })
})
describe('buildActivityListWhere (tab → where matrix)', () => {
@@ -81,6 +87,19 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
],
})
})
+
+ it('filters by title or responsible name when q is set', () => {
+ const where = buildActivityListWhere({ page: 1, tab: 'proximos', q: 'Maria' }, NOW)
+ expect(where).toEqual({
+ and: [
+ {
+ or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }],
+ },
+ { status: { in: ['planejado', 'confirmado'] } },
+ { startAt: { greater_than_equal: NOW.toISOString() } },
+ ],
+ })
+ })
})
describe('buildActivityListSearchParams', () => {
@@ -95,11 +114,14 @@ describe('buildActivityListSearchParams', () => {
const params = buildActivityListSearchParams({
page: 1,
tab: 'todos',
+ q: 'comício',
kind: 'caminhada',
status: 'cancelado',
municipality: 9,
})
- expect(params.toString()).toBe('tab=todos&kind=caminhada&status=cancelado&municipality=9')
+ expect(params.toString()).toBe(
+ 'q=com%C3%ADcio&tab=todos&kind=caminhada&status=cancelado&municipality=9',
+ )
})
})
diff --git a/tests/unit/listOmniboxB128.unit.spec.ts b/tests/unit/listOmniboxB128.unit.spec.ts
index 861318e6..69c4a944 100644
--- a/tests/unit/listOmniboxB128.unit.spec.ts
+++ b/tests/unit/listOmniboxB128.unit.spec.ts
@@ -10,7 +10,10 @@ import {
import {
applyActivityOmniboxSuggestion,
buildActivityOmniboxChips,
+ buildActivityOmniboxSuggestionSeeds,
clearActivityOmnibox,
+ filterActivityOmniboxSuggestions,
+ removeActivityOmniboxChip,
} from '@/utilities/activityOmnibox'
import { parseActivityListParams } from '@/utilities/activityUi'
import { parseAdvisorListParams } from '@/utilities/advisor/advisorListUrl'
@@ -189,6 +192,48 @@ describe('list omnibox adapters (B128)', () => {
expect(applied.kind).toBe('url')
})
+ it('activity applies window preset and search (B138)', () => {
+ const base = parseActivityListParams({})
+ const seeds = buildActivityOmniboxSuggestionSeeds({ tab: base.tab, municipalityOptions: [] })
+ const suggestions = filterActivityOmniboxSuggestions(seeds, 'realizados')
+ expect(suggestions.some((entry) => entry.id === 'tab:realizados')).toBe(true)
+
+ const tabApplied = applyActivityOmniboxSuggestion({
+ state: base,
+ suggestionId: 'tab:realizados',
+ })
+ expect(tabApplied.kind).toBe('url')
+ if (tabApplied.kind === 'url') expect(tabApplied.state.tab).toBe('realizados')
+
+ const searchApplied = applyActivityOmniboxSuggestion({
+ state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
+ suggestionId: 'q:comício',
+ })
+ expect(searchApplied.kind).toBe('url')
+ if (searchApplied.kind === 'url') {
+ expect(searchApplied.state.q).toBe('comício')
+ expect(searchApplied.state.status).toBe('planejado')
+ }
+
+ const leavingTodos = applyActivityOmniboxSuggestion({
+ state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
+ suggestionId: 'tab:realizados',
+ })
+ if (leavingTodos.kind === 'url') expect(leavingTodos.state.status).toBeUndefined()
+
+ const chips = buildActivityOmniboxChips({
+ state: parseActivityListParams({ tab: 'realizados', q: 'Maria' }),
+ municipalityLabelsById: new Map(),
+ })
+ expect(chips.map((chip) => chip.id)).toEqual(['q', 'tab:realizados'])
+
+ const removedTab = removeActivityOmniboxChip({
+ state: parseActivityListParams({ tab: 'realizados' }),
+ chipId: 'tab:realizados',
+ })
+ if (removedTab.kind === 'url') expect(removedTab.state.tab).toBe('proximos')
+ })
+
it('demand toggles exclusive status, kind and search; preserves activity on clear', () => {
const state = parseDemandListParams({
status: 'aberta',