diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 225e9008..139bb9cf 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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). diff --git a/docs/plans/fullcalendar-agenda-campanha-impl.md b/docs/plans/fullcalendar-agenda-campanha-impl.md new file mode 100644 index 00000000..d183d687 --- /dev/null +++ b/docs/plans/fullcalendar-agenda-campanha-impl.md @@ -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
municipality + deputyPresent + tag] --> R[RSC staff-gated
opções acessíveis] + R --> C[Ilha FullCalendar v7
week / month / list] + C -->|events function: intervalo ativo| A[server action de leitura] + A --> L[activityPageData
range limitado + overrideAccess false] + L --> D[DTO mínimo por evento] + C -->|slot| F[/atividades/nova
horário + município prefilled] + C -->|evento| E[/atividades/slug
detalhe existente] + C -->|drop / resize| W[rescheduleActivity] + W --> T[transaction + lock activity:id
fresh actor + overrideAccess false] + T --> H[hooks Activity
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:`, 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. diff --git a/package.json b/package.json index e3da190c..94ee312a 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@ai-sdk/deepseek": "^3.0.19", "@ai-sdk/react": "^4.0.50", "@base-ui/react": "^1.6.0", + "@fullcalendar/react": "7.0.2", "@hookform/resolvers": "^5.2.2", "@payloadcms/db-postgres": "3.82.0", "@payloadcms/email-resend": "3.82.0", @@ -116,6 +117,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", "tailwindcss-animate": "^1.0.7", + "temporal-polyfill": "^1.0.3", "topojson-client": "^3.1.0", "tw-animate-css": "^1.4.0", "web-push": "^3.6.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 938e9cac..b0850df1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 2f3f8ac6..bf0425a8 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -45,6 +45,7 @@ export const E2E_AFFECTED_MANIFEST = [ }, { prefixes: [ + `${CAMPAIGN_APP}/agenda`, `${CAMPAIGN_APP}/atividades`, 'src/components/campaign/activity', 'src/utilities/activity', diff --git a/src/app/(campaign)/campanha/(app)/agenda/loading.tsx b/src/app/(campaign)/campanha/(app)/agenda/loading.tsx new file mode 100644 index 00000000..1db966c5 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/agenda/loading.tsx @@ -0,0 +1,10 @@ +export default function AgendaLoading() { + return ( +
+
+
+
+ Carregando agenda. +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx new file mode 100644 index 00000000..6d3dda30 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -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> +} + +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 ( + +
+ + +
+ + + + + + + +
+ ) +} diff --git a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx index 1c98d78b..2ae70446 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx @@ -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> +} + +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 (
@@ -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} diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index 01710537..48054c18 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -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 => { + 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[] = [], diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 0de1a99f..0b78b616 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -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 diff --git a/src/components/campaign/activity/ActivityAgenda.css b/src/components/campaign/activity/ActivityAgenda.css new file mode 100644 index 00000000..f5267c92 --- /dev/null +++ b/src/components/campaign/activity/ActivityAgenda.css @@ -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; + } +} diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx new file mode 100644 index 00000000..f11a38a7 --- /dev/null +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -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 = { + 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 ( +
+
+ {timeText ? {timeText} : null} + {props.deputyPresent ? ( + + + ) : null} +
+ {event.title} + {location ? {location} : null} + {activityStatusLabels[props.status]} +
+ ) +} + +export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { + const router = useRouter() + const mountedRef = useRef(false) + const pendingEventIDs = useRef(new Set()) + const [visibleRange, setVisibleRange] = useState<{ start: string; end: string } | null>(null) + const [events, setEvents] = useState([]) + const [reloadCount, setReloadCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [savingCount, setSavingCount] = useState(0) + const [loadError, setLoadError] = useState(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 ( +
+
+ {savingCount > 0 ? 'Salvando novo horário…' : null} + {loadError} +
+ + {isLoading ? ( +
+ Carregando compromissos… +
+ ) : null} + + {loadError ? ( +
+ {loadError} + +
+ ) : null} + + {!isLoading && !loadError && events.length === 0 ? ( +
+ Nenhum compromisso nesta janela e neste filtro. + +
+ ) : null} + +
0}> + +
+
+ ) +} diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx new file mode 100644 index 00000000..ec6378f2 --- /dev/null +++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx @@ -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 ( +
+
+
+ + { + const municipality = Number(event.target.value) + update({ + ...draft, + municipality: municipality > 0 ? municipality : undefined, + }) + }} + className="w-full **:data-[slot=native-select]:min-h-11" + > + Todos os municípios + {municipalityOptions.map((option) => ( + + {option.name} + + ))} + +
+ +
+ + update({ ...draft, tag: event.target.value || undefined })} + className="w-full **:data-[slot=native-select]:min-h-11" + > + Todas as tags + {knownTags.map((tag) => ( + + {tag} + + ))} + +
+ + + + +
+

+ {isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'} +

+
+ ) +} diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx index c7a4e840..1e2f18dd 100644 --- a/src/components/campaign/activity/ActivityForm.tsx +++ b/src/components/campaign/activity/ActivityForm.tsx @@ -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 submittedTitle?: string searchContacts: (query: string) => Promise @@ -153,6 +155,7 @@ const ActivityFormFields = ({ advisorOptions = [], canManageAdvisors = false, activity, + initialValues, fieldErrors = {}, submittedTitle, searchContacts, @@ -172,6 +175,9 @@ const ActivityFormFields = ({ const [leadership, setLeadership] = useState( 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 ( @@ -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 = ({ { 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 = ({
diff --git a/src/components/campaign/municipality/MunicipalityDossier.tsx b/src/components/campaign/municipality/MunicipalityDossier.tsx index 8569e64e..17b722e3 100644 --- a/src/components/campaign/municipality/MunicipalityDossier.tsx +++ b/src/components/campaign/municipality/MunicipalityDossier.tsx @@ -294,7 +294,7 @@ export const MunicipalityDossier = ({ {data.upcomingActivities.length ? ( diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts index 63bf5a9f..4e34fbf1 100644 --- a/src/components/campaign/shell/nav.ts +++ b/src/components/campaign/shell/nav.ts @@ -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}/`) } diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index 03095b7b..146ebaa6 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -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), diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index 88f24e8e..34868c2a 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -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 diff --git a/src/lib/campaignQuickActionPaths.ts b/src/lib/campaignQuickActionPaths.ts index 31aff93a..739f23d0 100644 --- a/src/lib/campaignQuickActionPaths.ts +++ b/src/lib/campaignQuickActionPaths.ts @@ -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 } diff --git a/src/lib/schemas/activity.ts b/src/lib/schemas/activity.ts index d1564f91..e7b5cabe 100644 --- a/src/lib/schemas/activity.ts +++ b/src/lib/schemas/activity.ts @@ -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 export type ActivityUpdateInput = z.input +export type ActivityAgendaRequest = z.input +export type ActivityAgendaRequestData = z.output +export type ActivityRescheduleInput = z.input /** * E13 — one stop of a giro, as the composer submits it: which município and diff --git a/src/utilities/access/activities.ts b/src/utilities/access/activities.ts index 46afd533..143c2d2c 100644 --- a/src/utilities/access/activities.ts +++ b/src/utilities/access/activities.ts @@ -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 => { if (isPayloadAdmin(req.user)) return true diff --git a/src/utilities/activityPageData.ts b/src/utilities/activityPageData.ts index 4f3230d4..60c83ddf 100644 --- a/src/utilities/activityPageData.ts +++ b/src/utilities/activityPageData.ts @@ -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, + 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, + user: CampaignUser, +): Promise => { + 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 diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 178c1311..9018fc71 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -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(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, + accessibleTags: ReadonlySet, +): 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 = {}, +): 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, + accessibleTags: ReadonlySet, +): 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, +): 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(activityListParamNames) diff --git a/src/utilities/activityViewModels.ts b/src/utilities/activityViewModels.ts index 1056e63e..7aa85c1b 100644 --- a/src/utilities/activityViewModels.ts +++ b/src/utilities/activityViewModels.ts @@ -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 diff --git a/src/utilities/ai/campaignNavigationUrls.ts b/src/utilities/ai/campaignNavigationUrls.ts index 839ecb5e..42899d94 100644 --- a/src/utilities/ai/campaignNavigationUrls.ts +++ b/src/utilities/ai/campaignNavigationUrls.ts @@ -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 = diff --git a/src/utilities/ai/tools/buildCampaignLinks.ts b/src/utilities/ai/tools/buildCampaignLinks.ts index f17d64bf..383c7733 100644 --- a/src/utilities/ai/tools/buildCampaignLinks.ts +++ b/src/utilities/ai/tools/buildCampaignLinks.ts @@ -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({ diff --git a/src/utilities/campaignAccess.ts b/src/utilities/campaignAccess.ts index 79ce6042..f3375efe 100644 --- a/src/utilities/campaignAccess.ts +++ b/src/utilities/campaignAccess.ts @@ -105,6 +105,7 @@ export { } from '@/utilities/access/demands' export { + canCampaignUserRescheduleActivity, canCreateActivity, canCreateActivityAdvisors, canDeleteActivity, diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index c8a1ea0d..f643a3fd 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -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}`, + ) + }) +}) diff --git a/tests/int/campaignActivity.int.spec.ts b/tests/int/campaignActivity.int.spec.ts index 38e72fee..a93a595c 100644 --- a/tests/int/campaignActivity.int.spec.ts +++ b/tests/int/campaignActivity.int.spec.ts @@ -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({ + ...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({ + ...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({ + ...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({ + ...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') diff --git a/tests/unit/activityAgenda.unit.spec.ts b/tests/unit/activityAgenda.unit.spec.ts new file mode 100644 index 00000000..61afa0ca --- /dev/null +++ b/tests/unit/activityAgenda.unit.spec.ts @@ -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({ + 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({ 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({ + id: 8, + title: 'Comício', + slug: 'comicio', + tags: [], + status: 'confirmado', + deputyPresent: true, + startAt: '2026-08-08T22:00:00.000Z', + municipality: stub({ id: 12, name: 'Ilhéus', slug: 'ilheus' }), + }), + user('advisor'), + ).canReschedule, + ).toBe(false) + }) +}) diff --git a/tests/unit/activityAgendaInteractions.unit.spec.tsx b/tests/unit/activityAgendaInteractions.unit.spec.tsx new file mode 100644 index 00000000..d93bfb52 --- /dev/null +++ b/tests/unit/activityAgendaInteractions.unit.spec.tsx @@ -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() + + 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() + + 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.') + }) +}) diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts index 7e2ce1db..765c3e3a 100644 --- a/tests/unit/activityUi.unit.spec.ts +++ b/tests/unit/activityUi.unit.spec.ts @@ -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' }) + }) +}) diff --git a/tests/unit/campaignNav.unit.spec.ts b/tests/unit/campaignNav.unit.spec.ts index fa645355..00479476 100644 --- a/tests/unit/campaignNav.unit.spec.ts +++ b/tests/unit/campaignNav.unit.spec.ts @@ -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, + ) + }) +}) diff --git a/tests/unit/campaignNavigationUrls.unit.spec.ts b/tests/unit/campaignNavigationUrls.unit.spec.ts index 504876d8..887b2fa2 100644 --- a/tests/unit/campaignNavigationUrls.unit.spec.ts +++ b/tests/unit/campaignNavigationUrls.unit.spec.ts @@ -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' }, diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts index ce4ef71f..5c789a8f 100644 --- a/tests/unit/campaignPageChrome.unit.spec.ts +++ b/tests/unit/campaignPageChrome.unit.spec.ts @@ -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)', () => { diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index eee4cf04..215ed713 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -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', })