mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Merge pull request #409 from fsolla/C15-fullcalendar-em-campanha-agenda
feat(campanha): C15 — FullCalendar em /campanha/agenda
This commit is contained in:
commit
55178247c8
36 changed files with 2228 additions and 47 deletions
|
|
@ -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).
|
||||
|
|
|
|||
180
docs/plans/fullcalendar-agenda-campanha-impl.md
Normal file
180
docs/plans/fullcalendar-agenda-campanha-impl.md
Normal 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.
|
||||
|
|
@ -80,6 +80,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",
|
||||
|
|
@ -118,6 +119,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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
},
|
||||
{
|
||||
prefixes: [
|
||||
`${CAMPAIGN_APP}/agenda`,
|
||||
`${CAMPAIGN_APP}/atividades`,
|
||||
'src/components/campaign/activity',
|
||||
'src/utilities/activity',
|
||||
|
|
|
|||
10
src/app/(campaign)/campanha/(app)/agenda/loading.tsx
Normal file
10
src/app/(campaign)/campanha/(app)/agenda/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
src/app/(campaign)/campanha/(app)/agenda/page.tsx
Normal file
83
src/app/(campaign)/campanha/(app)/agenda/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[] = [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
155
src/components/campaign/activity/ActivityAgenda.css
Normal file
155
src/components/campaign/activity/ActivityAgenda.css
Normal 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;
|
||||
}
|
||||
}
|
||||
307
src/components/campaign/activity/ActivityAgenda.tsx
Normal file
307
src/components/campaign/activity/ActivityAgenda.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
src/components/campaign/activity/ActivityAgendaFilters.tsx
Normal file
115
src/components/campaign/activity/ActivityAgendaFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}/`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export {
|
|||
} from '@/utilities/access/demands'
|
||||
|
||||
export {
|
||||
canCampaignUserRescheduleActivity,
|
||||
canCreateActivity,
|
||||
canCreateActivityAdvisors,
|
||||
canDeleteActivity,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
148
tests/unit/activityAgenda.unit.spec.ts
Normal file
148
tests/unit/activityAgenda.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
110
tests/unit/activityAgendaInteractions.unit.spec.tsx
Normal file
110
tests/unit/activityAgendaInteractions.unit.spec.tsx
Normal 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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue