B51: add Atividades group to staff home search.
Staff can find activities by title with scoped access, municipality+date secondary, and word-start matching on the existing home-search POST.
This commit is contained in:
parent
6861ee1676
commit
67058d0027
15 changed files with 317 additions and 23 deletions
|
|
@ -7,7 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Assessores); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B51** atividades.
|
||||
- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Atividades); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B53** demandas.
|
||||
- **B72 entregue em código (2026-07-29):** gap horizontal da strip de ações do Início (UX-1) — `CampaignHomeActionStrip` `<ul>` `gap-6` → `gap-4` (16px); `w-[4.75rem]`, círculo 48px, `snap-x`, scrollbar oculta e pan B67 ✓ inalterados. Sem migration/action/URL. Unit strip 11/11 em `campaignHomeActionButton.unit.spec.tsx`. Plano: [`espacamento-strip-acoes-inicio.md`](../../../docs/plans/espacamento-strip-acoes-inicio.md).
|
||||
- **B65 entregue em código (2026-07-29):** âncora inferior mobile no Início — cadeia `h-full min-h-0` em `CampaignPageShell` + `CampaignHomeStaffChrome` + `CampaignHomeLayout`; wrapper `data-slot="home-dock"` agrupa strip B45 + busca B47; spacer `flex-1` empurra o dock acima da bottom nav (`pb-24` intacto); `md+` inalterado; modo focado B47/B66 intacto; sem `fixed`/`mt-auto`. Unit `campaignHomeLayout.unit.spec.tsx` (6 casos). Plano: [`ancorar-busca-acoes-inicio-mobile.md`](../../../docs/plans/ancorar-busca-acoes-inicio-mobile.md). Desbloqueia animação idle de **B66**. Débito adiado: ajuste fino `pb-24` vs altura real da bottom nav se gap >~8 px em device real.
|
||||
- **B59 entregue em código (2026-07-29):** chassis UX-1 dos wizards de ação — `CampaignWizardShell` (`shared/`: Voltar só mobile com rótulo visível, caption sticky central de município, `contentAlign`, foco no `h1` ao trocar passo); rotas staff `/campanha/acoes/<slug>` (cinco entradas espelhando o catálogo B45, passo ponte "Em qual município?" até **B60**); `campaignActionRoutes.ts` + hrefs em `toHomeActionButtonProps`; layout `acoes/` com gate `staff`. Sem migration/action/Consent. Unit `campaignActionRoutes`, `campaignWizardShell`, `campaignHomeActions` atualizado; e2e Início → `atualizar-votos`. Plano: [`chassis-wizard-campanha.md`](../../../docs/plans/chassis-wizard-campanha.md). Desbloqueia **B60** / **B61** / **B63** / **B64**.
|
||||
|
|
@ -15,8 +15,9 @@ alwaysApply: false
|
|||
- **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44.
|
||||
- **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md).
|
||||
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
|
||||
- **B49 entregue em código (2026-07-29):** segundo grupo da busca global do Início — **Lideranças** via `searchHomeLeaderships` + `HomeSearchLeadershipGroup` (entre Municípios e Assessores); payload `leaderships` no mesmo `POST /campanha/home-search`; empty state considera todos os grupos (`homeSearchHasAnyHits`); linha → `/campanha/liderancas/[id]`; `HomeSearchHitRow` compartilhado. Plano: [`busca-global-resultados-liderancas.md`](../../../docs/plans/busca-global-resultados-liderancas.md). Próximo: **B51** atividades.
|
||||
- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md). ~~Próximo: **B51** atividades.~~ **B52** ✓ dobradinhas; **B51** atividades pendente.
|
||||
- **B49 entregue em código (2026-07-29):** segundo grupo da busca global do Início — **Lideranças** via `searchHomeLeaderships` + `HomeSearchLeadershipGroup` (entre Municípios e Assessores); payload `leaderships` no mesmo `POST /campanha/home-search`; empty state considera todos os grupos (`homeSearchHasAnyHits`); linha → `/campanha/liderancas/[id]`; `HomeSearchHitRow` compartilhado. Plano: [`busca-global-resultados-liderancas.md`](../../../docs/plans/busca-global-resultados-liderancas.md).
|
||||
- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md).
|
||||
- **B51 entregue em código (2026-07-29):** grupo **Atividades** na busca global do Início — `searchHomeActivities` (staff-only; word-start no `title`; access `activity` com `overrideAccess: false`; rascunhos incluídos; cap 25; desempate `startAt` desc); `HomeSearchActivityGroup` após Assessores; secundário `"{município} · {quando}"`; int `homeSearchActivities.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-atividades.md`](../../../docs/plans/busca-global-resultados-atividades.md).
|
||||
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).
|
||||
- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~
|
||||
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` com slots `data-slot="home-actions"` / `home-search` (ordem mobile ações→busca; desktop busca→ações). Âncora inferior corrigida por **B65** ✓. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
# Busca global — resultados Atividades
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-29)
|
||||
Atualizado em: 2026-07-29 — as-built: `searchHomeActivities` (`isStaffCampaignRole` gate; word-start no `title`; access `activity` com `overrideAccess: false`; rascunhos incluídos; cap 25; desempate `startAt` desc); `HomeSearchActivityHit` + campo `activities` no `POST /campanha/home-search`; `HomeSearchActivityGroup` após Assessores; secundário `"{município} · {quando}"` (`formatBahiaDateTimeLabel` / `"Data a definir"`); empty via `homeSearchHasAnyHits`; int `homeSearchActivities.int.spec.ts`; sem migration.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B51 — busca global)
|
||||
Impeccable: B — grupo de hits no slot B47
|
||||
Appetite: ~0,5–0,75 dia eng; loader + grupo; share = B55
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-29 — ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B51–B55; wizards B60–B64.
|
||||
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards B60–B64.
|
||||
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
### Demais itens abertos
|
||||
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → **B71** (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → ~~**B50**~~ ✓ → ~~**B52**~~ ✓ → **B51** · **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**B64** · **B62** → **B63** · **B69** → **B70** (A4 liderança).
|
||||
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → **B71** (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**B64** · **B62** → **B63** · **B69** → **B70** (A4 liderança).
|
||||
- ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md)
|
||||
- ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md)
|
||||
- ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md)
|
||||
|
|
@ -107,8 +107,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
- **B72** ~~Reduzir espaçamento horizontal da strip de ações (`gap-6` → `gap-4`)~~ — **entregue 2026-07-29** (`CampaignHomeActionStrip` `<ul>` `gap-4`; botões `w-[4.75rem]` e pan B67 ✓ inalterados) · Depende de B58 ✓ / B67 ✓ (soft) · ~0,25d · Janela 1 · Impeccable B · [plano](plans/espacamento-strip-acoes-inicio.md)
|
||||
- ~~**B49** Busca — resultados Lideranças~~ — **entregue 2026-07-29** (`searchHomeLeaderships`, `HomeSearchLeadershipGroup`, `leaderships` no POST `/campanha/home-search`; word-start; assessor scoped; municípios no secundário; cap 25; sem WA — **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-liderancas.md)
|
||||
- ~~**B50** Busca — resultados Assessores (access B19)~~ — **entregue 2026-07-29** (`searchHomeAdvisors` + `HomeSearchAdvisorGroup`; unrestricted only; word-start no nome; contagem de municípios na linha; `POST /campanha/home-search` agrega `advisors`) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-assessores.md)
|
||||
- **B51** Busca — resultados Atividades · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-atividades.md)
|
||||
- **B52** ~~Busca — resultados Dobradinhas~~ — **entregue 2026-07-29** (`searchHomeStateDeputies` + `HomeSearchStateDeputyGroup`; staff incl. assessor; word-start em nome ou partido; secundário partido · contagem de municípios; `POST /campanha/home-search` agrega `stateDeputies`; WA = **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-dobradinhas.md)
|
||||
- ~~**B51** Busca — resultados Atividades~~ — **entregue 2026-07-29** (`searchHomeActivities` + `HomeSearchActivityGroup`; word-start no título; access `activity`; rascunhos no escopo; secundário município + data; cap 25; `POST /campanha/home-search` agrega `activities`; sem WA — **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-atividades.md)
|
||||
- ~~**B52** Busca — resultados Dobradinhas~~ — **entregue 2026-07-29** (`searchHomeStateDeputies` + `HomeSearchStateDeputyGroup`; staff incl. assessor; word-start em nome ou partido; secundário partido · contagem de municípios; `POST /campanha/home-search` agrega `stateDeputies`; WA = **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-dobradinhas.md)
|
||||
- **B53** Busca — resultados Demandas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-demandas.md)
|
||||
- **B54** Busca — layout resultados (mobile empilhado; tablet 2 col; desktop 3 col; cap ao viewport; prioridade Municípios) · Depende de B47 · soft B48 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-layout-resultados.md)
|
||||
- **B55** Busca — ações de linha (WA em lideranças/assessores/dobradinhas; share nativo mobile / WA no desktop em demandas/atividades) · Depende de B47 + ≥1 de B49–B53 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-acoes-linha.md)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { homeSearchBodySchema } from '@/lib/schemas/homeSearch'
|
|||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions'
|
||||
import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities'
|
||||
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
|
||||
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
|
||||
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
|
||||
|
|
@ -30,16 +31,19 @@ export const POST = campaignJsonMutationRoute(
|
|||
return NextResponse.json<HomeSearchSuccessResponse>(result)
|
||||
}
|
||||
|
||||
const [municipalityResult, advisors, leaderships, stateDeputies] = await Promise.all([
|
||||
searchHomeMunicipalities(payload, actor, body.query),
|
||||
searchHomeAdvisors(payload, actor, body.query),
|
||||
searchHomeLeaderships(payload, actor, body.query),
|
||||
searchHomeStateDeputies(payload, actor, body.query),
|
||||
])
|
||||
const [municipalityResult, advisors, leaderships, activities, stateDeputies] =
|
||||
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),
|
||||
])
|
||||
return NextResponse.json<HomeSearchSuccessResponse>({
|
||||
...municipalityResult,
|
||||
advisors,
|
||||
leaderships,
|
||||
activities,
|
||||
stateDeputies,
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHom
|
|||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
|
||||
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
|
||||
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
|
||||
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
|
|
@ -51,6 +52,7 @@ export default async function CampaignHomePage() {
|
|||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
<HomeSearchActivityGroup />
|
||||
<HomeSearchStateDeputyGroup />
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,8 @@ import Link from 'next/link'
|
|||
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { activityKindLabels } from '@/lib/schemas/activity'
|
||||
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
|
||||
|
||||
const formatWhen = (activity: ActivityListViewModel): string =>
|
||||
activity.startAt ? formatBahiaDateTimeLabel(activity.startAt) : 'Data a definir'
|
||||
import { formatActivityWhenLabel, type ActivityListViewModel } from '@/utilities/activityViewModels'
|
||||
|
||||
export const ActivityCard = ({ activity }: { activity: ActivityListViewModel }) => (
|
||||
<Card>
|
||||
|
|
@ -31,7 +27,7 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
|
|||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<CalendarIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>{formatWhen(activity)}</span>
|
||||
<span>{formatActivityWhenLabel(activity.startAt)}</span>
|
||||
</div>
|
||||
{activity.locationLabel ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
'use client'
|
||||
|
||||
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchActivityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
export const HomeSearchActivityGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
if (!homeSearchActivityGroupHasHits(results.data)) return null
|
||||
|
||||
const { activities } = results.data
|
||||
|
||||
return (
|
||||
<section aria-label="Atividades" className="flex flex-col gap-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Atividades
|
||||
</h2>
|
||||
<ul className="flex flex-col">
|
||||
{activities.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/atividades/${hit.slug}`}
|
||||
primary={hit.title}
|
||||
secondary={hit.secondary}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -39,6 +39,13 @@ export type HomeSearchStateDeputyHit = {
|
|||
municipalityCount: number
|
||||
}
|
||||
|
||||
export type HomeSearchActivityHit = {
|
||||
id: number
|
||||
slug: string
|
||||
title: string
|
||||
secondary: string
|
||||
}
|
||||
|
||||
type HomeSearchResultKind = 'search' | 'suggest'
|
||||
|
||||
export type HomeSearchSuccessResponse = {
|
||||
|
|
@ -49,6 +56,7 @@ export type HomeSearchSuccessResponse = {
|
|||
advisors: HomeSearchAdvisorHit[]
|
||||
leaderships: HomeSearchLeadershipHit[]
|
||||
stateDeputies: HomeSearchStateDeputyHit[]
|
||||
activities: HomeSearchActivityHit[]
|
||||
}
|
||||
|
||||
export const homeSearchMunicipalityGroupHasHits = (
|
||||
|
|
@ -63,6 +71,10 @@ export const homeSearchStateDeputyGroupHasHits = (
|
|||
data: Pick<HomeSearchSuccessResponse, 'stateDeputies'>,
|
||||
): boolean => data.stateDeputies.length > 0
|
||||
|
||||
export const homeSearchActivityGroupHasHits = (
|
||||
data: Pick<HomeSearchSuccessResponse, 'activities'>,
|
||||
): boolean => data.activities.length > 0
|
||||
|
||||
export const formatHomeSearchMunicipalityCount = (count: number): string => {
|
||||
if (count === 1) return '1 município'
|
||||
return `${count} municípios`
|
||||
|
|
@ -86,4 +98,5 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
|
|||
homeSearchMunicipalityGroupHasHits(data) ||
|
||||
data.advisors.length > 0 ||
|
||||
homeSearchLeadershipGroupHasHits(data) ||
|
||||
homeSearchStateDeputyGroupHasHits(data)
|
||||
homeSearchStateDeputyGroupHasHits(data) ||
|
||||
homeSearchActivityGroupHasHits(data)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import type { ActivityOrigin } from '@/lib/schemas/activity'
|
||||
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
|
||||
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
|
||||
|
|
@ -16,6 +17,18 @@ export const activityMunicipalitySummary = (
|
|||
? { id: municipality.id, name: municipality.name, slug: municipality.slug }
|
||||
: null
|
||||
|
||||
export const formatActivityWhenLabel = (startAt: string | null | undefined): string =>
|
||||
startAt ? formatBahiaDateTimeLabel(startAt) : 'Data a definir'
|
||||
|
||||
export const formatActivityHomeSearchSecondary = (
|
||||
municipalityName: string | null,
|
||||
startAt: string | null | undefined,
|
||||
): string => {
|
||||
const whenLabel = formatActivityWhenLabel(startAt)
|
||||
if (!municipalityName) return whenLabel
|
||||
return `${municipalityName} · ${whenLabel}`
|
||||
}
|
||||
|
||||
/** Short "where" line, e.g. "Itabuna · Feira do Malhado". */
|
||||
const formatActivityLocationLabel = ({
|
||||
municipalityName,
|
||||
|
|
|
|||
|
|
@ -82,5 +82,6 @@ export const loadHomeSearchSuggestions = async (
|
|||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
88
src/utilities/homeSearch/searchHomeActivities.ts
Normal file
88
src/utilities/homeSearch/searchHomeActivities.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
|
||||
import type { HomeSearchActivityHit } from '@/lib/campaignHomeSearchHits'
|
||||
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
compareHomeSearchNameRelevance,
|
||||
normalizeHomeSearchName,
|
||||
} from '@/lib/homeSearchMunicipalityMatch'
|
||||
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type { Activity, CampaignUser } from '@/payload-types'
|
||||
import {
|
||||
activityMunicipalitySummary,
|
||||
formatActivityHomeSearchSecondary,
|
||||
} from '@/utilities/activityViewModels'
|
||||
|
||||
const HOME_SEARCH_ACTIVITY_HIT_CAP = 25
|
||||
|
||||
export const searchHomeActivities = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
rawQuery: string,
|
||||
): Promise<HomeSearchActivityHit[]> => {
|
||||
if (!isStaffCampaignRole(user.role)) {
|
||||
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
|
||||
}
|
||||
|
||||
const query = normalizeHomeSearchRaw(rawQuery)
|
||||
if (!homeSearchQueryIsActive(query)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedQuery = normalizeHomeSearchName(query)
|
||||
|
||||
// List search uses `contains`; home search refines to word-start in memory (B51).
|
||||
const result = await payload.find({
|
||||
collection: 'activity',
|
||||
where: { title: { contains: query } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
startAt: true,
|
||||
municipality: true,
|
||||
},
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const candidates: Array<{
|
||||
normalizedName: string
|
||||
tieBreakDesc: number
|
||||
hit: HomeSearchActivityHit
|
||||
}> = []
|
||||
|
||||
for (const doc of result.docs as Activity[]) {
|
||||
const normalizedName = normalizeHomeSearchName(doc.title)
|
||||
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const municipalityName = activityMunicipalitySummary(doc.municipality)?.name ?? null
|
||||
|
||||
const tieBreakDesc = doc.startAt ? Date.parse(doc.startAt) : 0
|
||||
|
||||
candidates.push({
|
||||
normalizedName,
|
||||
tieBreakDesc: Number.isFinite(tieBreakDesc) ? tieBreakDesc : 0,
|
||||
hit: {
|
||||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
secondary: formatActivityHomeSearchSecondary(municipalityName, doc.startAt),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return candidates
|
||||
.sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery))
|
||||
.slice(0, HOME_SEARCH_ACTIVITY_HIT_CAP)
|
||||
.map((row) => row.hit)
|
||||
}
|
||||
|
|
@ -38,7 +38,8 @@ export const searchHomeAdvisors = async (
|
|||
pagination: false,
|
||||
sort: 'name',
|
||||
select: { name: true },
|
||||
overrideAccess: true,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const matched = result.docs
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
|
|||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
})
|
||||
|
||||
export const searchHomeMunicipalities = async (
|
||||
|
|
@ -116,5 +117,6 @@ export const searchHomeMunicipalities = async (
|
|||
advisors: [],
|
||||
leaderships: [],
|
||||
stateDeputies: [],
|
||||
activities: [],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
tests/int/homeSearchActivities.int.spec.ts
Normal file
138
tests/int/homeSearchActivities.int.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createActivityRecord } from '@/app/(campaign)/campanha/actions/activity'
|
||||
import config from '@/payload.config'
|
||||
import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const validActivityInput = (municipalityId: number, title: string) => ({
|
||||
title,
|
||||
kind: 'caminhada' as const,
|
||||
status: 'planejado' as const,
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipalityId,
|
||||
locality: 'Centro',
|
||||
})
|
||||
|
||||
describe('searchHomeActivities (B51)', () => {
|
||||
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 searchHomeActivities(payload, coordinator, 'a')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('matches activities 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('Caminhada Zeca Centro')
|
||||
const activity = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipality.id, title),
|
||||
)
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
const result = await searchHomeActivities(payload, coordinator, 'Caminhada')
|
||||
const hit = result.find((row) => row.id === activity.id)
|
||||
expect(hit?.title).toBe(title)
|
||||
expect(hit?.slug).toBe(activity.slug)
|
||||
expect(hit?.secondary).toContain('Cairu')
|
||||
})
|
||||
|
||||
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('Feira Comunitária')
|
||||
const activity = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(municipality.id, title),
|
||||
)
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
const result = await searchHomeActivities(payload, coordinator, 'eira')
|
||||
expect(result.some((row) => row.id === activity.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('scopes activity 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-atividade')
|
||||
const inScope = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(administered.id, `Caminhada ${scopeToken} Cairu`),
|
||||
)
|
||||
fixtures.own('activity', inScope.id)
|
||||
|
||||
const outOfScope = await createActivityRecord(
|
||||
payload,
|
||||
coordinator,
|
||||
validActivityInput(other.id, `Caminhada ${scopeToken} Feira`),
|
||||
)
|
||||
fixtures.own('activity', outOfScope.id)
|
||||
|
||||
const advisorResult = await searchHomeActivities(payload, advisor, scopeToken)
|
||||
expect(advisorResult.map((hit) => hit.id)).toEqual([inScope.id])
|
||||
|
||||
const coordinatorResult = await searchHomeActivities(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('includes drafts without startAt with Data a definir secondary', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality('cairu')
|
||||
const title = fixtures.value('Rascunho sem data')
|
||||
const activity = await createActivityRecord(payload, coordinator, {
|
||||
title,
|
||||
kind: 'caminhada',
|
||||
status: 'rascunho',
|
||||
municipality: municipality.id,
|
||||
})
|
||||
fixtures.own('activity', activity.id)
|
||||
|
||||
const result = await searchHomeActivities(payload, coordinator, 'Rascunho')
|
||||
const hit = result.find((row) => row.id === activity.id)
|
||||
expect(hit?.secondary).toContain('Data a definir')
|
||||
expect(hit?.secondary).toContain('Cairu')
|
||||
})
|
||||
|
||||
it('rejects leaders from home search', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
|
||||
await expect(searchHomeActivities(payload, leader, 'Caminhada')).rejects.toThrow(
|
||||
/equipe de campanha/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue