feat(campanha): C15 — FullCalendar em /campanha/agenda

Issue #390 — entrada operacional staff com FullCalendar v7 route-local
(semana/mês/lista), filtros compartilháveis por município/deputado/tag,
criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC,
transação e lock por atividade.

- /campanha/atividades e giros permanecem como lista/fluxos legados
- Sem migration/collection nova; reutiliza activity de C14
- Fuso Bahia, range exclusivo, erro/revert, loading/empty testados
- Leader negado, advisor limitado pelo access atual
- E2E cobre filtros, drag, mobile 390x844 e slot prefill
- Int specs cobrem RBAC, concorrência e defense-in-depth

Impl: docs/plans/fullcalendar-agenda-campanha-impl.md
This commit is contained in:
Francisco Solla 2026-08-07 04:37:36 -03:00
parent 21159a5602
commit 59983b33f7
36 changed files with 2228 additions and 47 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md).
**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md).
**Recently resolved (2026-08-05):** **B158** (Issue #364) — `/campanha/municipios` troca cards↔tabela e revela colunas pela largura do painel de conteúdo com named container queries (48–84rem), sem rolagem horizontal; Território virou sublinha de Município, Tendência/Sinal preservam um único editor na tabela quando ocultos manualmente, e `campaign_columns=municipios:__none__` diferencia “todas visíveis” dos defaults. E2E mede cada limiar em Chromium e mantém um caso iPad-sized no projeto WebKit focado. Sem migration ou mudança de RBAC. Intenção [`colunas-responsivas-municipios.md`](docs/plans/colunas-responsivas-municipios.md); impl [`colunas-responsivas-municipios-impl.md`](docs/plans/colunas-responsivas-municipios-impl.md).

View file

@ -0,0 +1,180 @@
# Impl: FullCalendar em `/campanha/agenda`
Status: aprovado
Atualizado em: 2026-08-07
Issue: #390
Intenção: docs/plans/fullcalendar-agenda-campanha.md
Appetite restante: ~1,5–2 dias eng (herdado; sem ampliar para sync, recorrência ou taxonomia)
## Leitura da intenção
- **Outcome:** staff opera a semana em `/campanha/agenda`: filtra o recorte que pode ver, cria pelo slot, abre o detalhe e remarca compromissos permitidos. Semana é a vista inicial; mês e lista continuam a um clique.
- **O que NÃO negociar:** filtros por município, deputado presente e tag; município obrigatório; coordinator/candidate são os únicos que remarcam compromisso com deputado; advisor preserva o escopo de atividades atual; leader continua em lockdown; C16 fica dona de import/sync.
- **O que reavaliar:** a hipótese previa novos componentes/actions de agenda sem saber que C14 já consolidou `activity`, filtros, formulário e o gate no owner atual. Também deixava redirect das rotas antigas em aberto, mas giros ainda geram atividades sem `startAt`, que não cabem no FullCalendar.
## Abordagem recomendada
```mermaid
flowchart LR
U[URL /campanha/agenda<br/>municipality + deputyPresent + tag] --> R[RSC staff-gated<br/>opções acessíveis]
R --> C[Ilha FullCalendar v7<br/>week / month / list]
C -->|events function: intervalo ativo| A[server action de leitura]
A --> L[activityPageData<br/>range limitado + overrideAccess false]
L --> D[DTO mínimo por evento]
C -->|slot| F[/atividades/nova<br/>horário + município prefilled]
C -->|evento| E[/atividades/slug<br/>detalhe existente]
C -->|drop / resize| W[rescheduleActivity]
W --> T[transaction + lock activity:id<br/>fresh actor + overrideAccess false]
T --> H[hooks Activity<br/>schedule + deputado]
W -->|falha| X[FullCalendar revert + feedback]
```
### Decisões de engenharia
**1. FullCalendar v7 como uma dependência route-local**
Opções: A) `@fullcalendar/react@7.0.2` com subpaths v7 | B) React v7 com plugins separados v6 | C) calendário próprio.
Recomendação: **A** — instalar `@fullcalendar/react@7.0.2` e o peer `temporal-polyfill`, importando `daygrid`, `timegrid`, `list`, `interaction`, locale e tema pelos subpaths do próprio pacote. É MIT, declara React 19 e evita misturar majors. A ilha só entra no chunk de `/campanha/agenda`; `next/dynamic` com `ssr: false` entra apenas se o build provar que o pacote exige browser no import.
Rejeitadas: B — os pacotes separados publicados estão em v6.1.21 e criam incompatibilidade concreta; C — reimplementa seleção, week/month/list, drag, resize, teclado e touch fora do appetite.
**2. `activity` permanece o único modelo; sem migration**
Opções: A) calendar sobre `activity` | B) collection `agenda` | C) materialização paralela de eventos.
Recomendação: **A** — C14 já entregou `startAt`, `endAt`, `tags`, `deputyPresent`, município, status, índices e access.
Rejeitadas: B/C — duplicam ownership, exigem sincronização e não acrescentam produto.
**3. Feed dinâmico por server action POST e intervalo visível**
Opções: A) server action chamada pelo `events` function | B) GET JSON feed | C) carregar todo o histórico no RSC/browser.
Recomendação: **A** — o FullCalendar pede `[start, end)` ao navegar; a action autentica novamente e chama um loader no owner `activityPageData`. POST evita que o service worker de `/campanha` grave um GET autenticado em Cache Storage. O range é validado, exclusivo e limitado a 45 dias.
Rejeitadas: B — o service worker atual faz `cache.put` de GETs sob `/campanha`; exigiria outra exceção de segurança; C — payload sem limite e exposição desnecessária.
O `where` preserva eventos que atravessam a borda da vista:
```text
startAt < rangeEnd
AND (endAt > rangeStart OR (endAt ausente AND startAt >= rangeStart))
```
Município, deputado presente e tag combinam entre si com AND. Atividades sem data continuam fora do calendário e disponíveis na lista antiga.
**4. Contrato de filtro compartilhável, porém uma tag exata na v1**
Opções: A) `?municipality=12&deputyPresent=1&tag=Comício` | B) estado local | C) multi-tag já nesta fatia.
Recomendação: **A** — estender o owner client-safe `activityUi.ts` com parse, canonicalização, href e `where` da agenda. Ausência de `deputyPresent` significa todos; `1` significa somente com deputado. A tag é uma seleção exata entre valores acessíveis, preservando o contrato já entregue por C14 e deixando um link estável para C16.
Rejeitadas: B — não é compartilhável nem consumível por C16; C — exige fechar semântica OR/AND e UI de multisseleção sem necessidade para o aceite. Gatilho: C16 ou evidência da mesa de que um link precisa unir várias tags.
**5. Remarcação estreita, não o update completo**
Opções: A) action `rescheduleActivity` que aceita só `id/startAt/endAt` | B) chamar `updateActivity` | C) confiar apenas em `editable` no browser.
Recomendação: **A** — schema próprio, transação, fresh actor, advisory lock `activity:<id>`, leitura da linha com `overrideAccess: false` e update somente do horário. A política de deputado fica nomeada em `access/activities.ts`, é reutilizada pelo DTO/action/hook e o hook passa a considerar o estado original ou resultante de `deputyPresent`.
Rejeitadas: B — abre campos, demandas e parsing alheios ao gesto; C — affordance não é autorização.
A action retorna união serializável `{ ok: true } | { ok: false, message }`. Falha chama `revert()` no `eventDrop`/`eventResize`; a recusa de deputado usa uma constante segura única, e demais falhas viram mensagem genérica.
**6. Rotas antigas ficam vivas; agenda vira a entrada primária**
Opções: A) nav e links primários para `/agenda`, mantendo `/atividades` | B) redirect global `/atividades` → `/agenda` | C) mover detalhe/form para `/agenda`.
Recomendação: **A** — nav “Agenda”, page chrome, quick actions, dossier e navegação assistida apontam à agenda; detalhe, criação, edição, giros e a lista de cards continuam nos URLs atuais. `isCampaignNavActive` reconhece `/agenda` e a subárvore `/atividades` como uma vertical.
Rejeitadas: B — descarta `q/tab/status/page` e deixa rascunhos de giro sem data sem superfície; C — quebra links, notificações e bookmarks sem ganho.
**7. Detalhe existente em vez de drawer paralelo**
Opções: A) evento navega para `/campanha/atividades/[slug]` | B) novo painel/drawer | C) modal com cópia parcial.
Recomendação: **A** — o detalhe já concentra tarefas, atualizações, resultado e edição, além de permanecer acessível por teclado via URL do evento.
Rejeitadas: B/C — duplicam dados e estados sem serem exigidos pelo aceite.
**8. Bahia é o fuso explícito e a página continua dona do scroll**
Opções: A) `timeZone: 'America/Bahia'`, instantes UTC no servidor e `height: 'auto'` | B) fuso local do dispositivo | C) calendário com altura fixa e segundo scroll vertical.
Recomendação: **A** — callbacks usam strings ISO com offset/UTC e o formulário continua convertendo via `campaignTime`. `height: 'auto'` evita scroll vertical aninhado no shell. A toolbar compacta mantém Semana/Mês/Lista visíveis; touch usa o long-press nativo, mas formulário e botões continuam sendo as alternativas de teclado/touch.
Rejeitadas: B — desloca compromisso de quem estiver fora da Bahia; C — disputa scroll com `CampaignContentScroll`.
### Componentes / mudanças
- **Dependências** (`package.json`, `pnpm-lock.yaml`): `@fullcalendar/react@7.0.2` + `temporal-polyfill`; não instalar plugins v6 separados nem pacote premium.
- **`activityAgendaRequestSchema` / `activityRescheduleSchema`** (`src/lib/schemas/activity.ts`): range ISO limitado, filtros canônicos e payload mínimo de remarcação; constante segura da recusa de deputado.
- **Agenda URL/query** (`src/utilities/activityUi.ts`): `ActivityAgendaState`, parse/build/resolve de `/campanha/agenda` e `buildActivityAgendaWhere`; a lista antiga mantém o contrato atual.
- **Access** (`src/utilities/access/activities.ts`, `src/utilities/campaignAccess.ts`): política nomeada de remarcação; coordinator/candidate liberados, advisor só em compromisso sem deputado, leader negado.
- **DTO** (`src/utilities/activityViewModels.ts`): select mínimo e `ActivityAgendaEvent` com id, title, URL/slug, start/end, status, tags, deputado, município/localidade e `canReschedule`; sem Contact, liderança, tarefas ou documento Payload inteiro.
- **Loaders** (`src/utilities/activityPageData.ts`): eventos do range com `user + overrideAccess: false`; tags conhecidas deduplicadas e ordenadas sobre atividades acessíveis; nenhuma cache longa para dado vivo de 2026.
- **Actions** (`src/app/(campaign)/campanha/actions/activity.ts`): `loadActivityAgendaEvents` e `rescheduleActivity`; remarcação sob `withPayloadTransaction` + `acquireTextAdvisoryLocks`.
- **Defense in depth** (`src/collections/Activity.ts`): reutilizar a política nomeada no gate C14 e avaliar deputado no doc original ou resultante; sem alteração de schema/migration.
- **Rota** (`src/app/(campaign)/campanha/(app)/agenda/page.tsx`, `loading.tsx`): gate `staff`, canonicalização da URL, opções de município/tags acessíveis, CTAs existentes e skeleton local.
- **Filtros** (`src/components/campaign/activity/ActivityAgendaFilters.tsx`): controles acessíveis de município, deputado e tag; URL via `useCampaignListFilterNavigation`; loading/pending no resultado, não só no controle.
- **Calendário** (`src/components/campaign/activity/ActivityAgenda.tsx` + CSS escopado): ilha FullCalendar com `timeGridWeek`, `dayGridMonth`, `listMonth`, `interaction`, pt-BR e tema adaptado aos tokens `data-theme='campaign'`; `events` assíncrono, empty/error/loading, event click, slot click, drop/resize e `revert()`.
- **Criação prefilled** (`atividades/nova/page.tsx`, `ActivityForm.tsx`): aceitar `startAt/endAt/municipality` validados; município só é pré-selecionado se estiver nas opções acessíveis; reutilizar a action/form atual.
- **Chrome e caminhos** (`campaignPaths.ts`, `campaignQuickActionPaths.ts`, `campaignPageChrome.ts`, `components/campaign/shell/nav.ts`): separar “home da agenda” do root de detalhe de atividades, manter quick actions e estado ativo coerentes.
- **Links primários** (`MunicipalityDossier.tsx`, `campaignNavigationUrls.ts`, cancelamento do form quando for criação): apontar à agenda preservando filtros equivalentes; giro continua retornando à lista antiga para expor atividades sem data.
- **Migration:** nenhuma. A migration C14 é história congelada e não será editada.
- **Access / Consent:** staff-only; leader fail-closed pelas barreiras de página/action/collection. Sem PII nova e sem Consent.
- **UI:** Impeccable D, mas dentro da linguagem atual: FullCalendar route-local, tokens da campanha, alvos de 44 px, estado não comunicado só por cor, evento com texto/ícone de deputado/status e CTA visível como alternativa ao clique/drag.
### Dados → forma
- **Forma escolhida:** time grid semanal como decisão operacional primária; month para visão de distribuição; list para varredura linear e mobile/acessibilidade.
- **Por quê:** o job é conflito/encaixe no tempo, não comparação analítica; cards continuam úteis apenas como fila legada de sem-data e busca avançada.
- **Rejeitadas:** tabela (perde simultaneidade), cards como default (estado atual que C15 substitui), resources por assessor (premium e anti-goal).
### Fontes oficiais verificadas
- React 17–19, instalação v7, plugins por subpath e integração Next: https://fullcalendar.io/docs/react
- Event source assíncrono e fim exclusivo do range: https://fullcalendar.io/docs/events-function
- Named time zone e Temporal: https://fullcalendar.io/docs/timeZone
- `eventDrop.revert()`: https://fullcalendar.io/docs/eventDrop
- `eventResize.revert()`: https://fullcalendar.io/docs/eventResize
- Editabilidade por evento e `url`: https://fullcalendar.io/docs/event-object
- Touch por long-press: https://fullcalendar.io/docs/touch
- `height: 'auto'` sem scrollbar interna: https://fullcalendar.io/docs/height
## Fases verificáveis
1. **Tracer / contrato + server (~35%)**
- Testes unitários primeiro para URL, range exclusivo/cap, combinação dos três filtros e política de remarcação.
- Schemas, policy helper, loader range/tags, DTO, action de leitura e action de remarcação.
- Testes de integração: coordinator/candidate movem compromisso com deputado; advisor move atividade comum do escopo, não move a do deputado; advisor fora do escopo e leader não leem/não movem; range e filtros não vazam linhas.
2. **UI funcional (~40%)**
- Dependências v7, rota, filtros e ilha route-local com semana/mês/lista.
- Slot → form prefilled; evento → detalhe; drop/resize → action + revert; loading/error/empty.
- Nav/chrome/quick actions/links primários, mantendo lista/detalhe/giros antigos.
3. **Craft, critique e polish (~15%)**
- Adaptar tema aos tokens, hierarquia de conteúdo e densidade da agenda.
- Browser/E2E em desktop e 390×844: toolbar, week/month/list, teclado, clique de evento, prefill e drag permitido; negação server-side fica pinada no int test.
- Confirmar que não há console error, scroll vertical aninhado nem indicador apenas por cor.
4. **Gates (~10%)**
- `pnpm gate:fast` durante a iteração.
- `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, testes int/e2e afetados e build local no fechamento.
- Push somente via `pnpm push`; PR Ready + auto-merge apenas com required checks verdes.
## Rabbit holes / Não escopo (engenharia)
- Sync Google, ICS/link de import e autenticação de feed: C16.
- Recorrência, resources, scheduler premium, calendários por assessor.
- Multi-tag OR/AND, filtros salvos, persistência de view/data no URL ou preferência de dispositivo.
- Drawer/modal de detalhe, quick-create paralelo e mudança das URLs de entidade.
- Tornar `startAt` NOT NULL: giros E13 mantêm drafts intencionalmente sem data.
- Corrigir em C15 defeitos adjacentes de C14 que não bloqueiam a agenda (escopo de município no create manual, status hidden no edit e política de notificações); revisar no `/simplify` e capturar débito se pontuar.
- Otimização raw SQL/distinct de tags antes de medida real; o loader acessível simples é a v1.
## Riscos e mitigação
- **Major incompatível:** v7 concentra plugins em `@fullcalendar/react`, enquanto pacotes separados estão em v6. Mitigação: pin único v7 + peer oficial; testes de build antes de craft.
- **Bundle:** week/month/list/interaction + Temporal são pesados. Mitigação: imports apenas na ilha da rota, DTO mínimo e inspeção do chunk no build; sem `locales-all` nem plugins premium.
- **Fuso:** native `Date` pode refletir o dispositivo mesmo com named zone. Mitigação: persistir UTC, usar `startStr/endStr` com offset e helpers Bahia; testes com instantes conhecidos.
- **RBAC:** editabilidade client pode ficar stale. Mitigação: fresh actor, Payload access com `overrideAccess: false`, policy no action/hook e `revert()` na UI.
- **Concorrência:** dois drags podem sobrescrever horário. Mitigação: advisory lock por atividade e desabilitar nova mutação do mesmo evento enquanto a primeira está pendente.
- **Range/performance:** overlap correto consulta `endAt` sem índice. Mitigação: range máximo de 45 dias, select mínimo e dado esperado pequeno; medir antes de migration de índice.
- **Mobile:** semana de sete colunas é densa e touch drag exige long-press. Mitigação: toolbar que quebra sem overflow, conteúdo compacto, lista sempre a um toque, CTA/form como alternativa e E2E 390×844; não usar `dayMinWidth`, que requer premium.
- **Atividades sem data:** não renderizam em calendário. Mitigação: manter lista antiga e retorno do compositor de giros.
- **Notificações:** o hook atual notifica assessores em toda mudança, inclusive drag. Mitigação nesta fatia: preservar comportamento existente; se o browser test mostrar ruído de produto, capturar como decisão separada em vez de silenciar implicitamente.
## Divergências da hipótese de direção
- **Sem novo `activityAgendaData.ts`:** leitura e DTO entram nos owners `activityPageData.ts` e `activityViewModels.ts`, evitando twin e novo top-level pinado.
- **Sem GET feed:** server action POST evita o cache explícito do PWA para GET autenticado.
- **Sem painel de evento:** o detalhe atual é a superfície operacional completa e vira o destino do clique.
- **Sem redirect de `/campanha/atividades`:** a lista permanece como compatibilidade e fila de giros sem data; somente a entrada primária muda para `/campanha/agenda`.
- **Uma tag por link na v1:** mantém o contrato C14 e cabe no appetite; multi-tag ganha gatilho explícito em vez de semântica inventada.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto: week/month/list, três filtros combinados, slot/create, detalhe, drag/resize permitido e rotas antigas.
- [x] `leader` negado e advisor limitado pelo access atual; nenhum loader/action usa Local API em nome do ator sem `overrideAccess: false`.
- [x] Remarcação é transacional, serializada por atividade e não usa o update amplo.
- [x] FullCalendar e CSS não entram no bundle comum de `/campanha`.
- [x] Fuso Bahia, range exclusivo, erro/revert, loading/empty e alternativas de teclado/touch testados.
- [x] Sem schema change/migration; C14 permanece congelada.
Decision-quality: 5/5 — decisões caras têm alternativas, cabe no appetite, rabbit holes estão nomeados, owners existentes são reutilizados e o outcome não foi reescrito.

View file

@ -78,6 +78,7 @@
"@ai-sdk/deepseek": "^3.0.19",
"@ai-sdk/react": "^4.0.50",
"@base-ui/react": "^1.6.0",
"@fullcalendar/react": "7.0.2",
"@hookform/resolvers": "^5.2.2",
"@payloadcms/db-postgres": "3.82.0",
"@payloadcms/email-resend": "3.82.0",
@ -116,6 +117,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"temporal-polyfill": "^1.0.3",
"topojson-client": "^3.1.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",

View file

@ -17,6 +17,9 @@ importers:
'@base-ui/react':
specifier: ^1.6.0
version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@fullcalendar/react':
specifier: 7.0.2
version: 7.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(temporal-polyfill@1.0.3)
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.71.1(react@19.2.4))
@ -131,6 +134,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.18)
temporal-polyfill:
specifier: ^1.0.3
version: 1.0.3
topojson-client:
specifier: ^3.1.0
version: 3.1.0
@ -1234,6 +1240,24 @@ packages:
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@full-ui/headless-calendar@7.0.2':
resolution: {integrity: sha512-i6UlTNjBVuiBca5Sq6OoLxFqK6eaQ/v1XOLObjRkzXLuSuNn+qs9/dUvlzrbeesn7QbRH7+u7lyvlQyq8WvmGA==}
peerDependencies:
temporal-polyfill: ^1.0.1
'@fullcalendar/core@7.0.2':
resolution: {integrity: sha512-EkhE1wkPttp5kKNmUNLsMwl8nUuQmLdj30SPKagOEWMJUE4AHETAu/F/SbYoRq+nksm2GWPe9cQs1kbQP2H9pA==}
peerDependencies:
'@full-ui/headless-calendar': 7.0.2
temporal-polyfill: ^1.0.1
'@fullcalendar/react@7.0.2':
resolution: {integrity: sha512-+etsJS6UsS1asxpEJt2h/vH/OCC/lqb8OCO/Xv7Mwb7GeJnR25iwBDEZmMKKmxNjJ2o8eU3uNGl/e7551eAt3A==}
peerDependencies:
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
temporal-polyfill: ^1.0.1
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
@ -7127,6 +7151,15 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
temporal-polyfill@1.0.3:
resolution: {integrity: sha512-U8vuzZBc+dfMIfl8wXWcedfI7AJCxJjjVBIRpJUvIz5+UAp555UJDHguKsfZqHWaHRSdVUThm7mqqIWhN/xPBQ==}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
temporal-utils@1.0.1:
resolution: {integrity: sha512-HAixuesxFQIUaQk3ptX2jhfO/FsOkgVkDDMawvp6n/fkB1q6BKfs3lURw9I+pK/2e2e/q/vrLrxmeqauBoyGMQ==}
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
@ -8592,6 +8625,23 @@ snapshots:
'@floating-ui/utils@0.2.12': {}
'@full-ui/headless-calendar@7.0.2(temporal-polyfill@1.0.3)':
dependencies:
temporal-polyfill: 1.0.3
'@fullcalendar/core@7.0.2(@full-ui/headless-calendar@7.0.2(temporal-polyfill@1.0.3))(temporal-polyfill@1.0.3)':
dependencies:
'@full-ui/headless-calendar': 7.0.2(temporal-polyfill@1.0.3)
temporal-polyfill: 1.0.3
'@fullcalendar/react@7.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(temporal-polyfill@1.0.3)':
dependencies:
'@full-ui/headless-calendar': 7.0.2(temporal-polyfill@1.0.3)
'@fullcalendar/core': 7.0.2(@full-ui/headless-calendar@7.0.2(temporal-polyfill@1.0.3))(temporal-polyfill@1.0.3)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
temporal-polyfill: 1.0.3
'@hexagon/base64@1.1.28': {}
'@hono/node-server@1.19.11(hono@4.12.7)':
@ -15278,6 +15328,15 @@ snapshots:
tapable@2.3.3: {}
temporal-polyfill@1.0.3:
dependencies:
temporal-spec: 1.0.1
temporal-utils: 1.0.1
temporal-spec@1.0.1: {}
temporal-utils@1.0.1: {}
thread-stream@3.1.0:
dependencies:
real-require: 0.2.0

View file

@ -45,6 +45,7 @@ export const E2E_AFFECTED_MANIFEST = [
},
{
prefixes: [
`${CAMPAIGN_APP}/agenda`,
`${CAMPAIGN_APP}/atividades`,
'src/components/campaign/activity',
'src/utilities/activity',

View file

@ -0,0 +1,10 @@
export default function AgendaLoading() {
return (
<div className="mx-auto flex w-full max-w-screen-2xl animate-pulse flex-col gap-6" aria-busy>
<div className="ml-auto h-11 w-64 rounded-lg bg-muted" />
<div className="h-24 rounded-xl bg-muted" />
<div className="h-[42rem] rounded-xl bg-muted" />
<span className="sr-only">Carregando agenda.</span>
</div>
)
}

View file

@ -0,0 +1,83 @@
import config from '@payload-config'
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
import {
buildActivityAgendaHref,
buildActivityCreateHref,
resolveActivityAgendaUrl,
restrictActivityAgendaState,
} from '@/utilities/activityUi'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
export const metadata = campaignPageMetadataFromCatalog('agenda')
type AgendaPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function AgendaPage({ searchParams }: AgendaPageProps) {
const rawSearchParams = await searchParams
const resolvedUrl = resolveActivityAgendaUrl(rawSearchParams)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const [municipalityOptions, knownTags] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadAccessibleActivityTags(payload, user),
])
const state = restrictActivityAgendaState(
resolvedUrl.state,
new Set(municipalityOptions.map((option) => option.id)),
new Set(knownTags),
)
const accessibleHref = buildActivityAgendaHref(state)
if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref)
return (
<CampaignPageShell className="gap-6">
<div className="flex flex-wrap justify-end gap-2">
<Button asChild variant="outline" className="min-h-11">
<Link href={TOUR_COMPOSER_PATH}>
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href={buildActivityCreateHref(state)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
</div>
<CampaignListPendingBoundary>
<ActivityAgendaFilters
state={state}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
/>
<CampaignListResults>
<ActivityAgenda state={state} />
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -8,6 +8,8 @@ import {
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
import { parseActivityAgendaReturnHref, parseActivityCreatePrefill } from '@/utilities/activityUi'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
@ -17,18 +19,32 @@ import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipality
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
export default async function NewActivityPage() {
type NewActivityPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function NewActivityPage({ searchParams }: NewActivityPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/agenda' }),
getPayload({ config }),
])
const canManageAdvisors = user.role === 'coordinator'
const [municipalityOptions, organizationOptions, advisorOptions] = await Promise.all([
const [municipalityOptions, organizationOptions, advisorOptions, knownTags] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
canManageAdvisors ? getEligibleAdvisorOptions(payload, user) : Promise.resolve([]),
loadAccessibleActivityTags(payload, user),
])
const accessibleMunicipalityIDs = new Set(municipalityOptions.map((option) => option.id))
const accessibleTags = new Set(knownTags)
const initialValues = parseActivityCreatePrefill(rawSearchParams, accessibleMunicipalityIDs)
const cancelHref = parseActivityAgendaReturnHref(
rawSearchParams.returnTo,
accessibleMunicipalityIDs,
accessibleTags,
)
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
@ -38,6 +54,9 @@ export default async function NewActivityPage() {
organizationOptions={organizationOptions}
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
initialValues={initialValues}
cancelHref={cancelHref}
knownTags={knownTags}
submitLabel="Criar atividade"
searchContacts={searchActivityContactOptions}
searchLeaderships={searchActivityLeadershipOptionsAction}

View file

@ -3,22 +3,29 @@
import type { Payload } from 'payload'
import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
ACTIVITY_RESULT_REQUIRED_MESSAGE,
ACTIVITY_RESULT_STAFF_MESSAGE,
ACTIVITY_RESULT_TOO_LONG_MESSAGE,
ACTIVITY_TASK_NOT_FOUND_MESSAGE,
ACTIVITY_UPDATE_BODY_REQUIRED_MESSAGE,
ACTIVITY_UPDATE_BODY_TOO_LONG_MESSAGE,
activityAgendaRequestSchema,
activityCreateSchema,
activityDemandDraftsSchema,
activityRescheduleSchema,
activityUpdateSchema,
type ActivityAgendaRequest,
type ActivityCreateInput,
type ActivityDemandDraft,
type ActivityRescheduleInput,
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import type { Activity, CampaignUser } from '@/payload-types'
import type { ParsedTourDraftFormData } from '@/utilities/activityFormData'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { loadActivityAgendaEventsData } from '@/utilities/activityPageData'
import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
@ -34,6 +41,56 @@ import {
const MAX_ACTIVITY_UPDATE_BODY_LENGTH = 4000
const MAX_ACTIVITY_RESULT_SUMMARY_LENGTH = 6000
export const loadActivityAgendaEventsRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActivityAgendaRequest,
) => {
const request = activityAgendaRequestSchema.parse(input)
const currentActor = await reloadCampaignActor(payload, actor)
return loadActivityAgendaEventsData(payload, currentActor, request)
}
export const rescheduleActivityRecord = async (
payload: Payload,
actor: CampaignUser,
input: ActivityRescheduleInput,
) => {
const schedule = activityRescheduleSchema.parse(input)
return withPayloadTransaction(
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`activity:${schedule.id}`])
const current = await payload.findByID({
collection: 'activity',
id: schedule.id,
depth: 0,
select: { deputyPresent: true },
user: currentActor,
overrideAccess: false,
req,
})
if (!canCampaignUserRescheduleActivity(currentActor, Boolean(current.deputyPresent))) {
throw new Error(ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE)
}
return payload.update({
collection: 'activity',
id: schedule.id,
data: { startAt: schedule.startAt, endAt: schedule.endAt },
depth: 0,
user: currentActor,
overrideAccess: false,
req,
})
},
{ beginFailureMessage: 'Não foi possível iniciar a remarcação da atividade.' },
)
}
export const createActivityRecord = async (
payload: Payload,
actor: CampaignUser,
@ -190,6 +247,7 @@ const updateActivityRecord = async (
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`activity:${id}`])
const activity = (await payload.update({
collection: 'activity',
@ -383,6 +441,29 @@ export const createActivity = async (
return createActivityRecord(payload, actor, input, demandDrafts)
}
export const loadActivityAgendaEvents = async (input: ActivityAgendaRequest) => {
const { payload, actor } = await getCampaignActionContext()
return loadActivityAgendaEventsRecord(payload, actor, input)
}
export type ActivityRescheduleResult = { ok: true } | { ok: false; message: string }
export const rescheduleActivity = async (
input: ActivityRescheduleInput,
): Promise<ActivityRescheduleResult> => {
try {
const { payload, actor } = await getCampaignActionContext()
await rescheduleActivityRecord(payload, actor, input)
return { ok: true }
} catch (error) {
const message =
error instanceof Error && error.message === ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE
? error.message
: ACTIVITY_RESCHEDULE_FAILED_MESSAGE
return { ok: false, message }
}
}
export const updateActivity = async (
input: ActivityUpdateInput,
demandDrafts: ActivityDemandDraft[] = [],

View file

@ -6,11 +6,16 @@ import type {
import { APIError } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { activityStatusLabels, activityStatuses } from '@/lib/schemas/activity'
import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
activityStatusLabels,
activityStatuses,
} from '@/lib/schemas/activity'
import { slugify } from '@/lib/slug'
import { trimmedText } from '@/lib/text'
import { isCampaignUnrestricted } from '@/utilities/access/shared'
import { getFreshCampaignUser } from '@/utilities/access/shared'
import {
canCampaignUserRescheduleActivity,
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,
@ -151,7 +156,6 @@ const validateDeputyPresentTimeGate: CollectionBeforeValidateHook = async ({
if (isActivityMutationShortcut(context)) return data
if (!data) return data
if (operation === 'create') return data
if (!originalDoc?.deputyPresent) return data
const timeChanged =
(data.startAt !== undefined && data.startAt !== (originalDoc.startAt ?? null)) ||
@ -159,14 +163,14 @@ const validateDeputyPresentTimeGate: CollectionBeforeValidateHook = async ({
if (!timeChanged) return data
const user = req.user
if (!user || user.collection !== 'campaignUser') return data
const deputyPresent = Boolean(originalDoc?.deputyPresent || data.deputyPresent)
if (!deputyPresent) return data
if (!isCampaignUnrestricted(user)) {
throw new APIError(
'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.',
403,
)
if (!req.user || req.user.collection !== 'campaignUser') return data
const user = await getFreshCampaignUser(req)
if (!user || !canCampaignUserRescheduleActivity(user, deputyPresent)) {
throw new APIError(ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE, 403)
}
return data

View file

@ -0,0 +1,155 @@
.activity-agenda-shell {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--card);
box-shadow: 0 4px 24px rgb(28 25 23 / 8%);
}
.activity-agenda {
--fc-classic-button: var(--secondary);
--fc-classic-button-border: var(--border);
--fc-classic-button-strong: var(--primary);
--fc-classic-button-strong-border: var(--primary);
--fc-classic-button-outline: color-mix(in oklab, var(--ring) 55%, transparent);
--fc-classic-button-foreground: var(--secondary-foreground);
--fc-classic-primary: var(--primary);
--fc-classic-primary-foreground: var(--primary-foreground);
--fc-classic-event: var(--primary);
--fc-classic-event-contrast: var(--primary-foreground);
--fc-classic-background-event: var(--support-engaged);
--fc-classic-background-event-opacity: 18%;
--fc-classic-background-event-foreground-opacity: 60%;
--fc-classic-highlight: color-mix(in oklab, var(--primary) 9%, transparent);
--fc-classic-today: color-mix(in oklab, var(--scope) 72%, transparent);
--fc-classic-now: var(--primary);
--fc-classic-small-dot-width: 8px;
--fc-classic-large-dot-width: 10px;
--fc-classic-background: var(--background);
--fc-classic-faint: color-mix(in oklab, var(--foreground) 3%, transparent);
--fc-classic-muted: color-mix(in oklab, var(--foreground) 8%, transparent);
--fc-classic-strong: color-mix(in oklab, var(--foreground) 14%, transparent);
--fc-classic-foreground: var(--foreground);
--fc-classic-faint-foreground: var(--muted-foreground);
--fc-classic-muted-foreground: var(--muted-foreground);
--fc-classic-border: var(--border);
--fc-classic-strong-border: var(--input);
padding: 1rem;
font-size: 0.875rem;
}
.activity-agenda button {
min-height: 2.75rem;
min-width: 2.75rem;
border-radius: var(--radius);
font-weight: 500;
}
.activity-agenda h2 {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.activity-agenda-notice {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--border);
background: var(--muted);
padding: 0.75rem 1rem;
color: var(--muted-foreground);
font-size: 0.875rem;
}
.activity-agenda-event-content {
display: grid;
min-width: 0;
gap: 0.125rem;
padding: 0.125rem 0.25rem;
line-height: 1.2;
}
.activity-agenda-event-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
}
.activity-agenda-event-time,
.activity-agenda-event-location {
overflow: hidden;
font-size: 0.6875rem;
opacity: 0.82;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-agenda-event-title {
overflow: hidden;
font-size: 0.75rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.activity-agenda-event-status {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.04em;
opacity: 0.85;
text-transform: uppercase;
}
.activity-agenda-deputy {
display: inline-flex;
flex: none;
align-items: center;
}
.activity-agenda-deputy svg {
width: 0.875rem;
height: 0.875rem;
}
.activity-agenda-event--cancelado .activity-agenda-event-title {
text-decoration: line-through;
}
@media (max-width: 767px) {
.activity-agenda {
padding: 0.5rem;
font-size: 0.75rem;
}
.activity-agenda h2 {
font-size: 0.9375rem;
}
.activity-agenda button {
padding-inline: 0.625rem;
}
.activity-agenda-notice {
align-items: stretch;
flex-direction: column;
}
.activity-agenda-event-location {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.activity-agenda *,
.activity-agenda *::before,
.activity-agenda *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -0,0 +1,307 @@
'use client'
import FullCalendar, {
type DateClickInfo,
type DatesSetInfo,
type EventDisplayInfo,
type EventDropInfo,
type EventInput,
type EventResizeDoneInfo,
} from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/react/daygrid'
import interactionPlugin from '@fullcalendar/react/interaction'
import listPlugin from '@fullcalendar/react/list'
import ptBrLocale from '@fullcalendar/react/locales/pt-br'
import '@fullcalendar/react/skeleton.css'
import themePlugin from '@fullcalendar/react/themes/classic'
import '@fullcalendar/react/themes/classic/theme.css'
import timeGridPlugin from '@fullcalendar/react/timegrid'
import { UserRoundCheckIcon } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
loadActivityAgendaEvents,
rescheduleActivity,
} from '@/app/(campaign)/campanha/actions/activity'
import { Button } from '@/components/ui/button'
import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
import {
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
activityStatusLabels,
type ActivityStatus,
} from '@/lib/schemas/activity'
import { buildActivityCreateHref, type ActivityAgendaState } from '@/utilities/activityUi'
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
import './ActivityAgenda.css'
type AgendaEventProps = {
status: ActivityStatus
deputyPresent: boolean
municipalityName: string | null
locality: string | null
}
const eventColors: Record<ActivityStatus, { color: string; contrastColor: string }> = {
confirmado: { color: 'var(--primary)', contrastColor: 'var(--primary-foreground)' },
realizado: {
color: 'var(--support-engaged)',
contrastColor: 'var(--support-engaged-foreground)',
},
cancelado: { color: 'var(--muted)', contrastColor: 'var(--muted-foreground)' },
}
const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
id: String(event.id),
title: event.title,
start: event.startAt,
end: event.endAt ?? undefined,
allDay: false,
url: event.href,
startEditable: event.canReschedule,
durationEditable: event.canReschedule,
className: `activity-agenda-event activity-agenda-event--${event.status}`,
...eventColors[event.status],
extendedProps: {
status: event.status,
deputyPresent: event.deputyPresent,
municipalityName: event.municipality?.name ?? null,
locality: event.locality,
} satisfies AgendaEventProps,
})
const renderEventContent = ({ event, timeText }: EventDisplayInfo) => {
const props = event.extendedProps as AgendaEventProps
const location = [props.municipalityName, props.locality].filter(Boolean).join(' · ')
return (
<div className="activity-agenda-event-content">
<div className="activity-agenda-event-heading">
{timeText ? <span className="activity-agenda-event-time">{timeText}</span> : null}
{props.deputyPresent ? (
<span className="activity-agenda-deputy" title="Deputado presente">
<UserRoundCheckIcon aria-hidden="true" />
<span className="sr-only">Deputado presente</span>
</span>
) : null}
</div>
<span className="activity-agenda-event-title">{event.title}</span>
{location ? <span className="activity-agenda-event-location">{location}</span> : null}
<span className="activity-agenda-event-status">{activityStatusLabels[props.status]}</span>
</div>
)
}
export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => {
const router = useRouter()
const mountedRef = useRef(false)
const pendingEventIDs = useRef(new Set<string>())
const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null)
const [events, setEvents] = useState<EventInput[]>([])
const [reloadCount, setReloadCount] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [savingCount, setSavingCount] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
useEffect(() => {
if (!visibleRange) return
let cancelled = false
setIsLoading(true)
setLoadError(null)
setEvents([])
void loadActivityAgendaEvents({
...state,
rangeStart: visibleRange.start,
rangeEnd: visibleRange.end,
})
.then((loadedEvents) => {
if (!cancelled) setEvents(loadedEvents.map(toEventInput))
})
.catch(() => {
if (!cancelled) {
setEvents([])
setLoadError('Não foi possível carregar esta janela da agenda. Tente novamente.')
}
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [reloadCount, state, visibleRange])
const handleDatesSet = useCallback(({ startStr, endStr }: DatesSetInfo) => {
setVisibleRange((current) =>
current?.start === startStr && current.end === endStr
? current
: { start: startStr, end: endStr },
)
}, [])
const persistSchedule = useCallback(
async ({
id,
startAt,
endAt,
revert,
}: {
id: string
startAt: string
endAt: string | null
revert: () => void
}) => {
if (pendingEventIDs.current.has(id)) {
revert()
return
}
pendingEventIDs.current.add(id)
setSavingCount((count) => count + 1)
try {
const result = await rescheduleActivity({ id: Number(id), startAt, endAt })
if (!mountedRef.current) return
if (!result.ok) {
revert()
toast.error(result.message)
return
}
setEvents((current) =>
current.map((event) =>
String(event.id) === id ? { ...event, start: startAt, end: endAt ?? undefined } : event,
),
)
setReloadCount((count) => count + 1)
toast.success('Horário atualizado.')
} catch {
if (!mountedRef.current) return
revert()
toast.error(ACTIVITY_RESCHEDULE_FAILED_MESSAGE)
} finally {
pendingEventIDs.current.delete(id)
if (mountedRef.current) setSavingCount((count) => Math.max(0, count - 1))
}
},
[],
)
const handleScheduleChange = useCallback(
(info: EventDropInfo | EventResizeDoneInfo) => {
void persistSchedule({
id: info.event.id,
startAt: info.event.startStr,
endAt: info.event.endStr || null,
revert: info.revert,
})
},
[persistSchedule],
)
const handleDateClick = useCallback(
(info: DateClickInfo) => {
if (info.allDay) {
const startAt = parseBahiaDateTimeInput(`${info.dateStr.slice(0, 10)}T09:00`)
const endAt = startAt
? new Date(new Date(startAt).getTime() + 3_600_000).toISOString()
: undefined
router.push(buildActivityCreateHref(state, { startAt: startAt ?? undefined, endAt }))
return
}
const start = new Date(info.dateStr)
const end = new Date(start.getTime() + 3_600_000)
router.push(
buildActivityCreateHref(state, {
startAt: start.toISOString(),
endAt: end.toISOString(),
}),
)
},
[router, state],
)
const emptyHref = buildActivityCreateHref(state)
return (
<section className="activity-agenda-shell" aria-label="Calendário de atividades">
<div className="sr-only" aria-live="polite">
{savingCount > 0 ? 'Salvando novo horário…' : null}
{loadError}
</div>
{isLoading ? (
<div className="activity-agenda-notice" role="status">
Carregando compromissos…
</div>
) : null}
{loadError ? (
<div className="activity-agenda-notice" role="alert">
<span>{loadError}</span>
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => setReloadCount((count) => count + 1)}
>
Tentar novamente
</Button>
</div>
) : null}
{!isLoading && !loadError && events.length === 0 ? (
<div className="activity-agenda-notice" role="status">
<span>Nenhum compromisso nesta janela e neste filtro.</span>
<Button asChild variant="outline" className="min-h-11">
<Link href={emptyHref}>Criar atividade</Link>
</Button>
</div>
) : null}
<div className="activity-agenda" aria-busy={isLoading || savingCount > 0}>
<FullCalendar
plugins={[themePlugin, timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin]}
locale={ptBrLocale}
timeZone="America/Bahia"
firstDay={1}
initialView="timeGridWeek"
headerToolbar={{
start: 'prev,next today',
center: 'title',
end: 'timeGridWeek,dayGridMonth,listMonth',
}}
height="auto"
slotMinTime="07:00:00"
slotMaxTime="22:00:00"
scrollTime="08:00:00"
nowIndicator
allDaySlot={false}
dayMaxEvents
editable={!isLoading && savingCount === 0}
eventLongPressDelay={650}
events={events}
datesSet={handleDatesSet}
eventContent={renderEventContent}
dateClick={handleDateClick}
eventDrop={handleScheduleChange}
eventResize={handleScheduleChange}
noEventsText="Nenhum compromisso nesta janela"
headingLevel={2}
/>
</div>
</section>
)
}

View file

@ -0,0 +1,115 @@
'use client'
import { useEffect, useState } from 'react'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
type ActivityAgendaFiltersProps = {
state: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags: string[]
}
export const ActivityAgendaFilters = ({
state,
municipalityOptions,
knownTags,
}: ActivityAgendaFiltersProps) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildActivityAgendaHref,
})
const [draft, setDraft] = useState(state)
useEffect(() => setDraft(state), [state])
const update = (next: ActivityAgendaState) => {
setDraft(next)
navigate(next)
}
const hasFilters = Boolean(draft.municipality || draft.deputyPresent || draft.tag)
return (
<section
aria-label="Filtros da agenda"
aria-busy={isPending}
className="rounded-xl border bg-card p-4 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
>
<div className="grid gap-4 md:grid-cols-[minmax(12rem,1fr)_minmax(11rem,0.7fr)_auto_auto] md:items-end">
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="agenda-municipality">
Município
</label>
<NativeSelect
id="agenda-municipality"
value={draft.municipality ? String(draft.municipality) : ''}
onChange={(event) => {
const municipality = Number(event.target.value)
update({
...draft,
municipality: municipality > 0 ? municipality : undefined,
})
}}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Todos os municípios</NativeSelectOption>
{municipalityOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="agenda-tag">
Tag
</label>
<NativeSelect
id="agenda-tag"
value={draft.tag ?? ''}
onChange={(event) => update({ ...draft, tag: event.target.value || undefined })}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Todas as tags</NativeSelectOption>
{knownTags.map((tag) => (
<NativeSelectOption key={tag} value={tag}>
{tag}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<label className="flex min-h-11 items-center gap-3 rounded-lg border px-3 text-sm font-medium">
<Checkbox
checked={Boolean(draft.deputyPresent)}
onCheckedChange={(checked) =>
update({ ...draft, deputyPresent: checked === true ? true : undefined })
}
/>
Deputado presente
</label>
<Button
type="button"
variant="ghost"
className="min-h-11"
disabled={!hasFilters}
onClick={() => update({})}
>
Limpar filtros
</Button>
</div>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'}
</p>
</section>
)
}

View file

@ -35,6 +35,7 @@ import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity'
import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions'
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
import type { EligibleAdvisorOption } from '@/utilities/municipality/municipalityViewModels'
@ -57,6 +58,7 @@ export type ActivityFormFieldsProps = {
advisorOptions?: EligibleAdvisorOption[]
canManageAdvisors?: boolean
activity?: ActivityFormViewModel
initialValues?: ActivityCreatePrefill
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
@ -153,6 +155,7 @@ const ActivityFormFields = ({
advisorOptions = [],
canManageAdvisors = false,
activity,
initialValues,
fieldErrors = {},
submittedTitle,
searchContacts,
@ -172,6 +175,9 @@ const ActivityFormFields = ({
const [leadership, setLeadership] = useState<ActivityLeadershipOption | null>(
activity?.leadership ? { id: activity.leadership.id, label: activity.leadership.label } : null,
)
const initialStartAt = activity?.startAt ?? initialValues?.startAt
const initialEndAt = activity?.endAt ?? initialValues?.endAt
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
return (
<FieldGroup>
@ -248,9 +254,7 @@ const ActivityFormFields = ({
id="startAt"
name="startAt"
type="datetime-local"
defaultValue={
activity?.startAt ? formatIsoAsBahiaDateTimeInput(activity.startAt) : ''
}
defaultValue={initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : ''}
required
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
@ -266,7 +270,7 @@ const ActivityFormFields = ({
id="endAt"
name="endAt"
type="datetime-local"
defaultValue={activity?.endAt ? formatIsoAsBahiaDateTimeInput(activity.endAt) : ''}
defaultValue={initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : ''}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
@ -290,7 +294,7 @@ const ActivityFormFields = ({
<NativeSelect
id="municipality"
name="municipality"
defaultValue={activity?.municipalityId ? String(activity.municipalityId) : ''}
defaultValue={initialMunicipalityId ? String(initialMunicipalityId) : ''}
required
aria-invalid={Boolean(errorFor('municipality'))}
aria-describedby={errorFor('municipality') ? 'municipality-error' : undefined}
@ -453,15 +457,18 @@ export const ActivityForm = ({
advisorOptions,
canManageAdvisors,
activity,
initialValues,
fieldErrors: externalFieldErrors,
submittedTitle,
searchContacts,
searchLeaderships,
knownTags,
submitLabel,
cancelHref,
}: ActivityFormFieldsProps & {
action: ActivityFormAction
submitLabel?: string
cancelHref?: string
}) => {
const [state, submitAction, isPending] = useActionState(action, {})
@ -473,6 +480,7 @@ export const ActivityForm = ({
advisorOptions={advisorOptions}
canManageAdvisors={canManageAdvisors}
activity={activity}
initialValues={initialValues}
fieldErrors={state.fieldErrors ?? externalFieldErrors}
submittedTitle={state.submittedTitle ?? submittedTitle}
searchContacts={searchContacts}
@ -502,7 +510,13 @@ export const ActivityForm = ({
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button asChild variant="outline" className="min-h-11">
<Link href={activity ? `/campanha/atividades/${activity.slug}` : '/campanha/atividades'}>
<Link
href={
activity
? `/campanha/atividades/${activity.slug}`
: (cancelHref ?? '/campanha/agenda')
}
>
Cancelar
</Link>
</Button>

View file

@ -294,7 +294,7 @@ export const MunicipalityDossier = ({
<SectionHeader
id="dossier-agenda-title"
title="Agenda no município"
seeAllHref={`/campanha/atividades?municipality=${view.id}`}
seeAllHref={`/campanha/agenda?municipality=${view.id}`}
seeAllLabel="Ver agenda completa"
/>
{data.upcomingActivities.length ? (

View file

@ -14,7 +14,7 @@ import {
type LucideIcon,
} from 'lucide-react'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { CAMPAIGN_AGENDA_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
@ -40,7 +40,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Lideranças', href: '/campanha/liderancas', icon: HandshakeIcon },
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Atividades', href: '/campanha/atividades', icon: CalendarDaysIcon },
{ title: 'Agenda', href: CAMPAIGN_AGENDA_HOME, icon: CalendarDaysIcon },
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
{ title: 'Assessores', href: '/campanha/assessores', icon: UserCogIcon },
@ -83,5 +83,6 @@ export const isCampaignNavActive = (pathname: string, href: string): boolean =>
if (href === '/campanha') {
return pathname === '/campanha' || pathname === '/campanha/'
}
if (href === CAMPAIGN_AGENDA_HOME && pathname.startsWith('/campanha/atividades')) return true
return pathname === href || pathname.startsWith(`${href}/`)
}

View file

@ -38,6 +38,9 @@ export const campaignPageChromeCatalog = {
atividades: {
title: 'Atividades',
},
agenda: {
title: 'Agenda',
},
atividadesNova: {
title: 'Nova atividade',
},
@ -155,6 +158,10 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === '/campanha/dobradinhas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas),
},
{
match: (pathname) => pathname === '/campanha/agenda',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.agenda),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),

View file

@ -9,6 +9,8 @@ export const CAMPAIGN_ADVISORS_HOME = '/campanha/assessores' as const
export const CAMPAIGN_SUPPORTERS_HOME = '/campanha/apoiadores' as const
export const CAMPAIGN_AGENDA_HOME = '/campanha/agenda' as const
export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const
export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const

View file

@ -1,10 +1,14 @@
/** Client-safe quick-action path helpers for the B84+ vertical registries. */
export const ACTIVITY_LIST_PATH = '/campanha/atividades' as const
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
export const ACTIVITY_NEW_PATH = '/campanha/atividades/nova' as const
export const ACTIVITY_LIST_PATH = CAMPAIGN_AGENDA_HOME
export const ACTIVITY_TOUR_COMPOSER_PATH = '/campanha/atividades/giros' as const
export const ACTIVITY_DETAIL_ROOT_PATH = '/campanha/atividades' as const
export const ACTIVITY_NEW_PATH = `${ACTIVITY_DETAIL_ROOT_PATH}/nova` as const
export const ACTIVITY_TOUR_COMPOSER_PATH = `${ACTIVITY_DETAIL_ROOT_PATH}/giros` as const
export const ORGANIZATIONS_LIST_PATH = '/campanha/organizacoes' as const
@ -42,13 +46,15 @@ const listDetailSlug = (
}
const activityDetailSlug = (pathname: string): string | null =>
listDetailSlug(pathname, ACTIVITY_LIST_PATH, ['nova', 'giros'])
listDetailSlug(pathname, ACTIVITY_DETAIL_ROOT_PATH, ['nova', 'giros'])
export const parseActivityQuickActionSurface = (
pathname: string,
): ActivityQuickActionSurface | null => {
const normalized = normalizePathname(pathname)
if (normalized === ACTIVITY_LIST_PATH) return { kind: 'list' }
if (normalized === ACTIVITY_LIST_PATH || normalized === ACTIVITY_DETAIL_ROOT_PATH) {
return { kind: 'list' }
}
const slug = activityDetailSlug(normalized)
return slug ? { kind: 'detail', activitySlug: slug } : null
}

View file

@ -21,6 +21,10 @@ export const ACTIVITY_RESULT_TOO_LONG_MESSAGE =
'Resultado muito longo. Reduza o texto e tente novamente.'
export const ACTIVITY_RESULT_STAFF_MESSAGE =
'Apenas a equipe da campanha pode registrar o resultado da atividade.'
export const ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE =
'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.'
export const ACTIVITY_RESCHEDULE_FAILED_MESSAGE =
'Não foi possível remarcar a atividade. O horário anterior foi mantido.'
/**
* C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
@ -28,6 +32,7 @@ export const ACTIVITY_RESULT_STAFF_MESSAGE =
*/
export const MAX_ACTIVITY_TAGS = 20
export const MAX_ACTIVITY_TAG_LENGTH = 80
export const ACTIVITY_AGENDA_MAX_RANGE_DAYS = 45
export const activityStatuses = ['confirmado', 'realizado', 'cancelado'] as const
export type ActivityStatus = (typeof activityStatuses)[number]
@ -111,6 +116,51 @@ const validateSchedule = (
}
}
const isoInstantSchema = z.string().datetime({ offset: true })
export const activityAgendaRequestSchema = z
.object({
rangeStart: isoInstantSchema,
rangeEnd: isoInstantSchema,
municipality: positiveRelationshipId.optional(),
deputyPresent: z.literal(true).optional(),
tag: z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH).optional(),
})
.superRefine((data, context) => {
const start = new Date(data.rangeStart)
const end = new Date(data.rangeEnd)
const duration = end.getTime() - start.getTime()
const maximumDuration = ACTIVITY_AGENDA_MAX_RANGE_DAYS * 86_400_000
if (!(duration > 0) || duration > maximumDuration) {
context.addIssue({
code: 'custom',
message: `Consulte no máximo ${ACTIVITY_AGENDA_MAX_RANGE_DAYS} dias da agenda por vez.`,
path: ['rangeEnd'],
})
}
})
.transform((data) => ({
...data,
rangeStart: new Date(data.rangeStart).toISOString(),
rangeEnd: new Date(data.rangeEnd).toISOString(),
}))
export const activityRescheduleSchema = z
.object({
id: positiveRelationshipId,
startAt: isoInstantSchema,
endAt: isoInstantSchema.nullable(),
})
.superRefine((data, context) => {
validateSchedule(data, context, true)
})
.transform((data) => ({
...data,
startAt: new Date(data.startAt).toISOString(),
endAt: data.endAt ? new Date(data.endAt).toISOString() : null,
}))
export const activityCreateSchema = activityFieldsSchema
.extend({
status: z.enum(['confirmado']).default('confirmado'),
@ -142,6 +192,9 @@ export const activityUpdateSchema = activityFieldsSchema
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
export type ActivityAgendaRequest = z.input<typeof activityAgendaRequestSchema>
export type ActivityAgendaRequestData = z.output<typeof activityAgendaRequestSchema>
export type ActivityRescheduleInput = z.input<typeof activityRescheduleSchema>
/**
* E13 — one stop of a giro, as the composer submits it: which município and

View file

@ -4,6 +4,7 @@
import type { Access, FieldAccess, Where } from 'payload'
import type { CampaignUser } from '@/payload-types'
import { getAccessibleMunicipalityIds } from '@/utilities/access/municipalities'
import {
advisorMunicipalityScopeWhere,
@ -23,6 +24,11 @@ const canStaffCreateActivity: FieldAccess = async ({ req }) => {
export const canCreateActivity: Access = canStaffCreateActivity
export const canCampaignUserRescheduleActivity = (
user: CampaignUser,
deputyPresent: boolean,
): boolean => isCampaignStaff(user) && (!deputyPresent || isCampaignUnrestricted(user))
export const canReadActivity: Access = async ({ req }): Promise<boolean | Where> => {
if (isPayloadAdmin(req.user)) return true

View file

@ -2,17 +2,21 @@ import 'server-only'
import type { Payload } from 'payload'
import type { ActivityAgendaRequestData } from '@/lib/schemas/activity'
import type { Activity, CampaignUser } from '@/payload-types'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
import {
activityPageSize,
buildActivityAgendaWhere,
buildActivityListWhere,
parseActivityListParams,
} from '@/utilities/activityUi'
import {
activityAgendaSelect,
activityFormSelect,
activityListSelect,
getActivityDetailSelect,
toActivityAgendaEvent,
toActivityFormViewModel,
} from '@/utilities/activityViewModels'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
@ -52,6 +56,43 @@ export const loadActivityListPageData = async (
return { result, state }
}
export const loadActivityAgendaEventsData = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
request: ActivityAgendaRequestData,
) => {
const result = await payload.find({
collection: 'activity',
depth: 1,
limit: 0,
pagination: false,
sort: 'startAt',
where: buildActivityAgendaWhere(request, request.rangeStart, request.rangeEnd),
select: activityAgendaSelect,
user,
overrideAccess: false,
})
return result.docs.map((activity) => toActivityAgendaEvent(activity as Activity, user))
}
export const loadAccessibleActivityTags = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
): Promise<string[]> => {
const result = await payload.find({
collection: 'activity',
depth: 0,
limit: 0,
pagination: false,
select: { tags: true },
user,
overrideAccess: false,
})
const tags = new Set(result.docs.flatMap((activity) => activity.tags ?? []))
return [...tags].sort((left, right) => left.localeCompare(right, 'pt-BR'))
}
const getActivityDetailQueryDepth = (activeTab: ActivityDetailTab): number =>
activeTab === 'updates' ? 0 : 1

View file

@ -1,10 +1,16 @@
import type { Where } from 'payload'
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
import { activityStatuses, type ActivityStatus } from '@/lib/schemas/activity'
import {
activityStatuses,
MAX_ACTIVITY_TAG_LENGTH,
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
buildListHref,
firstValue,
inspectRawListParams,
normalizedText,
resolveListUrl,
strictDecimalInteger,
@ -35,6 +41,184 @@ export type ActivityListState = {
type RawSearchParams = CampaignListRawSearchParams
export type ActivityAgendaState = {
municipality?: number
deputyPresent?: true
tag?: string
}
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag'] as const
const activityAgendaParamNameSet = new Set<string>(activityAgendaParamNames)
const activityTag = (value: string | undefined): string | undefined => {
const tag = normalizedText(value)
return tag && tag.length <= MAX_ACTIVITY_TAG_LENGTH ? tag : undefined
}
export const parseActivityAgendaParams = (params: RawSearchParams): ActivityAgendaState => {
const municipality = strictDecimalInteger(firstValue(params.municipality))
const deputyPresent = firstValue(params.deputyPresent) === '1' ? true : undefined
const tag = activityTag(firstValue(params.tag))
return {
...(municipality ? { municipality } : {}),
...(deputyPresent ? { deputyPresent } : {}),
...(tag ? { tag } : {}),
}
}
export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URLSearchParams => {
const canonicalState = parseActivityAgendaParams({
municipality: state.municipality === undefined ? undefined : String(state.municipality),
deputyPresent: state.deputyPresent ? '1' : undefined,
tag: state.tag,
})
const params = new URLSearchParams()
if (canonicalState.municipality) {
params.set('municipality', String(canonicalState.municipality))
}
if (canonicalState.deputyPresent) params.set('deputyPresent', '1')
if (canonicalState.tag) params.set('tag', canonicalState.tag)
return params
}
export const buildActivityAgendaHref = (state: ActivityAgendaState): string => {
const query = buildActivityAgendaSearchParams(state).toString()
return query ? `${CAMPAIGN_AGENDA_HOME}?${query}` : CAMPAIGN_AGENDA_HOME
}
export const restrictActivityAgendaState = (
state: ActivityAgendaState,
accessibleMunicipalityIDs: ReadonlySet<number>,
accessibleTags: ReadonlySet<string>,
): ActivityAgendaState => ({
...(state.municipality && accessibleMunicipalityIDs.has(state.municipality)
? { municipality: state.municipality }
: {}),
...(state.deputyPresent ? { deputyPresent: true } : {}),
...(state.tag && accessibleTags.has(state.tag) ? { tag: state.tag } : {}),
})
export const resolveActivityAgendaUrl = (
params: RawSearchParams,
): {
state: ActivityAgendaState
href: string
redirectHref?: string
} => {
const state = parseActivityAgendaParams(params)
const canonicalQuery = buildActivityAgendaSearchParams(state).toString()
const href = canonicalQuery ? `${CAMPAIGN_AGENDA_HOME}?${canonicalQuery}` : CAMPAIGN_AGENDA_HOME
const raw = inspectRawListParams(params, activityAgendaParamNameSet)
return {
state,
href,
...(raw.hasUnsupportedParams || raw.query !== canonicalQuery ? { redirectHref: href } : {}),
}
}
export const buildActivityAgendaWhere = (
state: ActivityAgendaState,
rangeStart: string,
rangeEnd: string,
): Where => {
const filters: Where[] = [
{ startAt: { less_than: rangeEnd } },
{
or: [
{ endAt: { greater_than: rangeStart } },
{
and: [{ endAt: { exists: false } }, { startAt: { greater_than_equal: rangeStart } }],
},
],
},
]
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
if (state.deputyPresent) filters.push({ deputyPresent: { equals: true } })
if (state.tag) filters.push({ tags: { contains: state.tag } })
return { and: filters }
}
export type ActivityCreatePrefill = {
startAt?: string
endAt?: string
municipalityId?: number
}
export const buildActivityCreateHref = (
agendaState: ActivityAgendaState,
prefill: Omit<ActivityCreatePrefill, 'municipalityId'> = {},
): string => {
const params = new URLSearchParams()
if (prefill.startAt) params.set('startAt', prefill.startAt)
if (prefill.endAt) params.set('endAt', prefill.endAt)
if (agendaState.municipality) params.set('municipality', String(agendaState.municipality))
params.set('returnTo', buildActivityAgendaHref(agendaState))
return `/campanha/atividades/nova?${params.toString()}`
}
export const parseActivityAgendaReturnHref = (
value: string | string[] | undefined,
accessibleMunicipalityIDs: ReadonlySet<number>,
accessibleTags: ReadonlySet<string>,
): string => {
const raw = firstValue(value)
if (!raw) return CAMPAIGN_AGENDA_HOME
const base = new URL('https://campaign.invalid')
let returnUrl: URL
try {
returnUrl = new URL(raw, base)
} catch {
return CAMPAIGN_AGENDA_HOME
}
if (returnUrl.origin !== base.origin || returnUrl.pathname !== CAMPAIGN_AGENDA_HOME) {
return CAMPAIGN_AGENDA_HOME
}
const state = restrictActivityAgendaState(
parseActivityAgendaParams({
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
tag: returnUrl.searchParams.get('tag') ?? undefined,
}),
accessibleMunicipalityIDs,
accessibleTags,
)
return buildActivityAgendaHref(state)
}
const isoInstantPattern = /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/
const normalizedIsoInstant = (value: string | undefined): string | undefined => {
if (!value || !isoInstantPattern.test(value)) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
export const parseActivityCreatePrefill = (
params: RawSearchParams,
accessibleMunicipalityIDs: ReadonlySet<number>,
): ActivityCreatePrefill => {
const startAt = normalizedIsoInstant(firstValue(params.startAt))
const rawEndAt = normalizedIsoInstant(firstValue(params.endAt))
const endAt = startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt) ? rawEndAt : undefined
const rawMunicipality = strictDecimalInteger(firstValue(params.municipality))
const municipalityId =
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
return {
...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}),
}
}
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
const activityListParamNameSet = new Set<string>(activityListParamNames)

View file

@ -1,6 +1,7 @@
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
import { canCampaignUserRescheduleActivity } from '@/utilities/access/activities'
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
export type ActivityMunicipalitySummary = {
@ -37,7 +38,7 @@ const formatActivityLocationLabel = ({
locality?: string | null
}): string => [municipalityName, locality].filter(Boolean).join(' · ')
export const activityListSelect = {
export const activityAgendaSelect = {
title: true,
slug: true,
tags: true,
@ -47,11 +48,53 @@ export const activityListSelect = {
endAt: true,
municipality: true,
locality: true,
} as const
export const activityListSelect = {
...activityAgendaSelect,
responsible: true,
taskDoneCount: true,
taskTotal: true,
} as const
export type ActivityAgendaEvent = {
id: number
title: string
href: string
tags: string[]
status: Activity['status']
deputyPresent: boolean
startAt: string
endAt: string | null
municipality: ActivityMunicipalitySummary | null
locality: string | null
canReschedule: boolean
}
export const toActivityAgendaEvent = (
activity: Activity,
user: CampaignUser,
): ActivityAgendaEvent => {
if (!activity.startAt) {
throw new Error('Atividade sem data não pode ser convertida em evento de agenda.')
}
const deputyPresent = Boolean(activity.deputyPresent)
return {
id: activity.id,
title: activity.title,
href: `/campanha/atividades/${activity.slug}`,
tags: activity.tags ?? [],
status: activity.status,
deputyPresent,
startAt: activity.startAt,
endAt: activity.endAt ?? null,
municipality: activityMunicipalitySummary(activity.municipality),
locality: activity.locality ?? null,
canReschedule: canCampaignUserRescheduleActivity(user, deputyPresent),
}
}
export type ActivityListViewModel = {
id: number
title: string

View file

@ -5,7 +5,12 @@ import {
campaignIntelligenceConcepts,
type CampaignConceptId,
} from '@/lib/campaignIntelligenceConcepts'
import { CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_HOME,
CAMPAIGN_PROFILE_HOME,
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
@ -27,7 +32,12 @@ import {
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser } from '@/payload-types'
import { activityTabs, buildActivityListHref, type ActivityTab } from '@/utilities/activityUi'
import {
activityTabs,
buildActivityAgendaHref,
buildActivityListHref,
type ActivityTab,
} from '@/utilities/activityUi'
import { advisorListHrefForPage } from '@/utilities/advisor/advisorListUrl'
import { buildDemandListHref } from '@/utilities/demand/demandListUrl'
import { buildLeadershipListHref } from '@/utilities/leadership/leadershipListUrl'
@ -169,6 +179,7 @@ export type CampaignNavigationLinkRequest =
tag?: string
status?: (typeof activityStatuses)[number]
municipality?: number
deputyPresent?: boolean
})
| (BaseLinkFields & {
destination: 'demandList'
@ -488,18 +499,37 @@ const buildPathForRequest = (
request.municipality !== undefined
? positiveInt(request.municipality, 'municipality')
: undefined
const path = buildActivityListHref(
{
page: 1,
tab: tab ?? 'proximos',
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Atividades', request.label) }
const usesLegacyList = Boolean(request.q?.trim() || tab || status)
if (usesLegacyList && request.deputyPresent) {
return {
ok: false,
error:
'O filtro de deputado presente pertence à Agenda e não pode ser combinado com busca, aba ou status da lista antiga.',
alternatives: [CAMPAIGN_AGENDA_HOME, '/campanha/atividades'],
}
}
const path = usesLegacyList
? buildActivityListHref(
{
page: 1,
tab: tab ?? 'proximos',
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(tag ? { tag } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
},
1,
)
: buildActivityAgendaHref({
...(tag ? { tag } : {}),
...(municipality ? { municipality } : {}),
...(request.deputyPresent ? { deputyPresent: true } : {}),
})
return {
ok: true,
path,
label: defaultLabel(usesLegacyList ? 'Atividades' : 'Agenda', request.label),
}
}
case 'demandList': {
const status =

View file

@ -159,6 +159,7 @@ const linkRequestSchema = z.discriminatedUnion('destination', [
tag: z.string().trim().min(1).optional(),
status: z.enum(activityStatuses).optional(),
municipality: z.number().int().positive().optional(),
deputyPresent: z.boolean().optional(),
label: labelSchema,
}),
z.object({

View file

@ -105,6 +105,7 @@ export {
} from '@/utilities/access/demands'
export {
canCampaignUserRescheduleActivity,
canCreateActivity,
canCreateActivityAdvisors,
canDeleteActivity,

View file

@ -1,3 +1,5 @@
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Atividades — registro-fundação', () => {
@ -53,3 +55,117 @@ test.describe('Atividades — registro-fundação', () => {
await expect(page.getByText('Alguém pediu algo:', { exact: true })).toBeVisible()
})
})
test.describe('Agenda — calendário operacional', () => {
test.setTimeout(90_000)
test('abre a semana, combina filtros e navega para o detalhe', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const title = fixtures.value('Comício na agenda')
const civilDate = formatBahiaCivilDate(new Date())
const startAt = parseBahiaDateTimeInput(`${civilDate}T10:00`)
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
const activity = await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title,
tags: ['Comício'],
status: 'confirmado',
deputyPresent: true,
startAt,
municipality: municipality.id,
}),
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('tab', { name: /semana/i })).toBeVisible()
await expect(page.getByRole('tab', { name: /mês/i })).toBeVisible()
await expect(page.getByRole('tab', { name: /lista/i })).toBeVisible()
const eventLink = page.getByRole('link', { name: new RegExp(title) })
const eventBox = await eventLink.boundingBox()
if (!eventBox) throw new Error('O compromisso não expôs área arrastável.')
await page.mouse.move(eventBox.x + eventBox.width / 2, eventBox.y + eventBox.height / 2)
await page.mouse.down()
await page.mouse.move(eventBox.x + eventBox.width / 2, eventBox.y + eventBox.height / 2 + 60, {
steps: 20,
})
await page.mouse.up()
await expect(page.getByText('Horário atualizado.')).toBeVisible()
await expect
.poll(async () => {
const persisted = await fixtures.payload.findByID({
collection: 'activity',
id: activity.id,
depth: 0,
})
return persisted.startAt
})
.not.toBe(startAt)
await page.getByLabel('Município').selectOption(String(municipality.id))
await page.getByLabel('Tag').selectOption('Comício')
await page.getByLabel('Deputado presente').check()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`,
)
await expect(page.getByText(title, { exact: true })).toBeVisible()
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('tab', { name: /lista/i }).click()
await expect(page.getByText(title, { exact: true })).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true)
await page.getByText(title, { exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/)
await expect(campaignPageChrome(page, title)).toBeVisible()
})
test('leva o slot semanal para o formulário existente', async ({ campaign, page }) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
const dayCell = page.getByRole('gridcell').nth(1)
await dayCell.scrollIntoViewIfNeeded()
const [slotBox, dayBox] = await Promise.all([
page.locator('[data-time="14:00:00"]').last().boundingBox(),
dayCell.boundingBox(),
])
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
await dayCell.click({
position: {
x: dayBox.width / 2,
y: slotBox.y - dayBox.y + slotBox.height / 2,
},
})
await expect(page).toHaveURL(/\/campanha\/atividades\/nova\?startAt=/)
const [startValue, endValue] = await Promise.all([
page.getByLabel('Início *').inputValue(),
page.getByLabel('Término').inputValue(),
])
const [startAt, endAt] = [startValue, endValue].map(parseBahiaDateTimeInput)
expect(startAt).not.toBeNull()
expect(endAt).not.toBeNull()
if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.')
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(3_600_000)
await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id))
await expect(page.getByRole('link', { name: 'Cancelar' })).toHaveAttribute(
'href',
`/campanha/agenda?municipality=${municipality.id}`,
)
})
})

View file

@ -11,8 +11,14 @@ import { beforeAll, describe, expect, it } from 'vitest'
import {
createActivityRecord,
createTourDraftActivitiesRecord,
loadActivityAgendaEventsRecord,
rescheduleActivityRecord,
} from '@/app/(campaign)/campanha/actions/activity'
import { activityCreateSchema, activityUpdateSchema } from '@/lib/schemas/activity'
import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
activityCreateSchema,
activityUpdateSchema,
} from '@/lib/schemas/activity'
import config from '@/payload.config'
import { parseActivityCreateFormData, parseTourDraftFormData } from '@/utilities/activityFormData'
import {
@ -45,6 +51,24 @@ const validActivityInput = (municipalityId: number) => ({
locality: 'Centro',
})
const createActivityInAdvisorScope = async (title: string, deputyPresent: boolean) => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value(title),
deputyPresent,
}),
overrideAccess: true,
})
fixtures.own('activity', activity.id)
return { activity, advisor }
}
describe('activity domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
@ -352,6 +376,160 @@ describe('activity domain', () => {
).rejects.toThrow('O horário de término deve ser posterior ao de início.')
})
it('loads only accessible events matching the agenda range and filters', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const [inScope, outOfScope] = await Promise.all([
fixtures.getMunicipality(),
fixtures.getMunicipality(),
])
await fixtures.assignMunicipalityAdvisors(inScope, [advisor])
const rangeStart = new Date('2026-08-03T03:00:00.000Z')
const rangeEnd = new Date('2026-08-10T03:00:00.000Z')
const matching = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(inScope.id),
title: fixtures.value('Comício que atravessa a semana'),
tags: ['Comício'],
deputyPresent: true,
startAt: new Date(rangeStart.getTime() - 3_600_000).toISOString(),
endAt: new Date(rangeStart.getTime() + 3_600_000).toISOString(),
}),
overrideAccess: true,
})
fixtures.own('activity', matching.id)
for (const data of [
{
title: fixtures.value('Tag diferente'),
municipality: inScope.id,
tags: ['Reunião'],
},
{
title: fixtures.value('Fora do escopo'),
municipality: outOfScope.id,
tags: ['Comício'],
},
]) {
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(data.municipality),
...data,
deputyPresent: true,
startAt: new Date(rangeStart.getTime() + 3_600_000).toISOString(),
}),
overrideAccess: true,
})
fixtures.own('activity', activity.id)
}
const events = await loadActivityAgendaEventsRecord(payload, advisor, {
rangeStart: rangeStart.toISOString(),
rangeEnd: rangeEnd.toISOString(),
municipality: inScope.id,
deputyPresent: true,
tag: 'Comício',
})
expect(events.map((event) => event.id)).toEqual([matching.id])
expect(events[0]?.municipality?.id).toBe(inScope.id)
expect(events[0]?.canReschedule).toBe(false)
})
it('allows an advisor to reschedule an ordinary activity in their scope', async () => {
const { activity, advisor } = await createActivityInAdvisorScope(
'Atividade comum para remarcar',
false,
)
const nextStart = new Date(Date.now() + 172_800_000).toISOString()
const updated = await rescheduleActivityRecord(payload, advisor, {
id: activity.id,
startAt: nextStart,
endAt: null,
})
expect(updated.startAt).toBe(nextStart)
expect(updated.endAt).toBeNull()
})
it('keeps a deputy commitment unchanged when an advisor tries to reschedule it', async () => {
const { activity, advisor } = await createActivityInAdvisorScope(
'Agenda do deputado protegida',
true,
)
const originalStart = activity.startAt
await expect(
rescheduleActivityRecord(payload, advisor, {
id: activity.id,
startAt: new Date(Date.now() + 259_200_000).toISOString(),
endAt: null,
}),
).rejects.toThrow(ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE)
const persisted = await payload.findByID({
collection: 'activity',
id: activity.id,
depth: 0,
overrideAccess: true,
})
expect(persisted.startAt).toBe(originalStart)
})
it('allows coordinator and candidate to reschedule a deputy commitment', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const candidate = await fixtures.createCampaignUser('candidate')
const municipality = await fixtures.getMunicipality()
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Agenda do deputado liberada'),
deputyPresent: true,
}),
overrideAccess: true,
})
fixtures.own('activity', activity.id)
for (const [actor, offset] of [
[coordinator, 345_600_000],
[candidate, 432_000_000],
] as const) {
const nextStart = new Date(Date.now() + offset).toISOString()
const updated = await rescheduleActivityRecord(payload, actor, {
id: activity.id,
startAt: nextStart,
endAt: null,
})
expect(updated.startAt).toBe(nextStart)
}
})
it('blocks changing the time while marking an activity as deputy-present', async () => {
const { activity, advisor } = await createActivityInAdvisorScope(
'Presença e horário no mesmo write',
false,
)
await expect(
payload.update({
collection: 'activity',
id: activity.id,
data: {
deputyPresent: true,
startAt: new Date(Date.now() + 518_400_000).toISOString(),
},
user: advisor,
overrideAccess: false,
}),
).rejects.toThrow(ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE)
})
it('denies leaders from updating activities', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import {
ACTIVITY_AGENDA_MAX_RANGE_DAYS,
activityAgendaRequestSchema,
activityRescheduleSchema,
} from '@/lib/schemas/activity'
import type { Activity, CampaignUser, Municipality } from '@/payload-types'
import { canCampaignUserRescheduleActivity } from '@/utilities/access/activities'
import { toActivityAgendaEvent } from '@/utilities/activityViewModels'
import { stub } from '../helpers/stub'
const user = (role: CampaignUser['role']): CampaignUser =>
({ role, collection: 'campaignUser' }) as CampaignUser
describe('activity agenda request', () => {
it('normalizes offset instants and accepts the largest supported month range', () => {
const start = '2026-08-01T00:00:00-03:00'
const end = new Date(
new Date(start).getTime() + ACTIVITY_AGENDA_MAX_RANGE_DAYS * 86_400_000,
).toISOString()
expect(
activityAgendaRequestSchema.parse({
rangeStart: start,
rangeEnd: end,
municipality: 12,
deputyPresent: true,
tag: 'Comício',
}),
).toEqual({
rangeStart: '2026-08-01T03:00:00.000Z',
rangeEnd: end,
municipality: 12,
deputyPresent: true,
tag: 'Comício',
})
})
it('rejects reversed and oversized ranges', () => {
expect(
activityAgendaRequestSchema.safeParse({
rangeStart: '2026-08-10T03:00:00.000Z',
rangeEnd: '2026-08-03T03:00:00.000Z',
}).success,
).toBe(false)
expect(
activityAgendaRequestSchema.safeParse({
rangeStart: '2026-08-01T03:00:00.000Z',
rangeEnd: '2026-09-16T03:00:00.000Z',
}).success,
).toBe(false)
})
})
describe('activity reschedule input', () => {
it('normalizes instants and preserves an absent end', () => {
expect(
activityRescheduleSchema.parse({
id: 7,
startAt: '2026-08-07T10:00:00-03:00',
endAt: null,
}),
).toEqual({
id: 7,
startAt: '2026-08-07T13:00:00.000Z',
endAt: null,
})
})
it('rejects an end that is not after the start', () => {
expect(
activityRescheduleSchema.safeParse({
id: 7,
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T12:59:00.000Z',
}).success,
).toBe(false)
})
})
describe('activity reschedule policy', () => {
it('allows all staff to move an ordinary activity', () => {
for (const role of ['coordinator', 'candidate', 'advisor'] as const) {
expect(canCampaignUserRescheduleActivity(user(role), false)).toBe(true)
}
})
it('allows only coordinator and candidate to move a deputy commitment', () => {
expect(canCampaignUserRescheduleActivity(user('coordinator'), true)).toBe(true)
expect(canCampaignUserRescheduleActivity(user('candidate'), true)).toBe(true)
expect(canCampaignUserRescheduleActivity(user('advisor'), true)).toBe(false)
expect(canCampaignUserRescheduleActivity(user('leader'), true)).toBe(false)
})
})
describe('activity agenda event view model', () => {
it('returns only the operational calendar fields and an existing detail URL', () => {
const event = toActivityAgendaEvent(
stub<Activity>({
id: 7,
title: 'Caminhada no centro',
slug: 'caminhada-no-centro',
tags: ['Caminhada'],
status: 'confirmado',
deputyPresent: false,
startAt: '2026-08-07T13:00:00.000Z',
endAt: null,
municipality: stub<Municipality>({ id: 12, name: 'Ilhéus', slug: 'ilheus' }),
locality: 'Centro histórico',
}),
user('advisor'),
)
expect(event).toEqual({
id: 7,
title: 'Caminhada no centro',
href: '/campanha/atividades/caminhada-no-centro',
tags: ['Caminhada'],
status: 'confirmado',
deputyPresent: false,
startAt: '2026-08-07T13:00:00.000Z',
endAt: null,
municipality: { id: 12, name: 'Ilhéus', slug: 'ilheus' },
locality: 'Centro histórico',
canReschedule: true,
})
})
it('marks a deputy commitment read-only for an advisor', () => {
expect(
toActivityAgendaEvent(
stub<Activity>({
id: 8,
title: 'Comício',
slug: 'comicio',
tags: [],
status: 'confirmado',
deputyPresent: true,
startAt: '2026-08-08T22:00:00.000Z',
municipality: stub<Municipality>({ id: 12, name: 'Ilhéus', slug: 'ilheus' }),
}),
user('advisor'),
).canReschedule,
).toBe(false)
})
})

View file

@ -0,0 +1,110 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement, useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loadEvents: vi.fn(),
reschedule: vi.fn(),
revertDrop: vi.fn(),
revertResize: vi.fn(),
routerPush: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
loadActivityAgendaEvents: (...args: unknown[]) => mocks.loadEvents(...args),
rescheduleActivity: (...args: unknown[]) => mocks.reschedule(...args),
}))
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mocks.routerPush }),
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}))
type ScheduleMutationInfo = {
event: { id: string; startStr: string; endStr: string }
revert: () => void
}
type CalendarProps = {
datesSet: (info: { startStr: string; endStr: string }) => void
eventDrop: (info: ScheduleMutationInfo) => void
eventResize: (info: ScheduleMutationInfo) => void
}
vi.mock('@fullcalendar/react', () => {
const CalendarMock = (props: CalendarProps) => {
const { datesSet } = props
useEffect(() => {
datesSet({
startStr: '2026-08-03T03:00:00.000Z',
endStr: '2026-08-10T03:00:00.000Z',
})
}, [datesSet])
const event = {
id: '7',
startStr: '2026-08-07T13:00:00.000Z',
endStr: '2026-08-07T14:00:00.000Z',
}
return createElement(
'div',
null,
createElement(
'button',
{ type: 'button', onClick: () => props.eventDrop({ event, revert: mocks.revertDrop }) },
'Mover',
),
createElement(
'button',
{
type: 'button',
onClick: () => props.eventResize({ event, revert: mocks.revertResize }),
},
'Redimensionar',
),
)
}
return { default: CalendarMock }
})
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
describe('ActivityAgenda schedule failures', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('reverts a transport failure and releases the event for another attempt', async () => {
mocks.loadEvents.mockResolvedValue([])
mocks.reschedule.mockRejectedValue(new Error('transport failed'))
render(<ActivityAgenda state={{}} />)
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Mover' }))
await waitFor(() => expect(mocks.revertDrop).toHaveBeenCalledOnce())
expect(mocks.toastError).toHaveBeenCalledWith(
'Não foi possível remarcar a atividade. O horário anterior foi mantido.',
)
fireEvent.click(screen.getByRole('button', { name: 'Mover' }))
await waitFor(() => expect(mocks.reschedule).toHaveBeenCalledTimes(2))
})
it('reverts a refused resize with the safe server message', async () => {
mocks.loadEvents.mockResolvedValue([])
mocks.reschedule.mockResolvedValue({ ok: false, message: 'Remarcação recusada.' })
render(<ActivityAgenda state={{}} />)
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Redimensionar' }))
await waitFor(() => expect(mocks.revertResize).toHaveBeenCalledOnce())
expect(mocks.toastError).toHaveBeenCalledWith('Remarcação recusada.')
})
})

View file

@ -2,12 +2,21 @@ import { describe, expect, it } from 'vitest'
import {
activityTabs,
buildActivityAgendaHref,
buildActivityAgendaSearchParams,
buildActivityAgendaWhere,
buildActivityCreateHref,
buildActivityFiltersKey,
buildActivityListHref,
buildActivityListSearchParams,
buildActivityListWhere,
parseActivityAgendaParams,
parseActivityAgendaReturnHref,
parseActivityCreatePrefill,
parseActivityListParams,
resolveActivityAgendaUrl,
resolveActivityListUrl,
restrictActivityAgendaState,
} from '@/utilities/activityUi'
const NOW = new Date('2026-07-25T12:00:00.000Z')
@ -148,3 +157,156 @@ describe('hrefs and canonical URL resolution', () => {
expect(clamped.redirectHref).toBe('/campanha/atividades?tab=todos&page=2')
})
})
describe('activity agenda URL contract', () => {
it('parses the three agenda filters fail-closed', () => {
expect(
parseActivityAgendaParams({
municipality: '12',
deputyPresent: '1',
tag: ' Comício ',
}),
).toEqual({ municipality: 12, deputyPresent: true, tag: 'Comício' })
expect(
parseActivityAgendaParams({ municipality: '0', deputyPresent: 'true', tag: ' ' }),
).toEqual({})
})
it('serializes agenda filters in one canonical order', () => {
const state = { municipality: 12, deputyPresent: true as const, tag: 'Comício' }
expect(buildActivityAgendaSearchParams(state).toString()).toBe(
'municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
)
expect(buildActivityAgendaHref(state)).toBe(
'/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
)
})
it('canonicalizes invalid and unsupported agenda params', () => {
expect(
resolveActivityAgendaUrl({
municipality: '12',
deputyPresent: 'true',
tag: ' Comício ',
page: '2',
}).redirectHref,
).toBe('/campanha/agenda?municipality=12&tag=Com%C3%ADcio')
})
it('drops municipality and tag filters outside the actor accessible options', () => {
expect(
restrictActivityAgendaState(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
new Set([13]),
new Set(['Reunião']),
),
).toEqual({ deputyPresent: true })
})
})
describe('buildActivityAgendaWhere', () => {
it('combines visible-range overlap with municipality, deputy and tag filters', () => {
expect(
buildActivityAgendaWhere(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
'2026-08-03T03:00:00.000Z',
'2026-08-10T03:00:00.000Z',
),
).toEqual({
and: [
{ startAt: { less_than: '2026-08-10T03:00:00.000Z' } },
{
or: [
{ endAt: { greater_than: '2026-08-03T03:00:00.000Z' } },
{
and: [
{ endAt: { exists: false } },
{ startAt: { greater_than_equal: '2026-08-03T03:00:00.000Z' } },
],
},
],
},
{ municipality: { equals: 12 } },
{ deputyPresent: { equals: true } },
{ tags: { contains: 'Comício' } },
],
})
})
})
describe('activity create prefill', () => {
it('carries the canonical agenda return URL into creation', () => {
expect(
buildActivityCreateHref(
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
{
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T14:00:00.000Z',
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T14%3A00%3A00.000Z&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12%26deputyPresent%3D1%26tag%3DCom%25C3%25ADcio',
)
})
it('accepts only accessible canonical agenda return URLs', () => {
expect(
parseActivityAgendaReturnHref(
'/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
new Set([12]),
new Set(['Comício']),
),
).toBe('/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio')
expect(
parseActivityAgendaReturnHref(
'https://example.com/campanha/agenda?municipality=12',
new Set([12]),
new Set(),
),
).toBe('/campanha/agenda')
})
it('normalizes a valid slot and accepts only an accessible municipality', () => {
expect(
parseActivityCreatePrefill(
{
startAt: '2026-08-07T10:00:00-03:00',
endAt: '2026-08-07T11:00:00-03:00',
municipality: '12',
},
new Set([12]),
),
).toEqual({
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T14:00:00.000Z',
municipalityId: 12,
})
})
it('drops an invalid slot and an out-of-scope municipality', () => {
expect(
parseActivityCreatePrefill(
{
startAt: 'sexta de manhã',
endAt: '2026-08-07T11:00:00-03:00',
municipality: '99',
},
new Set([12]),
),
).toEqual({})
})
it('keeps a valid start but drops an inverted end', () => {
expect(
parseActivityCreatePrefill(
{
startAt: '2026-08-07T10:00:00-03:00',
endAt: '2026-08-07T09:00:00-03:00',
},
new Set(),
),
).toEqual({ startAt: '2026-08-07T13:00:00.000Z' })
})
})

View file

@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/shell/nav'
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
import { ORGANIZATIONS_LIST_PATH } from '@/lib/campaignQuickActionPaths'
describe('organizations sidebar entry', () => {
@ -32,3 +33,22 @@ describe('organizations sidebar entry', () => {
expect(isCampaignNavActive('/campanha/organizacoes/nova', ORGANIZATIONS_LIST_PATH)).toBe(true)
})
})
describe('agenda sidebar entry', () => {
it('replaces Atividades for staff and stays hidden from leaders', () => {
for (const role of ['coordinator', 'candidate', 'advisor'] as const) {
const agenda = getCampaignNav(role).find((item) => item.href === CAMPAIGN_AGENDA_HOME)
expect(agenda?.title).toBe('Agenda')
}
expect(getCampaignNav('leader').map((item) => item.href)).not.toContain(CAMPAIGN_AGENDA_HOME)
})
it('stays active on the agenda and legacy activity routes', () => {
expect(isCampaignNavActive(CAMPAIGN_AGENDA_HOME, CAMPAIGN_AGENDA_HOME)).toBe(true)
expect(isCampaignNavActive('/campanha/atividades', CAMPAIGN_AGENDA_HOME)).toBe(true)
expect(isCampaignNavActive('/campanha/atividades/comicio', CAMPAIGN_AGENDA_HOME)).toBe(true)
expect(isCampaignNavActive('/campanha/atividades/comicio/editar', CAMPAIGN_AGENDA_HOME)).toBe(
true,
)
})
})

View file

@ -3,7 +3,12 @@
import { describe, expect, it } from 'vitest'
import { campaignConceptHref } from '@/lib/campaignIntelligenceConcepts'
import { CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_HOME,
CAMPAIGN_PROFILE_HOME,
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
import type { CampaignUser } from '@/payload-types'
import {
buildCampaignNavigationLink,
@ -112,6 +117,34 @@ describe('campaignNavigationUrls', () => {
})
})
it('uses agenda URLs for calendar filters and keeps advanced legacy list links', () => {
const agenda = buildCampaignNavigationLink(coordinator.role, {
destination: 'activityList',
municipality: 12,
tag: 'Comício',
deputyPresent: true,
})
expect(agenda).toEqual({
ok: true,
path: `${CAMPAIGN_AGENDA_HOME}?municipality=12&deputyPresent=1&tag=Com%C3%ADcio`,
label: 'Agenda',
})
const legacy = buildCampaignNavigationLink(coordinator.role, {
destination: 'activityList',
q: 'centro',
tab: 'todos',
})
expect(legacy.ok && legacy.path).toBe('/campanha/atividades?q=centro&tab=todos')
const mixed = buildCampaignNavigationLink(coordinator.role, {
destination: 'activityList',
q: 'centro',
deputyPresent: true,
})
expect(mixed.ok).toBe(false)
})
it('returns partial success in batch mode', () => {
const result = buildCampaignNavigationLinks(coordinator.role, [
{ destination: 'home', label: 'Início' },

View file

@ -19,6 +19,9 @@ describe('campaignPageChrome', () => {
expect(resolveCampaignPageChrome('/campanha/liderancas', 'advisor')).toEqual({
title: 'Lideranças',
})
expect(resolveCampaignPageChrome('/campanha/agenda', 'coordinator')).toEqual({
title: 'Agenda',
})
})
it('returns null for entity detail pages (chrome set per route)', () => {

View file

@ -26,6 +26,7 @@ import {
shouldMountQuickActionsFab,
} from '@/lib/campaignQuickActionMount'
import {
ACTIVITY_DETAIL_ROOT_PATH,
ACTIVITY_LIST_PATH,
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
@ -103,7 +104,10 @@ describe('campaignQuickActionPaths (activities)', () => {
it('parses list and detail surfaces', () => {
expect(parseActivityQuickActionSurface(ACTIVITY_LIST_PATH)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface(`${ACTIVITY_LIST_PATH}/`)).toEqual({ kind: 'list' })
expect(parseActivityQuickActionSurface('/campanha/atividades/caminhada-centro')).toEqual({
expect(parseActivityQuickActionSurface(ACTIVITY_DETAIL_ROOT_PATH)).toEqual({ kind: 'list' })
expect(
parseActivityQuickActionSurface(`${ACTIVITY_DETAIL_ROOT_PATH}/caminhada-centro`),
).toEqual({
kind: 'detail',
activitySlug: 'caminhada-centro',
})