Merge pull request #719 from fsolla/C123-agenda-modal-central-como-unica-superficie-de-criacao-e

C123 — Agenda: overlay modal central como única superfície de criação/edição de atividade
This commit is contained in:
Francisco Solla 2026-08-12 14:36:54 -03:00 committed by GitHub
commit 5557199977
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2148 additions and 2293 deletions

View file

@ -11,6 +11,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
**Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `<input name="id">` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md).
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).

View file

@ -0,0 +1,127 @@
# Impl: C123 — Agenda: modal central como única superfície de criação/edição de atividade
Status: aprovado
Atualizado em: 2026-08-11
Issue: #667
Intenção: docs/plans/agenda-criacao-modal-central.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** um overlay só (modal central no desktop, drawer no mobile) cria E edita compromisso com todas as configurações do formulário completo; clique no slot abre criação; clique no evento abre edição; `/nova` e `/editar` saem sem redirecionamento (404); "Local" sem duplicação; Início/Término com seletor de data e seletor de hora separados, visíveis sem rolar; salvar não navega (refetch da janela).
- **O que NÃO negociar:** contrato civil `YYYY-MM-DDTHH:mm` de parse/validação; validação início ≤ término; modo todo-o-dia (só data); prefill do slot; permissões staff (`overrideAccess: false`); título imutável na edição; líderes intocados; detalhe `/atividades/[slug]` continua (não é formulário); recorrência fora.
- **O que reavaliar (hipóteses da intenção):**
- A intenção sugere "popover vira modal" — confirmado, mas o mecanismo do popover inteiro (anchor no ponto de clique) morre junto; o container passa a ser `Dialog` (desktop) + `Drawer` (mobile, C103 intacto).
- A intenção assume `ActivityForm.tsx` como referência de seções — confirmado: as seções (cards) migram para o overlay; `ActivityForm` inteiro morre (só as duas páginas o usam).
- **Descoberta fora da hipótese:** o update em página está **quebrado desde C14** (adc29c03 removeu o `<input type="hidden" name="id">` de `ActivityForm`; `parseActivityUpdateFormData` exige `id` e lança `FormDataBoundaryError` em todo submit). O overlay conserta o caminho de edição ao renderizar `id` explícito.
- `activityInlineErrors.ts` só mapeia duplicate-title; o overlay (form completo) precisa do mapeamento de `FormDataBoundaryError`/ZodError que `formActions.ts` já tem — os dois mappers se unificam no módulo de erros do overlay (formActions morre).
## Abordagem recomendada
```mermaid
flowchart LR
slot["dateClick (slot)"] -->|activitySlotPrefill| req["request: {kind:'create', startAt, endAt}"]
event["eventClick (evento)"] -->|preventDefault + id| req2["request: {kind:'edit', activityId}"]
req --> overlay["ActivityOverlay<br/>(Dialog desktop / Drawer mobile)"]
req2 --> overlay
overlay -->|edit: busca| draft["loadActivityEditDraft<br/>(action, activityFormSelect, overrideAccess:false)"]
overlay -->|create/edit| form["ActivityOverlayForm<br/>(todas as seções do ActivityForm)"]
form -->|FormData| create["createActivityOverlay / updateActivityOverlay<br/>(reusa parseActivity*FormData + create/updateActivityRecord)"]
create -->|{ok}| close["fecha + refetch da janela (setReloadCount)"]
create -->|{ok:false}| inline["overlay aberto + fieldErrors/toast"]
draft -->|"ActivityFormViewModel"| form
```
**Opções consideradas:** A (recomendada, abaixo) | B (objeto estruturado client-side) | C (overlay no server component via RSC)
**Recomendação:** A — **editar o dono**: `ActivityInlineCreate.tsx` é renomeado/estendido para `ActivityOverlay.tsx` (container Dialog/Drawer + form completo); o pipeline de submit vira FormData indo para as novas actions `createActivityOverlay`/`updateActivityOverlay` que **reusam os parsers server-side existentes** (`parseActivityCreateFormData`/`parseActivityUpdateFormData` — únicos, testados, usados pelo giro) e os `*Record` transacionais, retornando `{ ok }` com `fieldErrors` mapeados. O overlay renderiza os campos controlados com `name` + inputs hidden (startAt/endAt/allDay/municipality/status/id); as seções (tags/responsáveis/orgs/tarefas/demandas) já emitem hidden inputs próprios. Sem segunda parser no client.
**Rejeitadas:** B — client teria que serializar 5 JSONs (tasks/demands/responsibles/tags/orgs) com parsing próprio, duplicando `activityFormData.ts` no browser; C — RSC não sustenta overlay com estado + fetch pós-clique.
### Decisões de engenharia
**D1 — Container do overlay.**
Opções: A) `Dialog` (desktop, central, com overlay escuro) + `Drawer` topo (mobile, C103) | B) manter `Popover`+anchor e só crescer o conteúdo | C) `Sheet` lateral nos dois.
Recomendação: **A** — o aceite é "modal central", o popover ancorado morre (o anchor `{x,y}` sai do draft); `Dialog` do shadcn (`dialog.tsx`, `max-w-sm` default → override p/ `max-w-2xl`) com corpo rolável e rodapé fixo (corte do rabbit hole da intenção); mobile continua o sheet do topo do C103 (mesma anatomia, mesmas seções, labels sr-only). Seleção pelo mesmo `isNarrow` (ResizeObserver, 640px) de hoje.
Rejeitadas: B — anti-aceite explícito (popover sai); C — sheet lateral não é modal central nem bottom sheet.
**D2 — Dados da edição (quem busca o view model).**
Opções: A) o overlay busca sozinho no mount via action `loadActivityEditDraft(activityId)` | B) o pai (ActivityAgenda) busca no eventClick e passa por prop | C) embutir o form completo no fetch de eventos da janela.
Recomendação: **A** — o overlay vira o dono do contrato "editar atividade por id", reutilizável no detalhe (botão Editar) sem duplicar loading no calendário; `loadActivityEditDraft` = `findByID` com `activityFormSelect`, depth 1, `overrideAccess: false` + actor → `toActivityFormViewModel` (scoping de advisor herdado do access). C deixa o fetch da agenda pesado (todo evento carregaria descrição/orgs/tarefas).
Rejeitadas: B — espalha o fetch para cada host; C — regressão de payload da janela.
**D3 — Submit: FormData nas actions do overlay.**
Opções: A) `createActivityOverlay(formData)`/`updateActivityOverlay(formData)` reusando `parseActivity*FormData` + `create/updateActivityRecord`, retorno `{ok, message, fieldErrors}` | B) manter `createActivityInline(input: object)` e criar `updateActivityInline` análogo, com parse dos JSONs no client | C) useActionState + redirect como o form antigo.
Recomendação: **A** — um parser server-side único (os parsers já existem e o giro depende deles); o client só dá `name` aos controlados (o `ActivityDateTimeField` reescrito emite o civil via hidden input). O mapeamento de erro junta o duplicate-title (`activityInlineErrors.ts`, estendido) + duplicate-demand + `FormDataBoundaryError`/ZodError via `mapCampaignFormActionError` — o `getActionError` do formActions morre junto com as páginas, seu conteúdo migra para `activityOverlayErrors.ts`.
Rejeitadas: B — client duplica parsing/validação de 5 campos JSON (drift client×server que o repo evitou até hoje); C — redirect é anti-aceite (salvar não navega).
**Bug legado consertado:** o overlay de edição renderiza `<input type="hidden" name="id">` — o caminho de update estava quebrado desde C14.
**D4 — Seletor de data + hora separados.**
Opções: A) reescrever `ActivityDateTimeField` internamente: trigger de data (calendar em popover desktop / bottom sheet mobile) + selects Hora/Minuto **inline** lado a lado, sempre visíveis | B) dois componentes novos (DatePicker + TimeSelects) | C) manter o picker único atual e só remover o duplicado de Local.
Recomendação: **A** — contrato civil `YYYY-MM-DDTHH:mm` e props inalterados (quem usa hoje: só o overlay); o time sai de dentro do popover (anti-aceite atual: "horário escondido abaixo do calendário"); all-day oculta só os selects de hora. Mobile: linha `[dia][hora][minuto]` compacta (aceite C123: dia e hora lado a lado na mesma linha, Início em cima / Término embaixo).
Rejeitadas: B — dois módulos para um único conceito "campo data+hora" (pass-through); C — não cumpre o aceite.
**D5 — Clique no evento abre a edição (e o detalhe continua acessível).**
Opções: A) `eventClick` no FullCalendar com `preventDefault` → abre overlay de edição; manter `url` no EventInput (middle-click/abrir em nova aba ainda navega ao detalhe) | B) remover `url` e interceptar tudo | C) manter navegação e só o botão Editar abre overlay.
Recomendação: **A** — decisão do gate (recomendação A da intenção); `preventDefault` no `eventClick` cobre clique simples; o `url` preserva o detalhe por atalhos de browser; o overlay de edição ganha link "Ver detalhes" (ghost, esquerda do rodapé).
Rejeitadas: B — perde o detalhe por middle-click; C — anti-aceite (clique no evento abre overlay).
**D6 — Pontos de entrada que apontavam para `/nova` (não podem 404).**
Opções: A) contexto de quick actions (precedente C94/C114 `openCalendarFeed`/`openGoogleCalendarSync`): `openActivityCreate`/`openActivityEdit` funções no `CampaignQuickActionContext`, bridged por cada página; FAB e botões viram `onAction` | B) manter href e redirecionar para a agenda com `?nova=1` | C) remover os botões.
Recomendação: **A** — mesma ponte que a agenda já usa para os diálogos de feed/Google; cada host monta o overlay e bridga: agenda (FAB + ícone "+" dos filtros → create com draft padrão hoje 09:00–10:00 via `activitySlotPrefill({allDay:true})`), lista `/atividades` (botão "Nova atividade" + FAB), detalhe (botão "Editar" + FAB edit → overlay de edição com `view.id`). `ACTIVITY_NEW_PATH` e a entrada `atividadesNova` do catálogo de chrome morrem.
Rejeitadas: B — param novo na agenda (estado de URL whitelisted) para um atalho que o contexto já resolve; C — regressão de feature.
**D7 — O que morre (remoção sem redirect, 404 — decisão do gate).**
- Páginas `nova/page.tsx` e `[slug]/editar/page.tsx` + `formActions.ts` (redirect-based) + `ActivityForm.tsx` (sections migram para o overlay).
- `activityUi.ts`: `buildActivityCreateHref`, `parseActivityCreatePrefill`, `ActivityCreatePrefill`, `parseActivityAgendaReturnHref` (+ helpers privados de prefill) — "Mais detalhes" morre junto; `activitySlotPrefill` permanece (prefill do slot + draft padrão).
- `activityPageData.ts`: `getActivityEditPageData` (só a página editar usava) — o novo `loadActivityEditDraftRecord` busca por id.
- `campaignPageChrome.ts`: match de `/atividades/nova` + catálogo `atividadesNova` + match de `/atividades/[slug]/editar`.
- `activityQuickActions.ts`: `new-activity` e `edit-activity` passam de `href` para `onAction` (contexto); `ACTIVITY_NEW_PATH` sai.
- `ActivityInlineCreate.tsx` → **`ActivityOverlay.tsx`** (rename + rewrite; `ActivityInlineCreateDraft` vira `ActivityOverlayRequest` sem `anchor`).
- `activityInlineErrors.ts` → **`activityOverlayErrors.ts`** (estende: duplicate-title + duplicate-demand + boundary/zod).
### Componentes / mudanças
- **`ActivityOverlay.tsx`** (client, renomeado de `ActivityInlineCreate.tsx`): props `{ request: ActivityOverlayRequest | null, agendaState, municipalityOptions, organizationOptions, knownTags, searchContacts, searchResponsibles, onClose, onSaved }`; container `Dialog`/`Drawer` pelo `isNarrow`; `ActivityOverlayForm` com todas as seções (Informações básicas → título readOnly na edição + tags + descrição + deputado presente; Data e horário → `ActivityDateTimeField` reescrito + todo-o-dia; Onde → município `StrictCombobox` + Local **uma única vez**; Pessoas e organizações → `ResponsibleMultiSelect` + `RelationMultiSelect`; Tarefas → `ActivityTaskFields`; Demandas → `ActivityDemandFields`); modo edit busca `loadActivityEditDraft` no mount (estado de loading no modal) e renderiza `id` hidden; rodapé fixo: edição `[Ver detalhes] [Cancelar] [Salvar]`, criação `[Cancelar] [Salvar]`; hidden `status=confirmado` só na criação (na edição não envia status — evita realizar→confirmado).
- **`ActivityDateTimeField.tsx`** (client): reescrito — trigger de data (calendar em popover desktop / nested bottom sheet mobile) + `NativeSelect` Hora + Minuto inline; `timeVisible={false}` (todo-o-dia) oculta só os selects; contrato e props preservados.
- **`actions/activity.ts`**: `createActivityOverlay`/`updateActivityOverlay` (FormData → parse → `*Record` → `{ok}`) substituem `createActivityInline`; novo `loadActivityEditDraft` (findByID `activityFormSelect`, depth 1, `overrideAccess:false`).
- **`utilities/activityOverlayErrors.ts`**: mapeamento unificado (duplicate-title, duplicate-demand, boundary/zod, fallback genérico).
- **`ActivityAgenda.tsx`**: estado vira `request` (`create` com prefill do slot / `edit` com id); `eventClick` → preventDefault + edit; `organizationOptions` prop nova; ponte `openActivityCreate` no contexto (draft padrão); onSaved = fechar + refetch (já é o mecanismo de hoje).
- **`agenda/page.tsx`**: carrega `loadOrganizationOptions` (nova) e passa ao `ActivityAgenda`.
- **`[slug]/page.tsx` (detalhe)**: botão "Editar" vira host client `ActivityEditOverlayHost` (botão + overlay + ponte `openActivityEdit`); página passa a carregar municipality/organization options + knownTags.
- **`atividades/page.tsx` (lista)**: botão "Nova atividade" vira host client `ActivityCreateOverlayHost` (draft padrão + ponte `openActivityCreate`); página carrega organization options + knownTags.
- **Migration:** nenhuma — sem mudança de schema.
- **Access / Consent:** nenhum novo — todas as escritas pelos mesmos `*Record` (`overrideAccess: false` + actor); `loadActivityEditDraft` idem (advisor escopado pelo access do payload); líderes não alcançam nenhum host (agenda/detalhe/lista são staff-gated; `isStaff` já condiciona o botão Editar).
- **UI:** Impeccable C — fluxo redesenhado na agenda (shape → craft → critique → polish). Desktop: modal central `max-w-2xl`, seções em cards como o formulário atual, corpo rolável + rodapé fixo. Mobile: sheet do topo do C103 com as mesmas seções em linhas label-less + rodapé fixo; linhas de Início/Término `[dia][hora][minuto]`.
### Dados → forma
N/A — superfície de escrita; nenhum dado novo de apresentação.
## Fases verificáveis
1. **Server/actions** (maior parte do appetite): `activityOverlayErrors.ts`; `createActivityOverlay`/`updateActivityOverlay`/`loadActivityEditDraft`; unit dos mappers; int do `loadActivityEditDraftRecord` (scoping advisor/leader) e do update com `id`. Verificar: `tsc`, `pnpm test` int.
2. **UI overlay** (Impeccable C): `ActivityOverlay.tsx` + `ActivityDateTimeField` reescrito + hosts (agenda/detalhe/lista) + ponte de quick actions; remoção das páginas/formActions/ActivityForm/prefill. Verificar: `tsc`, `lint`, `knip` (órfãos: `buildActivityCreateHref`, `getActivityEditPageData`, `atividadesNova`, `ACTIVITY_NEW_PATH`), `check:cycles`.
3. **Testes** — unit: `activityOverlay.unit.spec.tsx` (rewrite do `activityInlineCreate.unit.spec.tsx`: modal central, Local único, seções completas, edição prefilled + id, título readOnly, Ver detalhes); `activityUi.unit.spec.ts` (sai prefill, fica slotPrefill); `activityAgendaInteractions.unit.spec.tsx` (+ eventClick → overlay de edição). e2e: `campaignActivity.e2e.spec.ts` (o de "/nova com demandas" vira fluxo pelo overlay; "Mais detalhes" vira edição pelo overlay; C104 atualiza clique no evento → overlay → "Ver detalhes"); `campaignAgendaMobile.e2e.spec.ts` (C103: hora inline, sem bottom sheet aninhado nem "Pronto").
4. **Gates** — `tsc --noEmit`, `lint` (0 warnings), `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm test:e2e`, `pnpm build` contra DB local.
## Rabbit holes / Não escopo (engenharia)
- Redesenho da página de detalhe (fora — leitura/resultado fica como está).
- Recorrência, drag-and-drop de remarcação, filtros/feed iCal, Google sync — intocados.
- Segundo estilo de card no modal (2ª variante visual) — o modal herda os cards do formulário.
- Prefetch do view model de edição no hover do evento — barato demais para agora; o fetch no mount já é rápido (um findByID).
## Riscos e mitigação
- **e2e quebrados pela remoção das rotas:** esperado — substituir os 3 fluxos (`/nova` com demandas, "Mais detalhes", clique no evento → detalhe) por fluxos via overlay, não remover cobertura.
- **e2e mobile C103 (bottom sheet aninhado do tempo):** o aceite C123 muda a anatomia (hora inline) — atualizar as asserções (sem "Pronto", selects visíveis na linha).
- **Update quebrado desde C14:** o overlay renderiza `id` — o caminho de edição passa a funcionar; cobrir com e2e novo (editar via overlay, evento atualiza sem navegar).
- **`Dialog` aninhado** (seletor de data em popover dentro do modal; `CommandDialog` dos responsáveis por cima): portais Radix separados, mesmo comportamento já validado no sheet do C103; validar no craft.
- **knip com órfãos do form antigo:** remover no mesmo commit das páginas (grep de cada símbolo antes de apagar — `ActivityFormState`, `buildActivityCreateHref`, etc.).
- **Clique vs drag no FullCalendar:** `eventClick` não dispara após drag (comportamento do FullCalendar, já exercitado pelo teste de remarcação que continua verde).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: modal central desktop / drawer mobile; todas as seções no overlay (criar E editar); clique no slot abre criação com prefill; clique no evento abre edição (detalhe via link no overlay); `/nova` e `/editar` → 404 sem redirect; "Local" único; data e hora em seletores separados visíveis sem rolar; salvar não navega + refetch da janela; guardrails intactos (civil, início ≤ término, todo-o-dia, título imutável, staff)
- [ ] Invariantes AGENTS/engineering-standards: mesmos `*Record` transacionais com `overrideAccess: false` + actor; sem `Contact`/Consent novos; identificadores em inglês; copy pt-BR; sem migration; líderes intocados
- [ ] Testes de domínio: unit (overlay create/edit, erro inline, mappers), int (scoping do `loadActivityEditDraft`, update com id), e2e (criação completa pelo overlay, edição pelo clique no evento, remoção das rotas, mobile hora inline)

View file

@ -30,7 +30,10 @@ import {
restrictActivityAgendaState,
} from '@/utilities/activityUi'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
export const metadata = campaignPageMetadataFromCatalog('agenda')
@ -47,12 +50,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const [municipalityOptions, knownTags, feeds, googleSyncState] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadAccessibleActivityTags(payload, user),
listCalendarFeeds().catch(() => []),
getGoogleCalendarSyncState(),
])
const [municipalityOptions, organizationOptions, knownTags, feeds, googleSyncState] =
await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadAccessibleActivityTags(payload, user),
listCalendarFeeds().catch(() => []),
getGoogleCalendarSyncState(),
])
const state = restrictActivityAgendaState(
resolvedUrl.state,
new Set(municipalityOptions.map((option) => option.id)),
@ -109,6 +114,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
<ActivityAgenda
state={state}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
/>
</CampaignListResults>

View file

@ -1,72 +0,0 @@
import config from '@payload-config'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import {
searchActivityContactOptions,
searchActivityResponsibleOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
type EditActivityPageProps = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: EditActivityPageProps) {
const { slug } = await params
if (!slug) return campaignPageMetadata({ title: 'Editar atividade' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
getPayload({ config }),
])
try {
const view = await getActivityEditPageData(payload, user, slug)
return campaignPageMetadata({ title: `Editar ${view.title}` })
} catch {
return campaignPageMetadata({ title: 'Editar atividade' })
}
}
export default async function EditActivityPage({ params }: EditActivityPageProps) {
const [{ slug }, user, payload] = await Promise.all([
params,
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
getPayload({ config }),
])
if (!slug) notFound()
const [view, municipalityOptions, organizationOptions] = await Promise.all([
getActivityEditPageData(payload, user, slug).catch((error) => {
if (error instanceof ActivityNotFoundError) notFound()
throw error
}),
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
])
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<SetCampaignPageChrome chrome={{ title: `Editar ${view.title}` }} />
<ActivityForm
action={updateActivityFormAction}
activity={view}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
submitLabel="Salvar alterações"
searchContacts={searchActivityContactOptions}
searchResponsibles={searchActivityResponsibleOptionsAction}
/>
</div>
)
}

View file

@ -1,9 +1,10 @@
import config from '@payload-config'
import { PencilIcon, PlusIcon } from 'lucide-react'
import { PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActivityEditOverlayHost } from '@/components/campaign/activity/ActivityEditOverlayHost'
import {
CancelActivityDialog,
MarkActivityRealizedDialog,
@ -19,7 +20,12 @@ import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
import { formatActivityWhenLabel } from '@/utilities/activityViewModels'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
import {
@ -90,6 +96,12 @@ export default async function ActivityDetailPage({
const canonicalTabRedirect = getActivityDetailTabRedirect(view.slug, query)
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadAccessibleActivityTags(payload, user),
])
const isStaff = isCampaignStaff(user)
const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
const chromeSubtitle = view.locationLabel || view.municipality?.name
@ -101,6 +113,8 @@ export default async function ActivityDetailPage({
chromeSubtitle ? { title: view.title, subtitle: chromeSubtitle } : { title: view.title }
}
/>
{/* C123 — must stay ABOVE ActivityEditOverlayHost: the bridge replaces
the whole context, the host merges `openActivityEdit` on top. */}
<CampaignQuickActionContextBridge
activitySlug={view.slug}
municipalitySlug={view.municipality?.slug}
@ -122,12 +136,12 @@ export default async function ActivityDetailPage({
</div>
<div className="flex flex-wrap gap-2">
{isStaff ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/atividades/${view.slug}/editar`}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar
</Link>
</Button>
<ActivityEditOverlayHost
activityId={view.id}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
/>
) : null}
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/atividades/${view.slug}?tab=updates&newUpdate=1`}>

View file

@ -1,108 +0,0 @@
'use server'
// Documented exception to `runCampaignRedirectFormAction`/`runCampaignFormAction`
// (Pass 2 W4d): these ladders carry a custom error mapper (DB unique-violation
// regexes surfacing as field errors) plus an async duplicate-title fallback
// that links to the existing activity — policy the shared wrappers deliberately
// don't grow parameters for.
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { createActivity, updateActivity } from '@/app/(campaign)/campanha/actions/activity'
import type { ActivityFormState } from '@/components/campaign/activity/ActivityForm'
import { optionalFormText } from '@/lib/formData'
import { slugify } from '@/lib/slug'
import {
parseActivityCreateFormData,
parseActivityUpdateFormData,
} from '@/utilities/activityFormData'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
const getActionError = (error: unknown): ActivityFormState => {
if (
error instanceof Error &&
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
) {
return {
fieldErrors: {
demandsJson: ['Já existe uma demanda com um dos títulos informados.'],
},
}
}
// Checked before the shared mapper: a DB-level unique-violation message isn't
// a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own
// branch to surface as a field error instead of falling to the generic message.
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
return { fieldErrors: { title: ['Já existe uma atividade com este título.'] } }
}
return mapCampaignFormActionError({
error,
genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.',
})
}
const existingActivityState = async (formData: FormData): Promise<ActivityFormState | null> => {
const title = optionalFormText(formData, 'title')
const slug = title ? slugify(title) : ''
if (!slug) return null
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) return null
const result = await payload.find({
collection: 'activity',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
pagination: false,
select: { slug: true },
user,
overrideAccess: false,
})
const existing = result.docs[0]
return existing
? {
fieldErrors: { title: ['Já existe uma atividade com este título.'] },
existingHref: `/campanha/atividades/${existing.slug}`,
submittedTitle: title,
}
: null
}
export const createActivityFormAction = async (
_state: ActivityFormState,
formData: FormData,
): Promise<ActivityFormState> => {
let activitySlug: string
try {
const { demands, ...activityInput } = parseActivityCreateFormData(formData)
const activity = await createActivity(activityInput, demands)
activitySlug = activity.slug
} catch (error) {
const existing = await existingActivityState(formData)
if (existing) return existing
return getActionError(error)
}
redirect(`/campanha/atividades/${activitySlug}`)
}
export const updateActivityFormAction = async (
_state: ActivityFormState,
formData: FormData,
): Promise<ActivityFormState> => {
let activitySlug: string
try {
const { demands, ...activityInput } = parseActivityUpdateFormData(formData)
const activity = await updateActivity(activityInput, demands)
activitySlug = activity.slug
} catch (error) {
return getActionError(error)
}
redirect(`/campanha/atividades/${activitySlug}`)
}

View file

@ -1,61 +0,0 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import {
searchActivityContactOptions,
searchActivityResponsibleOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
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,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
type NewActivityPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function NewActivityPage({ searchParams }: NewActivityPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/agenda' }),
getPayload({ config }),
])
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadAccessibleActivityTags(payload, user),
])
const accessibleMunicipalityIDs = new Set(municipalityOptions.map((option) => option.id))
const accessibleTags = new Set(knownTags)
const initialValues = parseActivityCreatePrefill(rawSearchParams, accessibleMunicipalityIDs)
const cancelHref = parseActivityAgendaReturnHref(
rawSearchParams.returnTo,
accessibleMunicipalityIDs,
accessibleTags,
)
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<ActivityForm
action={createActivityFormAction}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
initialValues={initialValues}
cancelHref={cancelHref}
knownTags={knownTags}
submitLabel="Criar atividade"
searchContacts={searchActivityContactOptions}
searchResponsibles={searchActivityResponsibleOptionsAction}
/>
</div>
)
}

View file

@ -3,11 +3,12 @@ import {
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import config from '@payload-config'
import { MapPinnedIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import { MapPinnedIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { ActivityCreateOverlayHost } from '@/components/campaign/activity/ActivityCreateOverlayHost'
import { ActivityFilters } from '@/components/campaign/activity/ActivityFilters'
import { ActivityList } from '@/components/campaign/activity/ActivityList'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
@ -16,7 +17,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { Activity } from '@/payload-types'
import { loadActivityListPageData } from '@/utilities/activityPageData'
import { loadAccessibleActivityTags, loadActivityListPageData } from '@/utilities/activityPageData'
import {
buildActivityFiltersKey,
buildActivityListHref,
@ -25,7 +26,10 @@ import {
import { toActivityListViewModel } from '@/utilities/activityViewModels'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
} from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
export const metadata = campaignPageMetadataFromCatalog('atividades')
@ -45,10 +49,13 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
])
const now = new Date()
const [{ result, state }, municipalityOptions] = await Promise.all([
loadActivityListPageData(payload, user, rawSearchParams, now),
loadMunicipalityOptions(payload, user),
])
const [{ result, state }, municipalityOptions, organizationOptions, knownTags] =
await Promise.all([
loadActivityListPageData(payload, user, rawSearchParams, now),
loadMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadAccessibleActivityTags(payload, user),
])
const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@ -64,12 +71,12 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
<ActivityCreateOverlayHost
municipalityId={state.municipality}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
/>
</div>
) : null}

View file

@ -23,9 +23,14 @@ import {
type ActivityUpdateInput,
} from '@/lib/schemas/activity'
import type { Activity, CampaignUser } from '@/payload-types'
import type { ParsedTourDraftFormData } from '@/utilities/activityFormData'
import { mapActivityInlineCreateError } from '@/utilities/activityInlineErrors'
import {
parseActivityCreateFormData,
parseActivityUpdateFormData,
type ParsedTourDraftFormData,
} from '@/utilities/activityFormData'
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
import { loadActivityAgendaEventsData } from '@/utilities/activityPageData'
import { activityFormSelect, toActivityFormViewModel } from '@/utilities/activityViewModels'
import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { hookFilledCreateData } from '@/utilities/hookFilledData'
@ -434,14 +439,6 @@ const registerActivityResult = async (
)
}
export const createActivity = async (
input: ActivityCreateInput,
demandDrafts: ActivityDemandDraft[] = [],
) => {
const { payload, actor } = await getCampaignActionContext()
return createActivityRecord(payload, actor, input, demandDrafts)
}
export const loadActivityAgendaEvents = async (input: ActivityAgendaRequest) => {
const { payload, actor } = await getCampaignActionContext()
return loadActivityAgendaEventsRecord(payload, actor, input)
@ -465,34 +462,65 @@ export const rescheduleActivity = async (
}
}
export type ActivityInlineCreateResult =
export type ActivityOverlayResult =
| { ok: true }
| { ok: false; message: string; fieldErrors?: Record<string, string[]> }
/**
* C91 — server surface for the agenda's inline quick create. Goes through the
* same `createActivityRecord` (one transaction, `overrideAccess: false` with
* the actor, the advisor's portfolio scope inherited from the full form) but
* returns a result instead of throwing, so the overlay can stay open on a
* failure — same `{ ok }` contract as `rescheduleActivity`.
* C123 — server surface for the agenda's single create/edit overlay. Both
* actions reuse the full-form FormData parsers (`parseActivity*FormData`) and
* the transactional `*Record` writes (`overrideAccess: false` with the actor,
* the advisor's portfolio scope inherited from the full form), returning a
* result instead of throwing so the overlay stays open on a failure — same
* `{ ok }` contract the old inline quick create established.
*/
export const createActivityInline = async (
input: ActivityCreateInput,
): Promise<ActivityInlineCreateResult> => {
export const createActivityOverlay = async (formData: FormData): Promise<ActivityOverlayResult> => {
try {
await createActivity(input)
const { demands, ...activityInput } = parseActivityCreateFormData(formData)
const { payload, actor } = await getCampaignActionContext()
await createActivityRecord(payload, actor, activityInput, demands)
return { ok: true }
} catch (error) {
return { ok: false, ...mapActivityInlineCreateError(error) }
return { ok: false, ...mapActivityOverlayError(error) }
}
}
export const updateActivity = async (
input: ActivityUpdateInput,
demandDrafts: ActivityDemandDraft[] = [],
export const updateActivityOverlay = async (formData: FormData): Promise<ActivityOverlayResult> => {
try {
const { demands, ...activityInput } = parseActivityUpdateFormData(formData)
const { payload, actor } = await getCampaignActionContext()
await updateActivityRecord(payload, actor, activityInput, demands)
return { ok: true }
} catch (error) {
return { ok: false, ...mapActivityOverlayError(error) }
}
}
/**
* C123 — edit-mode data for the overlay: the full form view model of one
* activity, read through the same access the detail page uses (`overrideAccess:
* false` with the actor), so an advisor can only edit activities in their scope.
*/
export const loadActivityEditDraftRecord = async (
payload: Payload,
actor: CampaignUser,
activityId: number,
) => {
const currentActor = await reloadCampaignActor(payload, actor)
const activity = await payload.findByID({
collection: 'activity',
id: activityId,
depth: 1,
select: activityFormSelect,
user: currentActor,
overrideAccess: false,
})
return toActivityFormViewModel(activity as Activity)
}
export const loadActivityEditDraft = async (activityId: number) => {
const { payload, actor } = await getCampaignActionContext()
return updateActivityRecord(payload, actor, input, demandDrafts)
return loadActivityEditDraftRecord(payload, actor, activityId)
}
export const cancelActivity = async (id: number) => {

View file

@ -4,6 +4,7 @@ import FullCalendar, {
type CalendarRef,
type DateClickInfo,
type DatesSetInfo,
type EventClickInfo,
type EventDisplayInfo,
type EventDropInfo,
type EventInput,
@ -30,13 +31,14 @@ import {
type ActivityAgendaAdjacentRange,
} from '@/components/campaign/activity/ActivityAgendaSwipePreview'
import {
ActivityInlineCreate,
type ActivityInlineCreateDraft,
} from '@/components/campaign/activity/ActivityInlineCreate'
ActivityOverlay,
type ActivityOverlayRequest,
} from '@/components/campaign/activity/ActivityOverlay'
import { AgendaPeriodChrome } from '@/components/campaign/activity/AgendaPeriodChrome'
import { useAgendaKeyboardNavigation } from '@/components/campaign/activity/useAgendaKeyboardNavigation'
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
import { useIsMobileMeasured } from '@/hooks/use-mobile'
import {
@ -55,6 +57,7 @@ import {
activityAgendaPeriodLabel,
activityAgendaViewFcId,
activityAgendaViewFromFcId,
activityDefaultCreatePrefill,
activitySlotPrefill,
type ActivityAgendaState,
type ActivityAgendaView,
@ -156,10 +159,12 @@ const renderEventContent = ({ event, timeText, view }: EventDisplayInfo) => {
export const ActivityAgenda = ({
state,
municipalityOptions = [],
organizationOptions = [],
knownTags,
}: {
state: ActivityAgendaState
municipalityOptions?: RelationOption[]
organizationOptions?: RelationOption[]
knownTags?: string[]
}) => {
const containerRef = useRef<HTMLDivElement>(null)
@ -180,7 +185,7 @@ export const ActivityAgenda = ({
const [savingCount, setSavingCount] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [isNarrow, setIsNarrow] = useState(false)
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
const [overlayRequest, setOverlayRequest] = useState<ActivityOverlayRequest | null>(null)
// C110 — the adjacent-period reveal: which side is open and the events that
// load (async, during the drag) for that period.
const [swipePreview, setSwipePreview] = useState<{ direction: 'next' | 'prev' } | null>(null)
@ -425,26 +430,40 @@ export const ActivityAgenda = ({
// C101 — a consumed swipe ends with FullCalendar's dateClick in the day
// view (single column: the hit never leaves the starting slot). The
// navigation already happened — swallow the click so it does not open
// the inline create.
// the create overlay.
if (suppressDateClickRef.current) return
const prefill = activitySlotPrefill(info)
if (!prefill) return
const jsEvent = info.jsEvent as MouseEvent | undefined
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
const anchor = hasPoint
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
: (() => {
const rect = containerRef.current?.getBoundingClientRect()
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
})()
setCreateDraft({ ...prefill, anchor })
// C123 — the click opens the modal overlay (desktop) / top drawer
// (mobile) with the slot's window; there is no anchored popover anymore.
setOverlayRequest({ kind: 'create', ...prefill })
},
[suppressDateClickRef],
)
// C123 — clicking an event opens the EDIT overlay (Google Calendar style);
// the event keeps its `url` so middle-click / open-in-new-tab still reaches
// the detail page, and the overlay itself links to it ("Ver detalhes").
const handleEventClick = useCallback((info: EventClickInfo) => {
info.jsEvent.preventDefault()
const id = Number(info.event.id)
if (!Number.isInteger(id)) return
setOverlayRequest({ kind: 'edit', activityId: id })
}, [])
// C123 — the FAB quick action "Nova atividade" and the filter strip's
// "+" open the same create overlay with the default window (today 09:00,
// the historical all-day slot fallback).
const openDefaultCreate = useCallback(() => {
const prefill = activityDefaultCreatePrefill()
if (!prefill) return
setOverlayRequest({ kind: 'create', ...prefill })
}, [])
useBridgedQuickAction('openActivityCreate', openDefaultCreate)
const persistSchedule = useCallback(
async ({
id,
@ -585,6 +604,7 @@ export const ActivityAgenda = ({
datesSet={handleDatesSet}
eventContent={renderEventContent}
dateClick={handleDateClick}
eventClick={handleEventClick}
eventDrop={handleScheduleChange}
eventResize={handleScheduleChange}
eventDragStart={handleEventDragStart}
@ -607,15 +627,16 @@ export const ActivityAgenda = ({
) : null}
</div>
<ActivityInlineCreate
draft={createDraft}
<ActivityOverlay
request={overlayRequest}
isNarrow={isNarrow}
agendaState={state}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
onClose={() => setCreateDraft(null)}
onCreated={() => {
setCreateDraft(null)
onClose={() => setOverlayRequest(null)}
onSaved={() => {
setOverlayRequest(null)
setReloadCount((count) => count + 1)
}}
/>

View file

@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import {
@ -18,11 +19,7 @@ import {
removeActivityAgendaOmniboxChip,
type ActivityAgendaOmniboxAction,
} from '@/utilities/activityAgendaOmnibox'
import {
buildActivityAgendaHref,
buildActivityCreateHref,
type ActivityAgendaState,
} from '@/utilities/activityUi'
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
type ActivityAgendaFiltersProps = {
state: ActivityAgendaState
@ -49,6 +46,7 @@ export const ActivityAgendaFilters = ({
state,
toHref: buildActivityAgendaHref,
})
const { context } = useCampaignQuickActionContext()
const [draft, setDraft] = useState(state)
const [query, setQuery] = useState('')
@ -139,15 +137,14 @@ export const ActivityAgendaFilters = ({
</Link>
</Button>
<Button
asChild
type="button"
size="icon"
className="min-h-11 min-w-11"
aria-label="Nova atividade"
title="Nova atividade"
onClick={() => context.openActivityCreate?.()}
>
<Link href={buildActivityCreateHref(state)}>
<PlusIcon className="size-5" aria-hidden />
</Link>
<PlusIcon className="size-5" aria-hidden />
</Button>
</div>
}

View file

@ -0,0 +1,66 @@
'use client'
import { PlusIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import {
ActivityOverlay,
type ActivityOverlayRequest,
} from '@/components/campaign/activity/ActivityOverlay'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
import { useNarrowMeasured } from '@/hooks/use-mobile'
import { activityDefaultCreatePrefill } from '@/utilities/activityUi'
/**
* C123 — the activities list page's create surface: the "Nova atividade"
* button and the FAB quick action both open the agenda's create overlay with
* the default window (today 09:00) and the list's municipality filter as
* prefill. On save the page refreshes so the new row appears.
*/
export const ActivityCreateOverlayHost = ({
municipalityId,
municipalityOptions,
organizationOptions,
knownTags,
}: {
municipalityId?: number
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
knownTags: string[]
}) => {
const router = useRouter()
const { isNarrow } = useNarrowMeasured(640)
const [request, setRequest] = useState<ActivityOverlayRequest | null>(null)
const openCreate = useCallback(() => {
const prefill = activityDefaultCreatePrefill()
if (!prefill) return
setRequest({ kind: 'create', ...prefill })
}, [])
useBridgedQuickAction('openActivityCreate', openCreate)
return (
<>
<Button type="button" className="min-h-11" onClick={openCreate}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Button>
<ActivityOverlay
request={request}
isNarrow={isNarrow}
agendaState={municipalityId ? { municipality: municipalityId } : undefined}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
onClose={() => setRequest(null)}
onSaved={() => {
setRequest(null)
router.refresh()
}}
/>
</>
)
}

View file

@ -19,7 +19,6 @@ import {
floorToMinuteStep,
formatBahiaCivilDate,
formatBahiaCivilDateLabel,
formatBahiaCivilDateTimeLabel,
hourOptions,
minuteOptionsForStep,
parseBahiaDateTimeInput,
@ -27,20 +26,18 @@ import {
import { cn } from '@/lib/utils'
/**
* C97 — shadcn day + time picker for the agenda quick create. The value
* contract is the civil `YYYY-MM-DDTHH:mm` string the old `datetime-local`
* used, so parse/validation on submit is untouched. 24h by construction:
* the trigger formats the civil string directly and the selects list
* 00–23 / 00–45, so no browser locale or Intl hour cycle can inject AM/PM.
* The parent renders the surrounding Field/FieldError; this component is the
* control itself (trigger + popover with calendar and step selects).
* C97/C123 — the date+time control for the agenda overlay. C123 split it: the
* day part is a calendar trigger (popover on desktop, nested bottom sheet on
* mobile) and the Hora/Minuto selects render INLINE beside it, always visible
* — nothing hides below the calendar anymore. The value contract stays the
* civil `YYYY-MM-DDTHH:mm` string, so parse/validation on submit is untouched.
* 24h by construction: the trigger formats the civil date directly and the
* selects list 00–23 / 00–45, so no browser locale or Intl hour cycle can
* inject AM/PM. The parent renders the surrounding Field/FieldError; this
* component is the control itself.
*
* C103 — on narrow viewports the picker opens as a nested bottom sheet
* (calendar + hour/minute fully on screen, "Pronto" applies and closes),
* keeping the desktop popover untouched.
*
* C104 — `timeVisible={false}` is the all-day mode: the trigger formats only
* the date and the picker renders just the calendar (no time selects).
* C104 — `timeVisible={false}` is the all-day mode: only the date trigger
* renders, the time selects disappear.
*/
export const ActivityDateTimeField = ({
id,
@ -59,19 +56,21 @@ export const ActivityDateTimeField = ({
invalid?: boolean
/** FieldError id to announce on focus — parity with the input it replaces. */
errorId?: string
/** Renders the picker as a nested bottom sheet instead of a popover. */
/** Renders the calendar picker as a nested bottom sheet instead of a popover. */
isNarrow?: boolean
/** Field name used in the sheet header (e.g. "Início"). */
/** Field name used in the sheet header and the time-select a11y labels (e.g. "Início"). */
label?: string
/** Marks the required fields with a visible asterisk on the narrow trigger. */
required?: boolean
/** When false, hide the time selects and label the trigger with the date only. */
/** When false, hide the time selects (all-day mode). */
timeVisible?: boolean
}) => {
const [open, setOpen] = useState(false)
const civil = floorToMinuteStep(value)
const datePart = civil.slice(0, 10)
const timePart = civil.slice(11)
// A draft without a startAt (tour drafts) renders the controls empty: the
// selects default to 00:00 and picking a date seeds the time part.
const timePart = civil ? civil.slice(11) : '00:00'
const hour = timePart.slice(0, 2)
const minute = timePart.slice(3, 5)
@ -80,12 +79,22 @@ export const ActivityDateTimeField = ({
onValueChange(`${formatBahiaCivilDate(date)}T${timePart}`)
}
const setHour = (next: string) => onValueChange(`${datePart}T${next}:${minute}`)
const setMinute = (next: string) => onValueChange(`${datePart}T${hour}:${next}`)
// Time edits are a no-op until a date exists: a draft without a startAt
// (tour drafts) must not emit a corrupt civil string like `T09:00`.
const setHour = (next: string) => {
if (!datePart) return
onValueChange(`${datePart}T${next}:${minute}`)
}
const setMinute = (next: string) => {
if (!datePart) return
onValueChange(`${datePart}T${hour}:${next}`)
}
const selectedInstant = parseBahiaDateTimeInput(`${datePart}T12:00`)
const selectedInstant = parseBahiaDateTimeInput(
`${datePart || formatBahiaCivilDate(new Date())}T12:00`,
)
const trigger = (
const dateTrigger = (
<Button
id={id}
type="button"
@ -104,7 +113,7 @@ export const ActivityDateTimeField = ({
)}
>
<span className="truncate">
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
{civil ? formatBahiaCivilDateLabel(civil) : 'Selecionar data'}
</span>
{isNarrow && required ? (
<span aria-hidden="true" className="font-semibold text-destructive">
@ -119,90 +128,92 @@ export const ActivityDateTimeField = ({
</Button>
)
const pickerContent = (
<>
{selectedInstant ? (
<Calendar
mode="single"
locale={ptBR}
timeZone="America/Bahia"
selected={new Date(selectedInstant)}
onSelect={setDate}
className="w-full p-2"
/>
) : null}
{timeVisible ? (
<div className="flex flex-col gap-3 border-t p-3">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
<NativeSelect
aria-label="Hora"
value={hour}
onChange={(event) => setHour(event.target.value)}
>
{hourOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}h
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span>
<NativeSelect
aria-label="Minuto"
value={minute}
onChange={(event) => setMinute(event.target.value)}
>
{minuteOptionsForStep().map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
</div>
) : null}
</>
)
const calendarContent = selectedInstant ? (
<Calendar
mode="single"
locale={ptBR}
timeZone="America/Bahia"
selected={new Date(selectedInstant)}
onSelect={setDate}
className="w-full p-2"
/>
) : null
if (isNarrow) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>
{label ? `${label} — ` : ''}
{selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart}
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-2">
{pickerContent}
</div>
<DrawerFooter className="border-t">
<Button type="button" className="min-h-11 w-full" onClick={() => setOpen(false)}>
Pronto
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}
const timeSelects = timeVisible ? (
<div
className={cn('flex min-w-0 gap-1.5', isNarrow ? 'shrink-0' : 'shrink-0 sm:gap-2')}
role="group"
aria-label={`Horário de ${label ?? 'início'}`}
>
{(
[
['Hora', hour, setHour],
['Minuto', minute, setMinute],
] as const
).map(([part, value, onChange]) => (
<div key={part} className={cn('flex flex-col gap-1', isNarrow ? 'w-16' : 'w-20')}>
{!isNarrow ? (
<span className="text-xs font-medium text-muted-foreground select-none">{part}</span>
) : null}
<NativeSelect
aria-label={`${part} de ${label ?? 'início'}`}
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
'w-full **:data-[slot=native-select]:min-h-11',
isNarrow &&
'**:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-none **:data-[slot=native-select]:border-0 **:data-[slot=native-select]:bg-transparent **:data-[slot=native-select]:px-1',
)}
>
{(part === 'Hora' ? hourOptions : minuteOptionsForStep()).map((option) => (
<NativeSelectOption key={option} value={option}>
{part === 'Hora' ? `${option}h` : option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
))}
</div>
) : null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
>
{pickerContent}
</PopoverContent>
</Popover>
<div className={cn('flex w-full items-start gap-1.5 sm:gap-2', isNarrow && 'min-h-11')}>
<div className="min-w-0 flex-1">
{isNarrow ? (
<>
{dateTrigger}
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>
{label ? `${label} — ` : ''}
{selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart}
</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-2">
{calendarContent}
</div>
<DrawerFooter className="border-t">
<Button type="button" className="min-h-11 w-full" onClick={() => setOpen(false)}>
Pronto
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
) : (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{dateTrigger}</PopoverTrigger>
<PopoverContent
align="start"
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
>
{calendarContent}
</PopoverContent>
</Popover>
)}
</div>
{timeSelects}
</div>
)
}

View file

@ -0,0 +1,60 @@
'use client'
import { PencilIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import {
ActivityOverlay,
type ActivityOverlayRequest,
} from '@/components/campaign/activity/ActivityOverlay'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
import { useNarrowMeasured } from '@/hooks/use-mobile'
/**
* C123 — the activity detail page's edit surface: the "Editar" button and the
* FAB quick action both open the same agenda overlay (edit mode). The overlay
* fetches the form view model by id through `loadActivityEditDraft`; on save
* the page refreshes (title is immutable, so the URL stays valid).
*/
export const ActivityEditOverlayHost = ({
activityId,
municipalityOptions,
organizationOptions,
knownTags,
}: {
activityId: number
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
knownTags: string[]
}) => {
const router = useRouter()
const { isNarrow } = useNarrowMeasured(640)
const [request, setRequest] = useState<ActivityOverlayRequest | null>(null)
const openEdit = useCallback(() => setRequest({ kind: 'edit', activityId }), [activityId])
useBridgedQuickAction('openActivityEdit', openEdit)
return (
<>
<Button type="button" variant="outline" className="min-h-11" onClick={openEdit}>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar
</Button>
<ActivityOverlay
request={request}
isNarrow={isNarrow}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
onClose={() => setRequest(null)}
onSaved={() => {
setRequest(null)
router.refresh()
}}
/>
</>
)
}

View file

@ -1,405 +0,0 @@
'use client'
import Link from 'next/link'
import { useActionState, useState } from 'react'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/shared/RelationMultiSelect'
import {
ResponsibleMultiSelect,
type ResponsibleOption,
} from '@/components/campaign/shared/ResponsibleMultiSelect'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import { fieldError } from '@/utilities/campaignFormFields'
export type ActivityFormState = {
message?: string
fieldErrors?: Record<string, string[]>
existingHref?: string
submittedTitle?: string
}
type ActivityFormAction = (
state: ActivityFormState,
formData: FormData,
) => Promise<ActivityFormState>
export type ActivityFormFieldsProps = {
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
activity?: ActivityFormViewModel
initialValues?: ActivityCreatePrefill
fieldErrors?: Record<string, string[]>
submittedTitle?: string
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
searchResponsibles: (query: string) => Promise<ResponsibleOption[]>
/** Tags already used across activities, for autocomplete. */
knownTags?: string[]
}
const ActivityFormFields = ({
municipalityOptions,
organizationOptions,
activity,
initialValues,
fieldErrors = {},
submittedTitle,
searchContacts,
searchResponsibles,
knownTags = [],
}: ActivityFormFieldsProps) => {
const errorFor = (name: string) => fieldError(fieldErrors, name)
const initialResponsibles = activity?.responsibles ?? []
const initialStartAt = activity?.startAt ?? initialValues?.startAt
const initialEndAt = activity?.endAt ?? initialValues?.endAt
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
const [allDay, setAllDay] = useState(activity?.allDay ?? initialValues?.allDay ?? false)
// C104 — controlled so switching the toggle keeps the date the staff typed:
// the date input reads/preserves the date part, the datetime input the
// whole civil value; on submit the parser branches on the allDay checkbox.
const [startAt, setStartAt] = useState(
initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : '',
)
const [endAt, setEndAt] = useState(
initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : '',
)
const startDateOnly = startAt.slice(0, 10)
const endDateOnly = endAt.slice(0, 10)
const setStartDate = (date: string) => setStartAt(`${date}T${startAt.slice(11) || '00:00'}`)
const setEndDate = (date: string) => setEndAt(`${date}T${endAt.slice(11) || '00:00'}`)
return (
<FieldGroup>
<input type="hidden" name="status" value="confirmado" />
<Card>
<CardHeader>
<CardTitle>Informações básicas</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(errorFor('title'))}>
<FieldLabel htmlFor="title">Título *</FieldLabel>
<Input
id="title"
name="title"
defaultValue={submittedTitle ?? initialValues?.title ?? activity?.title}
minLength={2}
maxLength={160}
className="min-h-11"
required
readOnly={Boolean(activity)}
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'title-error' : undefined}
/>
{errorFor('title') ? (
<FieldError id="title-error">{errorFor('title')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
<FieldLabel>Tags</FieldLabel>
<ActivityTagInput
initialTags={initialValues?.tags ?? activity?.tags ?? []}
knownTags={knownTags}
error={errorFor('tagsJson')}
/>
</Field>
<Field data-invalid={Boolean(errorFor('description'))}>
<FieldLabel htmlFor="description">Descrição</FieldLabel>
<Textarea
id="description"
name="description"
defaultValue={activity?.description ?? ''}
maxLength={4000}
className="min-h-24"
aria-invalid={Boolean(errorFor('description'))}
aria-describedby={errorFor('description') ? 'description-error' : undefined}
/>
{errorFor('description') ? (
<FieldError id="description-error">{errorFor('description')}</FieldError>
) : null}
</Field>
<FieldLabel>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox name="deputyPresent" defaultChecked={activity?.deputyPresent ?? false} />
<span>Deputado presente</span>
</Field>
</FieldLabel>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data e horário</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<FieldLabel>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
name="allDay"
checked={allDay}
onCheckedChange={(next) => setAllDay(Boolean(next))}
/>
<span>Todo o dia</span>
</Field>
</FieldLabel>
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('startAt'))}>
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
{allDay ? (
<Input
id="startAt"
name="startAt"
type="date"
value={startDateOnly}
onChange={(event) => setStartDate(event.target.value)}
required
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
/>
) : (
<Input
id="startAt"
name="startAt"
type="datetime-local"
value={startAt}
onChange={(event) => setStartAt(event.target.value)}
required
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
/>
)}
{errorFor('startAt') ? (
<FieldError id="startAt-error">{errorFor('startAt')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('endAt'))}>
<FieldLabel htmlFor="endAt">Término</FieldLabel>
{allDay ? (
<Input
id="endAt"
name="endAt"
type="date"
value={endDateOnly}
onChange={(event) => setEndDate(event.target.value)}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
) : (
<Input
id="endAt"
name="endAt"
type="datetime-local"
value={endAt}
onChange={(event) => setEndAt(event.target.value)}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
)}
{errorFor('endAt') ? (
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
) : null}
</Field>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Onde</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<Field data-invalid={Boolean(errorFor('municipality'))}>
<FieldLabel htmlFor="municipality">Município *</FieldLabel>
<NativeSelect
id="municipality"
name="municipality"
defaultValue={initialMunicipalityId ? String(initialMunicipalityId) : ''}
required
aria-invalid={Boolean(errorFor('municipality'))}
aria-describedby={errorFor('municipality') ? 'municipality-error' : undefined}
className="w-full **:data-[slot=native-select]:min-h-11"
>
<NativeSelectOption value="">Selecione o município</NativeSelectOption>
{municipalityOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
{errorFor('municipality') ? (
<FieldError id="municipality-error">{errorFor('municipality')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Local (bairro, endereço ou referência)</FieldLabel>
<Input
id="locality"
name="locality"
defaultValue={activity?.locality ?? ''}
maxLength={160}
className="min-h-11"
aria-invalid={Boolean(errorFor('locality'))}
aria-describedby={errorFor('locality') ? 'locality-error' : undefined}
/>
{errorFor('locality') ? (
<FieldError id="locality-error">{errorFor('locality')}</FieldError>
) : null}
</Field>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pessoas e organizações</CardTitle>
</CardHeader>
<CardContent>
<FieldGroup>
<ResponsibleMultiSelect
name="responsiblesJson"
label="Responsáveis"
value={initialResponsibles}
error={errorFor('responsiblesJson')}
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
search={searchResponsibles}
/>
<RelationMultiSelect
name="organizations"
label="Organizações apoiadoras"
options={organizationOptions}
initialSelectedIDs={activity?.organizationIDs ?? []}
error={errorFor('organizations')}
placeholder="Adicionar organização…"
/>
</FieldGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>
<ActivityTaskFields
initialTasks={activity?.tasks ?? []}
searchContacts={searchContacts}
error={errorFor('tasksJson')}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Demandas</CardTitle>
</CardHeader>
<CardContent>
<ActivityDemandFields error={errorFor('demandsJson')} />
</CardContent>
</Card>
</FieldGroup>
)
}
export const ActivityForm = ({
action,
municipalityOptions,
organizationOptions,
activity,
initialValues,
fieldErrors: externalFieldErrors,
submittedTitle,
searchContacts,
searchResponsibles,
knownTags,
submitLabel,
cancelHref,
}: ActivityFormFieldsProps & {
action: ActivityFormAction
submitLabel?: string
cancelHref?: string
}) => {
const [state, submitAction, isPending] = useActionState(action, {})
return (
<form action={submitAction} className="flex flex-col gap-5">
<ActivityFormFields
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
activity={activity}
initialValues={initialValues}
fieldErrors={state.fieldErrors ?? externalFieldErrors}
submittedTitle={state.submittedTitle ?? submittedTitle}
searchContacts={searchContacts}
searchResponsibles={searchResponsibles}
knownTags={knownTags}
/>
{state.existingHref ? (
<Alert variant="destructive">
<AlertTitle>Já existe uma atividade com este título</AlertTitle>
<AlertDescription>
<a
href={state.existingHref}
className="font-medium text-primary underline underline-offset-4"
>
Ver atividade existente
</a>
</AlertDescription>
</Alert>
) : null}
{state.message ? (
<Alert variant="destructive">
<AlertTitle>Nada foi gravado</AlertTitle>
<AlertDescription>{state.message}</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button asChild variant="outline" className="min-h-11">
<Link
href={
activity
? `/campanha/atividades/${activity.slug}`
: (cancelHref ?? '/campanha/agenda')
}
>
Cancelar
</Link>
</Button>
<Button type="submit" disabled={isPending} className="min-h-11">
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{submitLabel ?? (activity ? 'Salvar alterações' : 'Criar compromisso')}
</Button>
</div>
</form>
)
}

View file

@ -1,527 +0,0 @@
'use client'
import Link from 'next/link'
import { useState, type FormEvent } from 'react'
import { toast } from 'sonner'
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { allDayEndInstant, allDayRangeValid, allDayStartInstant } from '@/lib/activityAllDay'
import {
formatBahiaDateTimeLabel,
formatIsoAsBahiaDateTimeInput,
parseBahiaDateTimeInput,
} from '@/lib/campaignTime'
import { activityResponsibleSchema, type ActivityCreateInput } from '@/lib/schemas/activity'
import { buildActivityCreateHref, type ActivityAgendaState } from '@/utilities/activityUi'
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
/**
* C91 — the Google-Calendar-like quick create anchored on a calendar slot.
* One content component is rendered inside a Popover (desktop, anchored at the
* click) or a bottom Drawer (mobile); saving never navigates and the parent
* refetches the visible window (`onCreated`).
*
* C103 — on narrow viewports the drawer opens from the TOP (`swipeDirection="up"`)
* filling the usable height, the form switches to a label-less list variant
* (placeholders + divider rows, `sr-only` labels) with a fixed footer, and the
* date/time picker opens as a nested bottom sheet.
*/
export type ActivityInlineCreateDraft = {
startAt: string
endAt: string
anchor: { x: number; y: number } | null
}
type ActivityInlineCreateProps = {
draft: ActivityInlineCreateDraft | null
isNarrow: boolean
agendaState: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags?: string[]
onClose: () => void
onCreated: () => void
}
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
options.map((option) => ({ value: String(option.id), label: option.name }))
/**
* Bounded JSON-array read for the inline sheet's `responsiblesJson` hidden
* input: same 4 KB cap as the full form's `boundedJsonFormValue`, fail-closed
* on malformed or oversized payloads (the component generates the JSON, so a
* violation means tampering, not user input).
*/
const INLINE_JSON_MAX_BYTES = 4_000
const parseInlineJsonArray = (raw: FormDataEntryValue | null): unknown[] | undefined => {
if (typeof raw !== 'string' || raw.length > INLINE_JSON_MAX_BYTES) return undefined
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
} catch {
return undefined
}
}
const parseInlineResponsibles = (
raw: FormDataEntryValue | null,
): ActivityCreateInput['responsible'] => {
const parsed = parseInlineJsonArray(raw)
if (!parsed) return undefined
const entries: Array<{
relationTo: 'campaignUser' | 'leadership' | 'stateDeputy'
value: number
}> = []
for (const item of parsed) {
const result = activityResponsibleSchema.safeParse(item)
if (result.success) entries.push(result.data)
}
return entries.length > 0 ? entries : undefined
}
/** List-row styling for the label-less mobile sheet (C103). The focus ring is
* kept (subtle) so keyboard users keep a visible focus indicator. */
const sheetFieldInputClass =
'rounded-none border-0 bg-transparent px-0 py-0 focus-visible:ring-2 focus-visible:ring-primary/30'
type InlineDateTimeFieldProps = {
sheet: boolean
id: string
label: string
value: string
onValueChange: (value: string) => void
invalid: boolean
error?: string
required?: boolean
/** C104 — false hides the time selects (all-day mode). */
timeVisible?: boolean
}
const InlineDateTimeField = ({
sheet,
id,
label,
value,
onValueChange,
invalid,
error,
required = false,
timeVisible = true,
}: InlineDateTimeFieldProps) => (
<Field data-invalid={invalid}>
<FieldLabel htmlFor={id} className={sheet ? 'sr-only' : undefined}>
{label}
{required ? ' *' : ''}
</FieldLabel>
<ActivityDateTimeField
id={id}
value={value}
onValueChange={onValueChange}
invalid={invalid}
errorId={error ? `${id}-error` : undefined}
// The sheet variant implies the narrow viewport (C103); the picker keeps
// the popover whenever the create overlay is a popover.
isNarrow={sheet}
label={label}
required={required}
timeVisible={timeVisible}
/>
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
</Field>
)
const ActivityInlineCreateForm = ({
startAt,
endAt,
agendaState,
municipalityOptions,
knownTags,
variant,
onCreated,
}: {
startAt: string
endAt: string
agendaState: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags?: string[]
variant: 'popover' | 'sheet'
onCreated: () => void
}) => {
const sheet = variant === 'sheet'
const [title, setTitle] = useState('')
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
const [allDay, setAllDay] = useState(false)
const [locality, setLocality] = useState('')
const [tags, setTags] = useState<string[]>([])
const preselectedMunicipalityId =
agendaState.municipality &&
municipalityOptions.some((option) => option.id === agendaState.municipality)
? String(agendaState.municipality)
: ''
const [municipalityValue, setMunicipalityValue] = useState(preselectedMunicipalityId)
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const errorFor = (name: string) => fieldErrors[name]?.[0]
const allDayStartDate = start.slice(0, 10)
const allDayEndDate = end ? end.slice(0, 10) : allDayStartDate
// C105 — the href mirrors the LIVE state (times edited in the sheet, typed
// tags), not the slot draft, so "Mais detalhes" carries everything the user
// actually filled.
const moreDetailsHref = buildActivityCreateHref(agendaState, {
...(allDay
? { allDay: true, startAt: allDayStartDate, endAt: allDayEndDate }
: {
startAt: parseBahiaDateTimeInput(start) ?? undefined,
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
}),
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
title: title.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
})
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const trimmedTitle = title.trim()
const municipalityId = Number(municipalityValue)
const startIso = parseBahiaDateTimeInput(start)
const endIso = end ? parseBahiaDateTimeInput(end) : undefined
const nextFieldErrors: Record<string, string[]> = {}
if (!trimmedTitle) {
nextFieldErrors.title = ['Informe o título do compromisso.']
} else if (trimmedTitle.length < 2) {
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
}
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
if (allDay) {
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
nextFieldErrors.endAt = ['A data de término deve ser igual ou posterior à de início.']
}
} else {
if (!startIso)
nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.']
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) {
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.']
}
}
if (Object.keys(nextFieldErrors).length > 0) {
setFieldErrors(nextFieldErrors)
return
}
setFieldErrors({})
setFailedMessage(null)
setSaving(true)
try {
const responsible = parseInlineResponsibles(formData.get('responsiblesJson'))
const result = await createActivityInline({
title: trimmedTitle,
municipality: municipalityId,
...(allDay
? {
allDay: true,
startAt: allDayStartInstant(allDayStartDate),
...(end ? { endAt: allDayEndInstant(allDayEndDate) } : {}),
}
: {
startAt: startIso as string,
...(endIso ? { endAt: endIso } : {}),
}),
...(locality.trim() ? { locality: locality.trim() } : {}),
...(responsible ? { responsible } : {}),
// C105 — the tags mirror state (every chip mutation fires `onChange`),
// so the payload reads the same source the "Mais detalhes" href uses.
...(tags.length > 0 ? { tags } : {}),
})
if (result.ok) {
onCreated()
return
}
setSaving(false)
setFieldErrors(result.fieldErrors ?? {})
setFailedMessage(result.message)
toast.error(result.message)
} catch {
setSaving(false)
const message = 'Não foi possível criar o compromisso. Tente novamente.'
setFailedMessage(message)
toast.error(message)
}
}
const startEndFields = (
<>
<InlineDateTimeField
sheet={sheet}
id="inline-startAt"
label="Início"
value={start}
onValueChange={setStart}
invalid={Boolean(errorFor('startAt'))}
error={errorFor('startAt')}
required
timeVisible={!allDay}
/>
<InlineDateTimeField
sheet={sheet}
id="inline-endAt"
label="Término"
value={end}
onValueChange={setEnd}
invalid={Boolean(errorFor('endAt'))}
error={errorFor('endAt')}
timeVisible={!allDay}
/>
</>
)
return (
<form
onSubmit={handleSubmit}
noValidate
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col gap-4'}
>
<div
className={
sheet
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
: 'flex flex-col gap-4'
}
>
<div className={sheet ? 'divide-y divide-border' : undefined}>
<Field data-invalid={Boolean(errorFor('title'))}>
<FieldLabel htmlFor="inline-title" className={sheet ? 'sr-only' : undefined}>
Título *
</FieldLabel>
<Input
id="inline-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={160}
minLength={2}
placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
autoComplete="off"
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'inline-title-error' : undefined}
/>
{errorFor('title') ? (
<FieldError id="inline-title-error">{errorFor('title')}</FieldError>
) : null}
</Field>
{/* C104 — all-day toggle: hides the time part of Início/Término. */}
<Field
orientation="horizontal"
className={
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
}
>
<Checkbox
checked={allDay}
onCheckedChange={(next) => setAllDay(Boolean(next))}
aria-label="Todo o dia"
/>
<span>Todo o dia</span>
</Field>
{sheet ? (
startEndFields
) : (
<div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>
)}
<Field data-invalid={Boolean(errorFor('municipality'))}>
<FieldLabel htmlFor="inline-municipality" className={sheet ? 'sr-only' : undefined}>
Município *
</FieldLabel>
<StrictCombobox
id="inline-municipality"
options={inlineMunicipalityOptions(municipalityOptions)}
value={municipalityValue}
onValueChange={setMunicipalityValue}
error={errorFor('municipality')}
placeholder={sheet ? 'Município *' : undefined}
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
/>
{errorFor('municipality') ? (
<FieldError id="inline-municipality-error">{errorFor('municipality')}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
Local (opcional)
</FieldLabel>
<Input
id="inline-locality"
value={locality}
onChange={(event) => setLocality(event.target.value)}
maxLength={160}
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
/>
</Field>
<Field>
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
Local (opcional)
</FieldLabel>
<Input
id="inline-locality"
value={locality}
onChange={(event) => setLocality(event.target.value)}
maxLength={160}
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
/>
</Field>
<div className={sheet ? 'py-1.5' : undefined}>
<Field>
<FieldLabel className={sheet ? 'sr-only' : undefined}>Tags</FieldLabel>
<ActivityTagInput
knownTags={knownTags}
onChange={setTags}
compact={sheet}
placeholder={sheet ? 'Adicionar tags' : undefined}
/>
</Field>
</div>
<ResponsibleMultiSelect
name="responsiblesJson"
label="Responsáveis"
search={searchActivityResponsibleOptionsAction}
labelClassName={sheet ? 'sr-only' : undefined}
emptyText={sheet ? 'Adicionar responsáveis' : undefined}
triggerClassName={
sheet
? 'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30'
: undefined
}
/>
</div>
{failedMessage ? (
<Alert variant="destructive">
<AlertDescription>{failedMessage}</AlertDescription>
</Alert>
) : null}
</div>
<div
className={
sheet
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
: 'mt-1 flex items-center justify-between gap-3 border-t pt-3'
}
>
<Button asChild variant="ghost" className="min-h-11">
<Link href={moreDetailsHref}>Mais detalhes</Link>
</Button>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar
</Button>
</div>
</form>
)
}
export const ActivityInlineCreate = ({
draft,
isNarrow,
agendaState,
municipalityOptions,
knownTags,
onClose,
onCreated,
}: ActivityInlineCreateProps) => {
if (!draft) return null
if (isNarrow) {
return (
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} swipeDirection="up">
<DrawerContent className="h-[calc(100dvh-1rem)] max-h-[calc(100dvh-1rem)]">
<DrawerHeader>
<DrawerTitle>Nova atividade</DrawerTitle>
<DrawerDescription>{formatBahiaDateTimeLabel(draft.startAt)}</DrawerDescription>
</DrawerHeader>
<ActivityInlineCreateForm
startAt={draft.startAt}
endAt={draft.endAt}
agendaState={agendaState}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
variant="sheet"
onCreated={onCreated}
/>
</DrawerContent>
</Drawer>
)
}
return (
<Popover open onOpenChange={(next) => (next ? undefined : onClose())}>
<div
className="fixed size-0"
style={{ left: draft.anchor?.x ?? 0, top: draft.anchor?.y ?? 0 }}
aria-hidden="true"
>
<PopoverAnchor />
</div>
{/* C105 — the tags field made the sheet taller. The height is capped at
the space Radix computed for the current placement
(`--radix-popper-available-height`, set by its `size` middleware):
the sheet always fits the viewport on its placed side and scrolls
internally instead of clipping fields off-screen (a static cap is
not enough — Radix runs shift before flip, so an overflowing
popover can end up with its top fields out of view). */}
<PopoverContent
align="start"
sideOffset={8}
className="max-h-(--radix-popper-available-height) w-96 overflow-y-auto p-4"
>
<ActivityInlineCreateForm
startAt={draft.startAt}
endAt={draft.endAt}
agendaState={agendaState}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
variant="popover"
onCreated={onCreated}
/>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,659 @@
'use client'
import Link from 'next/link'
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
import { toast } from 'sonner'
import {
searchActivityContactOptions,
searchActivityResponsibleOptionsAction,
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import {
createActivityOverlay,
loadActivityEditDraft,
updateActivityOverlay,
} from '@/app/(campaign)/campanha/actions/activity'
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
import {
RelationMultiSelect,
type RelationOption,
} from '@/components/campaign/shared/RelationMultiSelect'
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/Checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import { allDayRangeValid } from '@/lib/activityAllDay'
import { ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE } from '@/lib/activityOverlayMessages'
import {
formatBahiaDateTimeLabel,
formatIsoAsBahiaDateTimeInput,
parseBahiaDateTimeInput,
} from '@/lib/campaignTime'
import type { ActivityAgendaState } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
/**
* C123 — the agenda's ONE create/edit surface. A modal Dialog (desktop) or the
* C103 top drawer (mobile) hosting every section the full form had; creating
* (slot click) and editing (event click / detail button) live here, and the
* `/atividades/nova` / `/atividades/[slug]/editar` pages are gone.
*
* The submit posts FormData to `createActivityOverlay` / `updateActivityOverlay`
* (the same server parsers the old full form used), never navigates, and the
* parent refetches the visible window (`onSaved`). Edit mode loads the form
* view model through `loadActivityEditDraft` (staff-scoped).
*/
export type ActivityOverlayRequest =
| { kind: 'create'; startAt: string; endAt: string }
| { kind: 'edit'; activityId: number }
type ActivityOverlayProps = {
request: ActivityOverlayRequest | null
isNarrow: boolean
/** Create-mode prefill: the agenda's active municipality filter. Edit mode ignores it. */
agendaState?: ActivityAgendaState
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
knownTags?: string[]
onClose: () => void
onSaved: () => void
}
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
options.map((option) => ({ value: String(option.id), label: option.name }))
/** List-row styling for the label-less mobile sheet (C103). */
const sheetFieldInputClass =
'rounded-none border-0 bg-transparent px-0 py-0 focus-visible:ring-2 focus-visible:ring-primary/30'
type OverlayDateTimeFieldProps = {
sheet: boolean
id: string
label: string
value: string
onValueChange: (value: string) => void
invalid: boolean
error?: string
required?: boolean
/** C104 — false hides the time selects (all-day mode). */
timeVisible?: boolean
}
const OverlayDateTimeField = ({
sheet,
id,
label,
value,
onValueChange,
invalid,
error,
required = false,
timeVisible = true,
}: OverlayDateTimeFieldProps) => (
<Field data-invalid={invalid}>
<FieldLabel htmlFor={id} className={sheet ? 'sr-only' : undefined}>
{label}
{required ? ' *' : ''}
</FieldLabel>
<ActivityDateTimeField
id={id}
value={value}
onValueChange={onValueChange}
invalid={invalid}
errorId={error ? `${id}-error` : undefined}
isNarrow={sheet}
label={label}
required={required}
timeVisible={timeVisible}
/>
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
</Field>
)
type ActivityOverlayFormProps = {
variant: 'modal' | 'sheet'
mode: 'create' | 'edit'
createDraft?: { startAt: string; endAt: string }
editDraft?: ActivityFormViewModel
/** Create-mode prefill: the active municipality filter. Edit mode ignores it. */
agendaState?: ActivityAgendaState
municipalityOptions: RelationOption[]
organizationOptions: RelationOption[]
knownTags?: string[]
onClose: () => void
onSaved: () => void
}
const ActivityOverlayForm = ({
variant,
mode,
createDraft,
editDraft,
agendaState,
municipalityOptions,
organizationOptions,
knownTags,
onClose,
onSaved,
}: ActivityOverlayFormProps) => {
const sheet = variant === 'sheet'
const isEdit = mode === 'edit'
const initialStartIso = isEdit ? editDraft?.startAt : createDraft?.startAt
const initialEndIso = isEdit ? editDraft?.endAt : createDraft?.endAt
const [title, setTitle] = useState(isEdit ? (editDraft?.title ?? '') : '')
const [start, setStart] = useState(
initialStartIso ? formatIsoAsBahiaDateTimeInput(initialStartIso) : '',
)
const [end, setEnd] = useState(initialEndIso ? formatIsoAsBahiaDateTimeInput(initialEndIso) : '')
const [allDay, setAllDay] = useState(isEdit ? Boolean(editDraft?.allDay) : false)
const [locality, setLocality] = useState(isEdit ? (editDraft?.locality ?? '') : '')
const initialMunicipalityId = isEdit ? editDraft?.municipalityId : agendaState?.municipality
const [municipalityValue, setMunicipalityValue] = useState(
initialMunicipalityId &&
municipalityOptions.some((option) => option.id === initialMunicipalityId)
? String(initialMunicipalityId)
: '',
)
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const errorFor = (name: string) => fieldErrors[name]?.[0]
const allDayStartDate = start.slice(0, 10)
const allDayEndDate = end ? end.slice(0, 10) : allDayStartDate
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const trimmedTitle = title.trim()
const municipalityId = Number(municipalityValue)
const startIso = parseBahiaDateTimeInput(start)
const endIso = end ? parseBahiaDateTimeInput(end) : undefined
const nextFieldErrors: Record<string, string[]> = {}
if (!isEdit && !trimmedTitle) {
nextFieldErrors.title = ['Informe o título do compromisso.']
} else if (!isEdit && trimmedTitle.length < 2) {
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
}
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
if (allDay) {
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
nextFieldErrors.endAt = ['A data de término deve ser igual ou posterior à de início.']
}
} else {
if (!startIso)
nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.']
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) {
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.']
}
}
if (Object.keys(nextFieldErrors).length > 0) {
setFieldErrors(nextFieldErrors)
return
}
setFieldErrors({})
setFailedMessage(null)
setSaving(true)
try {
const result = isEdit
? await updateActivityOverlay(formData)
: await createActivityOverlay(formData)
if (result.ok) {
onSaved()
return
}
setSaving(false)
setFieldErrors(result.fieldErrors ?? {})
setFailedMessage(result.message)
toast.error(result.message)
} catch {
setSaving(false)
setFailedMessage(ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE)
toast.error(ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE)
}
}
const startEndFields = (
<>
<OverlayDateTimeField
sheet={sheet}
id="overlay-startAt"
label="Início"
value={start}
onValueChange={setStart}
invalid={Boolean(errorFor('startAt'))}
error={errorFor('startAt')}
required
timeVisible={!allDay}
/>
<OverlayDateTimeField
sheet={sheet}
id="overlay-endAt"
label="Término"
value={end}
onValueChange={setEnd}
invalid={Boolean(errorFor('endAt'))}
error={errorFor('endAt')}
timeVisible={!allDay}
/>
</>
)
const basicInfoSection = (
<>
<Field data-invalid={Boolean(errorFor('title'))}>
<FieldLabel htmlFor="overlay-title" className={sheet ? 'sr-only' : undefined}>
Título *
</FieldLabel>
<Input
id="overlay-title"
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={160}
minLength={2}
readOnly={isEdit}
placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
autoComplete="off"
aria-invalid={Boolean(errorFor('title'))}
aria-describedby={errorFor('title') ? 'overlay-title-error' : undefined}
/>
{errorFor('title') ? (
<FieldError id="overlay-title-error">{errorFor('title')}</FieldError>
) : null}
</Field>
<div className={sheet ? 'py-1.5' : undefined}>
<Field>
<FieldLabel className={sheet ? 'sr-only' : undefined}>Tags</FieldLabel>
<ActivityTagInput
initialTags={isEdit ? (editDraft?.tags ?? []) : []}
knownTags={knownTags}
compact={sheet}
placeholder={sheet ? 'Adicionar tags' : undefined}
error={errorFor('tagsJson')}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="overlay-description" className={sheet ? 'sr-only' : undefined}>
Descrição
</FieldLabel>
<Textarea
id="overlay-description"
name="description"
defaultValue={isEdit ? (editDraft?.description ?? '') : ''}
maxLength={4000}
className={sheet ? 'min-h-24 rounded-none border-0 px-0' : 'min-h-24'}
placeholder={sheet ? 'Descrição' : undefined}
/>
</Field>
<Field
orientation="horizontal"
className={
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
}
>
<Checkbox
name="deputyPresent"
defaultChecked={isEdit ? editDraft?.deputyPresent : false}
aria-label="Deputado presente"
/>
<span>Deputado presente</span>
</Field>
</>
)
const scheduleSection = (
<>
<Field
orientation="horizontal"
className={
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
}
>
<Checkbox
checked={allDay}
onCheckedChange={(next) => setAllDay(Boolean(next))}
aria-label="Todo o dia"
/>
<span>Todo o dia</span>
</Field>
{sheet ? startEndFields : <div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>}
</>
)
const whereSection = (
<>
<Field data-invalid={Boolean(errorFor('municipality'))}>
<FieldLabel htmlFor="overlay-municipality" className={sheet ? 'sr-only' : undefined}>
Município *
</FieldLabel>
<StrictCombobox
id="overlay-municipality"
options={inlineMunicipalityOptions(municipalityOptions)}
value={municipalityValue}
onValueChange={setMunicipalityValue}
error={errorFor('municipality')}
placeholder={sheet ? 'Município *' : undefined}
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
/>
{errorFor('municipality') ? (
<FieldError id="overlay-municipality-error">{errorFor('municipality')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="overlay-locality" className={sheet ? 'sr-only' : undefined}>
Local (opcional)
</FieldLabel>
<Input
id="overlay-locality"
name="locality"
value={locality}
onChange={(event) => setLocality(event.target.value)}
maxLength={160}
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
/>
{errorFor('locality') ? (
<FieldError id="overlay-locality-error">{errorFor('locality')}</FieldError>
) : null}
</Field>
</>
)
const peopleSection = (
<>
<ResponsibleMultiSelect
name="responsiblesJson"
label="Responsáveis"
value={isEdit ? (editDraft?.responsibles ?? []) : []}
error={errorFor('responsiblesJson')}
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
labelClassName={sheet ? 'sr-only' : undefined}
emptyText={sheet ? 'Adicionar responsáveis' : undefined}
search={searchActivityResponsibleOptionsAction}
triggerClassName={
sheet
? 'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30'
: undefined
}
/>
<RelationMultiSelect
name="organizations"
label="Organizações apoiadoras"
options={organizationOptions}
initialSelectedIDs={isEdit ? (editDraft?.organizationIDs ?? []) : []}
error={errorFor('organizations')}
placeholder="Adicionar organização…"
/>
</>
)
const tasksSection = (
<ActivityTaskFields
initialTasks={isEdit ? (editDraft?.tasks ?? []) : []}
searchContacts={searchActivityContactOptions}
error={errorFor('tasksJson')}
/>
)
const demandsSection = <ActivityDemandFields error={errorFor('demandsJson')} />
return (
<form
onSubmit={handleSubmit}
noValidate
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'}
>
{isEdit ? <input type="hidden" name="id" value={editDraft?.id ?? ''} /> : null}
<input type="hidden" name="status" value="confirmado" />
{allDay ? <input type="hidden" name="allDay" value="on" /> : null}
<input type="hidden" name="startAt" value={allDay ? allDayStartDate : start} />
<input type="hidden" name="endAt" value={allDay ? allDayEndDate : end} />
<input type="hidden" name="municipality" value={municipalityValue} />
<div
className={
sheet
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
: 'max-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6'
}
>
{sheet ? (
<div className="divide-y divide-border">
{basicInfoSection}
{scheduleSection}
{whereSection}
<div className="py-1.5">{peopleSection}</div>
<div className="py-1.5">{tasksSection}</div>
<div className="py-1.5">{demandsSection}</div>
</div>
) : (
<div className="flex flex-col gap-5">
<Card>
<CardHeader>
<CardTitle>Informações básicas</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{basicInfoSection}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Data e horário</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{scheduleSection}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Onde</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{whereSection}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pessoas e organizações</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">{peopleSection}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tarefas</CardTitle>
</CardHeader>
<CardContent>{tasksSection}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Demandas</CardTitle>
</CardHeader>
<CardContent>{demandsSection}</CardContent>
</Card>
</div>
)}
{failedMessage ? (
<Alert variant="destructive" className={sheet ? undefined : 'mt-5'}>
<AlertDescription>{failedMessage}</AlertDescription>
</Alert>
) : null}
</div>
<div
className={
sheet
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
: 'flex shrink-0 items-center justify-between gap-3 border-t px-6 py-4'
}
>
{isEdit && editDraft ? (
<Button asChild variant="ghost" className="min-h-11">
<Link href={`/campanha/atividades/${editDraft.slug}`}>Ver detalhes</Link>
</Button>
) : (
<span />
)}
<div className="flex items-center gap-3">
<Button type="button" variant="outline" className="min-h-11" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
{isEdit ? 'Salvar alterações' : 'Salvar'}
</Button>
</div>
</div>
</form>
)
}
export const ActivityOverlay = ({
request,
isNarrow,
agendaState,
municipalityOptions,
organizationOptions,
knownTags,
onClose,
onSaved,
}: ActivityOverlayProps) => {
const [editDraft, setEditDraft] = useState<ActivityFormViewModel | 'loading' | 'error' | null>(
null,
)
const isEdit = request?.kind === 'edit'
const activityId = isEdit ? request.activityId : null
useEffect(() => {
if (!isEdit || activityId === null) return
let cancelled = false
setEditDraft('loading')
loadActivityEditDraft(activityId)
.then((viewModel) => {
if (!cancelled) setEditDraft(viewModel)
})
.catch(() => {
if (!cancelled) setEditDraft('error')
})
return () => {
cancelled = true
}
}, [isEdit, activityId])
if (!request) return null
const sheetTitle = isEdit ? 'Editar atividade' : 'Nova atividade'
const dialog = (children: ReactNode) =>
isNarrow ? (
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} swipeDirection="up">
<DrawerContent className="h-[calc(100dvh-1rem)] max-h-[calc(100dvh-1rem)]">
<DrawerHeader>
<DrawerTitle>{sheetTitle}</DrawerTitle>
{request.kind === 'create' ? (
<DrawerDescription>{formatBahiaDateTimeLabel(request.startAt)}</DrawerDescription>
) : editDraft && editDraft !== 'loading' && editDraft !== 'error' ? (
<DrawerDescription>{editDraft.title}</DrawerDescription>
) : null}
</DrawerHeader>
{children}
</DrawerContent>
</Drawer>
) : (
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{isEdit ? 'Editar atividade' : 'Nova atividade'}</DialogTitle>
{request.kind === 'create' ? (
<DialogDescription>{formatBahiaDateTimeLabel(request.startAt)}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
)
if (isEdit) {
if (editDraft === 'loading' || editDraft === null) {
return dialog(
<div className="flex min-h-48 flex-1 items-center justify-center" role="status">
<Spinner aria-hidden="true" />
<span className="sr-only">Carregando compromisso…</span>
</div>,
)
}
if (editDraft === 'error') {
return dialog(
<div className="flex min-h-48 flex-1 flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-muted-foreground">
Não foi possível carregar este compromisso. Tente novamente.
</p>
</div>,
)
}
return dialog(
<ActivityOverlayForm
variant={isNarrow ? 'sheet' : 'modal'}
mode="edit"
editDraft={editDraft}
agendaState={agendaState}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
onClose={onClose}
onSaved={onSaved}
/>,
)
}
return dialog(
<ActivityOverlayForm
variant={isNarrow ? 'sheet' : 'modal'}
mode="create"
createDraft={{ startAt: request.startAt, endAt: request.endAt }}
agendaState={agendaState}
municipalityOptions={municipalityOptions}
organizationOptions={organizationOptions}
knownTags={knownTags}
onClose={onClose}
onSaved={onSaved}
/>,
)
}

View file

@ -1,7 +1,7 @@
'use client'
import { CalendarIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import {
CalendarFeedDialog,
@ -9,7 +9,7 @@ import {
type CreateCalendarFeedResult,
} from '@/components/campaign/activity/CalendarFeedDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
type AgendaFeedChromeProps = {
@ -25,16 +25,10 @@ type AgendaFeedChromeProps = {
* dialog instance.
*/
export const AgendaFeedChrome = ({ feeds, onCreateFeed, onRevokeFeed }: AgendaFeedChromeProps) => {
const { setContext } = useCampaignQuickActionContext()
const [open, setOpen] = useState(false)
const openFeed = useCallback(() => setOpen(true), [])
useEffect(() => {
setContext((current) => ({ ...current, openCalendarFeed: openFeed }))
return () => {
setContext((current) => ({ ...current, openCalendarFeed: undefined }))
}
}, [setContext, openFeed])
useBridgedQuickAction('openCalendarFeed', openFeed)
const headerButton = useMemo(
() => (

View file

@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
type AgendaGoogleSyncChromeProps = {
@ -51,19 +51,13 @@ export const AgendaGoogleSyncChrome = ({
onSyncNow,
onSetDisabled,
}: AgendaGoogleSyncChromeProps) => {
const { setContext } = useCampaignQuickActionContext()
const [open, setOpen] = useState(false)
const [state, setState] = useState(initialState)
const autoRetriedRef = useRef(false)
const openSync = useCallback(() => setOpen(true), [])
useEffect(() => {
setContext((current) => ({ ...current, openGoogleCalendarSync: openSync }))
return () => {
setContext((current) => ({ ...current, openGoogleCalendarSync: undefined }))
}
}, [setContext, openSync])
useBridgedQuickAction('openGoogleCalendarSync', openSync)
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
// page loads (the staff is operating — the natural retry moment).

View file

@ -3,6 +3,7 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type Dispatch,
@ -38,3 +39,24 @@ export const useCampaignQuickActionContext = (): CampaignQuickActionContextValue
}
return value
}
type CampaignBridgedQuickActionKey =
| 'openCalendarFeed'
| 'openGoogleCalendarSync'
| 'openActivityCreate'
| 'openActivityEdit'
/**
* Registers a dialog-style quick action into the shared context while the
* owning surface is mounted (precedent C94/C114, reused by C123 hosts). The
* same key is unregistered on unmount so the FAB never fires a stale closure.
*/
export const useBridgedQuickAction = (key: CampaignBridgedQuickActionKey, open: () => void) => {
const { setContext } = useCampaignQuickActionContext()
useEffect(() => {
setContext((current) => ({ ...current, [key]: open }))
return () => {
setContext((current) => ({ ...current, [key]: undefined }))
}
}, [setContext, key, open])
}

View file

@ -0,0 +1,11 @@
/**
* C123 — client-safe overlay messages. Split from `activityOverlayErrors.ts`
* (whose mapper imports the server-only FormData ladder) so the overlay
* component can reference the generic failure copy without dragging the
* server graph into the client bundle.
*/
export const ACTIVITY_DUPLICATE_TITLE_MESSAGE = 'Já existe uma atividade com este título.'
export const ACTIVITY_DEMAND_DUPLICATE_MESSAGE =
'Já existe uma demanda com um dos títulos informados.'
export const ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE =
'Não foi possível salvar o compromisso. Tente novamente.'

View file

@ -16,7 +16,6 @@ import {
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
import {
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
type ActivityQuickActionSurface,
} from '@/lib/campaignQuickActionPaths'
@ -32,13 +31,21 @@ const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
]
const listQuickActions = (context: CampaignQuickActionContext): readonly CampaignQuickAction[] => [
{
id: 'new-activity',
label: 'Nova atividade',
icon: Plus,
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
href: ACTIVITY_NEW_PATH,
},
// C123 — the create surface is the agenda overlay; the FAB opens it via the
// page-bridged context (same pattern as openCalendarFeed/openGoogleCalendarSync).
// Conditional like the dialog neighbors: while the bridge is not registered
// yet (initial load), the entry must not render as a dead button.
...(context.openActivityCreate
? [
{
id: 'new-activity',
label: 'Nova atividade',
icon: Plus,
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
onAction: context.openActivityCreate,
},
]
: []),
...(context.openCalendarFeed
? [
{
@ -93,16 +100,25 @@ const wizardActionsWithMunicipalityPrefill = (
})
}
const detailVerticalQuickActions = (activitySlug: string): readonly CampaignQuickAction[] => {
const detailVerticalQuickActions = (
activitySlug: string,
context: CampaignQuickActionContext,
): readonly CampaignQuickAction[] => {
const base = `/campanha/atividades/${activitySlug}`
const actions: CampaignQuickAction[] = [
{
id: 'edit-activity',
label: 'Editar',
icon: Pencil,
description: 'Abrir o formulário completo da atividade',
href: `${base}/editar`,
},
// C123 — set by the detail page bridge (opens the edit overlay). Conditional
// like the list entry: no dead "Editar" while the bridge is not registered.
...(context.openActivityEdit
? [
{
id: 'edit-activity',
label: 'Editar',
icon: Pencil,
description: 'Abrir o formulário completo da atividade',
onAction: context.openActivityEdit,
},
]
: []),
{
id: 'activity-tasks',
label: 'Tarefas',
@ -138,7 +154,7 @@ export const resolveActivityQuickActions = (
const municipalitySlug = context.municipalitySlug
const returnPath = pathname ?? `/campanha/atividades/${activitySlug}`
const detailActions = detailVerticalQuickActions(activitySlug)
const detailActions = detailVerticalQuickActions(activitySlug, context)
if (!municipalitySlug) {
return detailActions

View file

@ -57,9 +57,6 @@ export const campaignPageChromeCatalog = {
agenda: {
title: 'Agenda',
},
atividadesNova: {
title: 'Nova atividade',
},
atividadesGiros: {
title: 'Planejar giro',
},
@ -185,18 +182,10 @@ const pathRules: PathRule[] = [
match: (pathname) => pathname === '/campanha/pessoas',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.pessoas),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),
},
{
match: (pathname) => pathname === '/campanha/atividades/giros',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros),
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
resolve: () => null,

View file

@ -18,6 +18,13 @@ export type CampaignQuickActionContext = {
openCalendarFeed?: () => void
/** C114 — opens the Google Calendar mirror dialog (same bridge as above). */
openGoogleCalendarSync?: () => void
/**
* C123 — opens the agenda's create overlay (bridged by the agenda and the
* activities list page, which host the overlay).
*/
openActivityCreate?: () => void
/** C123 — opens the agenda's edit overlay for the current activity (bridged by the detail page). */
openActivityEdit?: () => void
}
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})

View file

@ -6,8 +6,6 @@ export const ACTIVITY_LIST_PATH = CAMPAIGN_AGENDA_HOME
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

View file

@ -1,24 +0,0 @@
/**
* C91 — safe-message mapping for the agenda's inline quick create. Pure, so
* both the server action and the unit tests share one source of truth: a DB
* unique-title violation surfaces as a field error named like the full form's,
* anything else collapses to a generic message.
*/
export const ACTIVITY_DUPLICATE_TITLE_MESSAGE = 'Já existe uma atividade com este título.'
export const ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE =
'Não foi possível criar o compromisso. Tente novamente.'
export type ActivityInlineCreateErrorResult = {
message: string
fieldErrors?: Record<string, string[]>
}
export const mapActivityInlineCreateError = (error: unknown): ActivityInlineCreateErrorResult => {
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
return {
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
}
}
return { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE }
}

View file

@ -0,0 +1,43 @@
import {
ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
} from '@/lib/activityOverlayMessages'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
/**
* C123 — unified error mapping for the agenda's single create/edit overlay.
* Pure, so both the server actions and the unit tests share one source of
* truth. DB unique-violations surface as named field errors (same strings the
* old full-form actions used), everything else collapses to a generic message.
*/
export type ActivityOverlayErrorResult = {
message: string
fieldErrors?: Record<string, string[]>
}
export const mapActivityOverlayError = (error: unknown): ActivityOverlayErrorResult => {
if (
error instanceof Error &&
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
) {
return {
message: ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
fieldErrors: { demandsJson: [ACTIVITY_DEMAND_DUPLICATE_MESSAGE] },
}
}
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
return {
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
}
}
const mapped = mapCampaignFormActionError({
error,
genericMessage: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
})
return {
message: mapped.message ?? ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
...(mapped.fieldErrors ? { fieldErrors: mapped.fieldErrors } : {}),
}
}

View file

@ -13,11 +13,9 @@ import {
} from '@/utilities/activityUi'
import {
activityAgendaSelect,
activityFormSelect,
activityListSelect,
getActivityDetailSelect,
toActivityAgendaEvent,
toActivityFormViewModel,
} from '@/utilities/activityViewModels'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
@ -100,7 +98,7 @@ const loadAccessibleActivityBySlug = async (
payload: Pick<Payload, 'find'>,
user: CampaignUser,
activitySlug: string,
select: ReturnType<typeof getActivityDetailSelect> | typeof activityFormSelect,
select: ReturnType<typeof getActivityDetailSelect>,
depth: number,
): Promise<Activity> => {
const result = await payload.find({
@ -138,12 +136,3 @@ export const resolveAccessibleActivityContext = async (
document,
}
}
export const getActivityEditPageData = async (
payload: Payload,
user: CampaignUser,
activitySlug: string,
) =>
toActivityFormViewModel(
await loadAccessibleActivityBySlug(payload, user, activitySlug, activityFormSelect, 1),
)

View file

@ -11,7 +11,6 @@ import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/co
import {
activityStatuses,
MAX_ACTIVITY_TAG_LENGTH,
MAX_ACTIVITY_TAGS,
type ActivityStatus,
} from '@/lib/schemas/activity'
import {
@ -354,39 +353,6 @@ export const buildActivityAgendaWhere = (
return { and: filters }
}
export type ActivityCreatePrefill = {
startAt?: string
endAt?: string
municipalityId?: number
title?: string
tags?: string[]
/** C104 — the sheet opened the toggle; startAt/endAt are civil dates. */
allDay?: boolean
}
export const buildActivityCreateHref = (
agendaState: ActivityAgendaState,
prefill: Partial<ActivityCreatePrefill> = {},
): string => {
const params = new URLSearchParams()
if (prefill.allDay) {
params.set('allDay', '1')
if (prefill.startAt) params.set('startAt', prefill.startAt.slice(0, 10))
if (prefill.endAt) params.set('endAt', prefill.endAt.slice(0, 10))
} else {
if (prefill.startAt) params.set('startAt', prefill.startAt)
if (prefill.endAt) params.set('endAt', prefill.endAt)
}
const municipalityId = prefill.municipalityId ?? agendaState.municipality
if (municipalityId) params.set('municipality', String(municipalityId))
if (prefill.title) params.set('title', prefill.title)
// C105 — repeated `tags` params: a free-form tag may contain a comma, so a
// comma-joined single value would corrupt the boundary between tags.
for (const tag of prefill.tags ?? []) params.append('tags', tag)
params.set('returnTo', buildActivityAgendaHref(agendaState))
return `/campanha/atividades/nova?${params.toString()}`
}
/**
* C91 — the inline create overlay prefills the slot the staff clicked. The
* FullCalendar `dateClick` already snaps the click to a slot boundary, so for
@ -410,105 +376,15 @@ export const activitySlotPrefill = (info: {
}
}
export const parseActivityAgendaReturnHref = (
value: string | string[] | undefined,
accessibleMunicipalityIDs: ReadonlySet<number>,
accessibleTags: ReadonlySet<string>,
): string => {
const raw = firstValue(value)
if (!raw) return CAMPAIGN_AGENDA_HOME
const base = new URL('https://campaign.invalid')
let returnUrl: URL
try {
returnUrl = new URL(raw, base)
} catch {
return CAMPAIGN_AGENDA_HOME
}
if (returnUrl.origin !== base.origin || returnUrl.pathname !== CAMPAIGN_AGENDA_HOME) {
return CAMPAIGN_AGENDA_HOME
}
const state = restrictActivityAgendaState(
parseActivityAgendaParams({
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
tag: returnUrl.searchParams.get('tag') ?? undefined,
view: returnUrl.searchParams.get('view') ?? 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()
}
/**
* C105 — bounded prefill tags: repeated `tags` params, trimmed, deduped,
* capped at the same limits the create schema enforces. Anything out of bounds
* is dropped (same drop semantics as the agenda filter's `activityTag`),
* never rejected — a prefill is a convenience, not a contract.
* C123 — the default create window for overlay hosts with no slot clicked
* (FAB "Nova atividade", list-page button): today 09:00–10:00, the same
* historical all-day-slot fallback `activitySlotPrefill` uses.
*/
const parseActivityPrefillTags = (value: string | string[] | undefined): string[] | undefined => {
if (value === undefined) return undefined
const tags: string[] = []
for (const entry of Array.isArray(value) ? value : [value]) {
if (tags.length >= MAX_ACTIVITY_TAGS) break
const tag = activityTag(entry)
if (tag && !tags.includes(tag)) tags.push(tag)
}
return tags.length > 0 ? tags : undefined
}
export const parseActivityCreatePrefill = (
params: RawSearchParams,
accessibleMunicipalityIDs: ReadonlySet<number>,
): ActivityCreatePrefill => {
const rawAllDay = firstValue(params.allDay)
const allDay = rawAllDay === '1'
const startAt = allDay
? civilDatePrefillInstant(firstValue(params.startAt))
: normalizedIsoInstant(firstValue(params.startAt))
const rawEndAt = firstValue(params.endAt)
const endAt = allDay
? civilDatePrefillInstant(rawEndAt, startAt)
: startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt)
? normalizedIsoInstant(rawEndAt)
: undefined
const rawMunicipality = strictDecimalInteger(firstValue(params.municipality))
const municipalityId =
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
const title = normalizedText(firstValue(params.title))
const trimmedTitle = title && title.length <= 160 ? title : undefined
const tags = parseActivityPrefillTags(params.tags)
return {
...(allDay ? { allDay } : {}),
...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}),
...(trimmedTitle ? { title: trimmedTitle } : {}),
...(tags ? { tags } : {}),
}
}
const civilDatePrefillInstant = (
value: string | undefined,
startAt?: string,
): string | undefined => {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
const instant = allDayStartInstant(value)
if (startAt && instant < startAt) return undefined
return instant
}
export const activityDefaultCreatePrefill = (
now = new Date(),
): { startAt: string; endAt: string } | null =>
activitySlotPrefill({ allDay: true, dateStr: formatBahiaCivilDate(now) })
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const

View file

@ -3,10 +3,37 @@ import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
import { dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js'
/**
* Agenda prologue shared by every journey in this file: opens the agenda
* filtered to one município, waits for the window load to land (FullCalendar
* renders the grid lazily and re-lays out on mount — the grid first paints
* empty and re-renders, replacing its gridcells, when the events land) and
* clicks the 14:00 slot. `force` bypasses the C104 all-day lane, which paints
* day fills over the time grid: real clicks still land on the slot
* (FullCalendar resolves by data-time), but Playwright's actionability check
* refuses them.
*/
const openAgendaSlot = async ({
campaign,
page,
municipalityId,
}: {
campaign: { baseURL: string }
page: import('@playwright/test').Page
municipalityId: number
}) => {
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipalityId}`)
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
await slotLocator.scrollIntoViewIfNeeded()
await slotLocator.click({ force: true })
}
test.describe('Atividades — registro-fundação', () => {
test.setTimeout(90_000)
test('cria compromisso com demandas vinculadas e exibe os sinais no município', async ({
test('cria compromisso com demandas vinculadas pelo overlay e exibe os sinais no município', async ({
campaign,
page,
}) => {
@ -21,11 +48,15 @@ test.describe('Atividades — registro-fundação', () => {
const materialDemand = fixtures.value('Material de rua')
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
// C123 — the agenda overlay is the only create surface; /nova is gone.
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
// The central modal (desktop) hosts every section of the old full form.
const modal = page.getByRole('dialog', { name: 'Nova atividade' })
await expect(modal).toBeVisible()
await page.getByLabel('Título *').fill(activityTitle)
await page.getByLabel('Início *').fill('2026-08-15T10:00')
await page.getByLabel('Município *').selectOption(String(municipality.id))
// The municipality filter prefills the field; the times come from the slot.
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
// C90 — the unified polymorphic selector replaces the old Contact
// responsável + assessores/liderança fields.
@ -39,8 +70,20 @@ test.describe('Atividades — registro-fundação', () => {
await page.getByLabel('Título da demanda 2').fill(materialDemand)
await page.getByLabel('Tipo da demanda 2').selectOption('material')
await page.getByRole('button', { name: 'Criar atividade' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
// Save never navigates: the event lands in the calendar and the URL stays.
await expect(page.getByText(activityTitle, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
// Clicking the event opens the edit overlay; the detail stays reachable
// through the overlay's own "Ver detalhes" link.
await page.getByText(activityTitle, { exact: true }).first().click()
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(editModal).toBeVisible()
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
await expect(campaignPageChrome(page, activityTitle)).toBeVisible({
timeout: 15_000,
})
@ -166,8 +209,16 @@ test.describe('Agenda — calendário operacional', () => {
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true)
// C123 — clicking the event opens the edit overlay; the detail is reached
// through the overlay's own link.
await page.getByText(title, { exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/)
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(editModal).toBeVisible()
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, {
timeout: 30_000,
})
await expect(campaignPageChrome(page, title)).toBeVisible()
})
@ -181,65 +232,38 @@ test.describe('Agenda — calendário operacional', () => {
const title = fixtures.value('Café inline na agenda')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
// FullCalendar renders the time axis lazily and re-lays out on mount: the
// grid first paints empty and re-renders (replacing its gridcells) when the
// events land. Wait for the load to finish before interacting, or the
// locator's element is replaced mid-action ("Element is not attached").
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
await expect(slotLocator).toBeVisible()
// C104 — with the all-day lane enabled, the v7 classic theme paints the
// all-day day fills over the time grid; real clicks still land on the slot
// (FullCalendar resolves by data-time), but Playwright's actionability
// check refuses them, so the click is forced on the slot lane itself.
await slotLocator.scrollIntoViewIfNeeded()
await slotLocator.click({ force: true })
// The click no longer navigates: the inline overlay opens at the slot.
// The click no longer navigates: the central modal opens with the slot's
// date and the split controls (date trigger + inline hour/minute).
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
// C97 — the native datetime-local is gone; the trigger buttons show the
// civil 24h label (slot = 14:00–14:30 Bahia, whatever the browser locale).
const startTrigger = page.getByLabel('Início *')
await expect(startTrigger).toBeVisible()
const [startLabel, endLabel] = await Promise.all([
startTrigger.textContent(),
page.getByLabel('Término').textContent(),
page.getByRole('button', { name: 'Término', exact: true }).textContent(),
])
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:00/)
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:30/)
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
// C123 — the time selects are INLINE, visible without opening anything.
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
await expect(page.getByLabel('Hora de Término')).toHaveValue('14')
await expect(page.getByLabel('Minuto de Término')).toHaveValue('30')
await page.getByLabel('Título *').fill(title)
// The acceptance center: pick a different time through the shadcn picker
// (calendar keeps the day; the step selects change hour/minute) and the
// saved event lands at the chosen hour. Both fields move together so the
// "Término posterior ao Início" validation stays satisfied. After
// selectOption the focus sits in the native select, where Escape never
// reaches Radix — re-clicking the selected day moves focus into the
// calendar, then Escape closes only the picker (never the overlay).
await startTrigger.click()
// Scope to the live picker: a just-closed popover lingers in the DOM for
// its 100 ms close animation, so a bare combobox query can match twice.
const openPicker = () => page.locator('[data-slot="popover-content"][data-state="open"]').last()
const hourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
await expect(hourSelect).toBeVisible()
await hourSelect.selectOption('15')
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 15:00/)
await openPicker().locator('[data-selected-single="true"]').click()
await page.keyboard.press('Escape')
await page.getByLabel('Término').click()
const endHourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
await expect(endHourSelect).toBeVisible()
await endHourSelect.selectOption('16')
// The acceptance center: pick a different time through the inline selects
// and the saved event lands at the chosen hour. Both fields move together
// so the "Término posterior ao Início" validation stays satisfied.
await page.getByLabel('Hora de Início').selectOption('15')
await expect(page.getByLabel('Hora de Início')).toHaveValue('15')
await page.getByLabel('Hora de Término').selectOption('16')
// The minute keeps its prefill (30) — only the hour changed.
await expect(page.getByLabel('Término')).toHaveText(/\d{2}\/\d{2}\/\d{4} às 16:30/)
await openPicker().locator('[data-selected-single="true"]').click()
await page.keyboard.press('Escape')
await expect(page.getByLabel('Minuto de Término')).toHaveValue('30')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
// The saved event landed at the chosen times (24h chip, no meridiem).
@ -257,27 +281,24 @@ test.describe('Agenda — calendário operacional', () => {
const title = fixtures.value('Giro de dia inteiro')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
// C104 — with the all-day lane enabled, the v7 classic theme paints the
// all-day day fills over the time grid; real clicks still land on the slot
// (FullCalendar resolves by data-time), but Playwright's actionability
// check refuses them, so the click is forced on the slot lane itself.
await slotLocator.scrollIntoViewIfNeeded()
await slotLocator.click({ force: true })
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
const startTrigger = page.getByLabel('Início *')
await expect(startTrigger).toBeVisible()
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 14:00/)
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4}/)
// C123 — the time selects are inline; the slot window shows in them.
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
// C104 — toggling "Todo o dia" hides the times: both triggers show only
// the civil date, and the picker no longer offers Hora/Minuto selects.
// the civil date, the inline selects disappear and the picker no longer
// offers Hora/Minuto selects.
await page.getByLabel('Todo o dia').click()
await expect(startTrigger).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
await expect(page.getByLabel('Término')).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
await expect(page.getByRole('button', { name: 'Término', exact: true })).toHaveText(
/^\d{2}\/\d{2}\/\d{4}$/,
)
await expect(page.getByLabel('Hora de Início')).toHaveCount(0)
await startTrigger.click()
await expect(
page.locator('[data-slot="popover-content"][data-state="open"]').last().getByRole('combobox'),
@ -285,14 +306,16 @@ test.describe('Agenda — calendário operacional', () => {
await page.keyboard.press('Escape')
// Multi-day: the end picker lands on a later day of the same month.
await page.getByLabel('Término').click()
await page.getByRole('button', { name: 'Término', exact: true }).click()
const endPicker = page.locator('[data-slot="popover-content"][data-state="open"]').last()
await endPicker.getByRole('button', { name: /17 de agosto de 2026/ }).click()
await page.keyboard.press('Escape')
await expect(page.getByLabel('Término')).toHaveText(/17\/08\/2026/)
await expect(page.getByRole('button', { name: 'Término', exact: true })).toHaveText(
/17\/08\/2026/,
)
await page.getByLabel('Título *').fill(title)
await page.getByRole('button', { name: 'Salvar' }).click()
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
// The band lands without a time chip: the all-day event carries no
// "14:00 – 14:30" text (the timed events render it; allDay:true does not).
@ -300,55 +323,71 @@ test.describe('Agenda — calendário operacional', () => {
await expect(page.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toHaveCount(0)
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
// The detail page preserves the all-day choice: date-only labels.
// C123 — clicking the event opens the edit overlay (no navigation); the
// detail page stays reachable through the overlay link and preserves the
// all-day choice: date-only labels.
await page.getByText(title, { exact: true }).first().click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(editModal).toBeVisible()
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
await expect(page.getByText(/^\d{2}\/\d{2}\/\d{4}$/).first()).toBeVisible()
await expect(page.getByText(/\d{2}\/\d{2}\/\d{4} às /)).toHaveCount(0)
})
test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({
test('edita o compromisso pelo overlay no clique do evento, sem navegar (C123)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const title = fixtures.value('Detalhes inline da agenda')
const title = fixtures.value('Compromisso editado no overlay')
const newLocality = fixtures.value('Nova sede do comitê')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
// FullCalendar renders the time axis lazily and re-lays out on mount: the
// grid first paints empty and re-renders (replacing its gridcells) when the
// events land. Wait for the load to finish before interacting, or the
// locator's element is replaced mid-action ("Element is not attached").
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
await expect(slotLocator).toBeVisible()
// C104 — with the all-day lane enabled, the v7 classic theme paints the
// all-day day fills over the time grid; real clicks still land on the slot
// (FullCalendar resolves by data-time), but Playwright's actionability
// check refuses them, so the click is forced on the slot lane itself.
await slotLocator.scrollIntoViewIfNeeded()
await slotLocator.click({ force: true })
await expect(page.getByLabel('Título *')).toBeVisible()
// Create first through the same overlay.
await expect(page.getByRole('dialog', { name: 'Nova atividade' })).toBeVisible()
await page.getByLabel('Título *').fill(title)
await page.getByRole('link', { name: 'Mais detalhes' }).click()
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page).toHaveURL(/\/campanha\/atividades\/nova\?startAt=/)
await expect(page.getByLabel('Título *')).toHaveValue(title)
await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id))
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(1_800_000)
// Clicking the event opens the EDIT overlay prefilled with the saved
// values — no navigation, no /editar page.
await page.getByText(title, { exact: true }).first().click()
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(editModal).toBeVisible()
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
await expect(editModal.getByLabel('Título *')).toHaveValue(title, { timeout: 30_000 })
await expect(editModal.getByLabel('Município *')).toHaveValue(municipality.name)
await expect(editModal.getByLabel('Hora de Início')).toHaveValue('14')
await expect(editModal.getByLabel('Minuto de Término')).toHaveValue('30')
// C123 — the edit overlay carries the id the old /editar page lost (C14
// regression): saving must actually persist.
await editModal.getByLabel('Hora de Término').selectOption('15')
await editModal.getByLabel('Local (opcional)').fill(newLocality)
await editModal.getByRole('button', { name: 'Salvar alterações' }).click()
// Save closes the overlay, never navigates; the calendar reflects the edit.
await expect(page.getByRole('dialog', { name: 'Editar atividade' })).toBeHidden()
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
await expect(page.getByText('14:00 – 15:30', { exact: true })).toBeVisible({
timeout: 15_000,
})
await expect(page.getByText('14:00 – 14:30', { exact: true })).toHaveCount(0)
// The detail page reflects the persisted edit.
await page.getByText(title, { exact: true }).first().click()
const detailModal = page.getByRole('dialog', { name: 'Editar atividade' })
await expect(detailModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
await detailModal.getByRole('link', { name: 'Ver detalhes' }).click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
// The location shows in the overview's own <dd> (exact); the chrome
// subtitle carries "município · local" as one string.
await expect(page.getByText(newLocality, { exact: true })).toBeVisible()
})
test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({
@ -447,17 +486,7 @@ test.describe('Atividades — agenda mobile (C103)', () => {
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
// FullCalendar renders the grid lazily and re-lays out on mount: wait for
// the load to finish before interacting (same pattern as the desktop test).
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
// Click the time slot itself — C104's all-day lane sits above the timed
// grid, so the old day-cell nth() offset no longer lands on the slot.
// `force` bypasses the timed-row content overlay; the FullCalendar grid
// still receives the click and resolves the 14:00 date from the position.
await slotLocator.scrollIntoViewIfNeeded()
await slotLocator.click({ force: true })
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
// C103 — the sheet opens from the TOP, hugging the usable viewport edge.
const sheet = page.getByRole('dialog', { name: /Nova atividade/ })
@ -475,11 +504,15 @@ test.describe('Atividades — agenda mobile (C103)', () => {
// viewport — the acceptance center of C103.
await expect(page.getByRole('button', { name: 'Salvar' })).toBeVisible()
// Date/time opens as a nested bottom sheet (no popover) with "Pronto".
await page.getByLabel('Início *').click()
// C123 — the time selects are INLINE in the sheet row (no nested sheet for
// the time); the calendar still opens as a nested bottom sheet with
// "Pronto" when the date trigger is tapped.
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
await page.getByRole('button', { name: 'Início *', exact: true }).click()
const picker = page.getByRole('dialog', { name: /Início —/ })
await expect(picker).toBeVisible()
await expect(picker.getByRole('combobox', { name: 'Hora' })).toBeVisible()
await expect(picker.getByRole('button', { name: 'Pronto' })).toBeVisible()
await picker.getByRole('button', { name: 'Pronto' }).click()
await expect(picker).toBeHidden()

View file

@ -12,6 +12,7 @@ import {
createActivityRecord,
createTourDraftActivitiesRecord,
loadActivityAgendaEventsRecord,
loadActivityEditDraftRecord,
rescheduleActivityRecord,
} from '@/app/(campaign)/campanha/actions/activity'
import { allDayEndInstant, allDayStartInstant } from '@/lib/activityAllDay'
@ -376,6 +377,41 @@ describe('activity domain', () => {
expect(result.docs.map((doc) => doc.id)).toEqual([visibleByResponsibility.id])
})
it('loads the edit draft only inside the actor scope (C123 overlay)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const otherAdvisor = await fixtures.createCampaignUser('advisor')
const municipality = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
// Far-future startAt: this spec is about read scoping, not the agenda
// window — and keeping the rows OUTSIDE the Google sync engine's
// 90/365-day scan window avoids feeding the C126 parallel-flake class.
const farFuture = new Date(Date.now() + 400 * 86_400_000).toISOString()
const inScope = await createActivityRecord(payload, coordinator, {
...validActivityInput(municipality.id),
title: fixtures.value('No portfólio do assessor'),
startAt: farFuture,
})
fixtures.own('activity', inScope.id)
const outOfScope = await createActivityRecord(payload, coordinator, {
...validActivityInput((await fixtures.getMunicipality()).id),
title: fixtures.value('Fora do portfólio do assessor'),
startAt: farFuture,
})
fixtures.own('activity', outOfScope.id)
const advisorDraft = await loadActivityEditDraftRecord(payload, advisor, inScope.id)
expect(advisorDraft.id).toBe(inScope.id)
expect(advisorDraft.title).toBe(inScope.title)
expect(advisorDraft.municipalityId).toBe(municipality.id)
await expect(loadActivityEditDraftRecord(payload, otherAdvisor, inScope.id)).rejects.toThrow()
await expect(loadActivityEditDraftRecord(payload, advisor, outOfScope.id)).rejects.toThrow()
})
it('lets a responsible advisor re-save an activity whose leadership they cannot read (C90 existence validation)', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')

View file

@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loadEvents: vi.fn(),
reschedule: vi.fn(),
createInline: vi.fn(),
createOverlay: vi.fn(),
updateOverlay: vi.fn(),
loadEditDraft: vi.fn(),
searchContacts: vi.fn(),
searchResponsibles: vi.fn(),
revertDrop: vi.fn(),
revertResize: vi.fn(),
@ -17,10 +20,13 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
loadActivityAgendaEvents: (...args: unknown[]) => mocks.loadEvents(...args),
rescheduleActivity: (...args: unknown[]) => mocks.reschedule(...args),
createActivityInline: (...args: unknown[]) => mocks.createInline(...args),
createActivityOverlay: (...args: unknown[]) => mocks.createOverlay(...args),
updateActivityOverlay: (...args: unknown[]) => mocks.updateOverlay(...args),
loadActivityEditDraft: (...args: unknown[]) => mocks.loadEditDraft(...args),
}))
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
searchActivityContactOptions: (query: string) => mocks.searchContacts(query),
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
}))
@ -38,6 +44,11 @@ type DateClickInfo = {
jsEvent: MouseEvent
}
type EventClickInfo = {
event: { id: string }
jsEvent: MouseEvent
}
type ScheduleMutationInfo = {
event: { id: string; startStr: string; endStr: string }
revert: () => void
@ -52,6 +63,7 @@ type CalendarProps = {
eventDrop: (info: ScheduleMutationInfo) => void
eventResize: (info: ScheduleMutationInfo) => void
dateClick: (info: DateClickInfo) => void
eventClick: (info: EventClickInfo) => void
}
vi.mock('@fullcalendar/react', () => {
@ -89,6 +101,18 @@ vi.mock('@fullcalendar/react', () => {
},
'Clicar slot',
),
createElement(
'button',
{
type: 'button',
onClick: () =>
props.eventClick({
event: { id: '7' },
jsEvent: new MouseEvent('click'),
}),
},
'Clicar evento',
),
createElement(
'button',
{ type: 'button', onClick: () => props.eventDrop({ event, revert: mocks.revertDrop }) },
@ -109,13 +133,19 @@ vi.mock('@fullcalendar/react', () => {
import { ActivityAgenda, tagsLabel } from '@/components/campaign/activity/ActivityAgenda'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import {
CampaignQuickActionContextProvider,
useCampaignQuickActionContext,
} from '@/components/campaign/shell/CampaignQuickActionContext'
import { stubMatchMedia } from '../helpers/matchMedia'
const renderAgenda = (props: { state?: object } = {}) =>
render(
<CampaignPageChromeProvider role="coordinator">
<ActivityAgenda state={props.state ?? {}} />
</CampaignPageChromeProvider>,
<CampaignQuickActionContextProvider>
<CampaignPageChromeProvider role="coordinator">
<ActivityAgenda state={props.state ?? {}} />
</CampaignPageChromeProvider>
</CampaignQuickActionContextProvider>,
)
describe('tagsLabel (C105)', () => {
@ -142,7 +172,7 @@ describe('ActivityAgenda schedule failures', () => {
stubMatchMedia()
// C104 — the toggle (radix Checkbox) measures its indicator via
// react-use-size, which needs ResizeObserver — same stub the
// inline-create spec uses. Stubbed per test because the afterEach below
// overlay spec uses. Stubbed per test because the afterEach below
// restores every stubGlobal.
vi.stubGlobal(
'ResizeObserver',
@ -188,7 +218,7 @@ describe('ActivityAgenda schedule failures', () => {
expect(mocks.toastError).toHaveBeenCalledWith('Remarcação recusada.')
})
it('abre a criação inline no clique de um slot vazio, sem navegar', async () => {
it('abre o overlay de criação no clique de um slot vazio, sem navegar', async () => {
mocks.loadEvents.mockResolvedValue([])
renderAgenda()
@ -197,20 +227,86 @@ describe('ActivityAgenda schedule failures', () => {
fireEvent.click(screen.getByRole('button', { name: 'Clicar slot' }))
// C123 — the slot click opens the central modal (dialog) with the split
// date/time controls prefilled from the slot.
expect(screen.getByRole('dialog', { name: 'Nova atividade' })).toBeTruthy()
const startTrigger = await screen.findByLabelText('Início *')
// C97 — the overlay trigger is a button showing the civil label, 24h.
expect(startTrigger.textContent).toBe('07/08/2026 às 13:00')
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
expect(startTrigger.textContent).toBe('07/08/2026')
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
expect(
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
).toBe('13')
expect(mocks.routerPush).not.toHaveBeenCalled()
})
it('abre o overlay de edição no clique de um evento e carrega o rascunho (C123)', async () => {
mocks.loadEvents.mockResolvedValue([])
mocks.loadEditDraft.mockResolvedValue({
id: 7,
title: 'Comício na feira',
slug: 'comicio-na-feira',
tags: [],
status: 'confirmado',
description: null,
deputyPresent: false,
allDay: false,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T17:00:00.000Z',
municipalityId: 12,
locality: null,
organizationIDs: [],
responsibles: [],
tasks: [],
})
renderAgenda()
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Clicar evento' }))
await waitFor(() => expect(mocks.loadEditDraft).toHaveBeenCalledWith(7))
expect(screen.getByRole('dialog', { name: 'Editar atividade' })).toBeTruthy()
await waitFor(() =>
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
'Comício na feira',
),
)
expect(screen.getByRole('link', { name: 'Ver detalhes' }).getAttribute('href')).toBe(
'/campanha/atividades/comicio-na-feira',
)
})
it('não exibe o aviso de janela vazia nem o botão de criação acima do calendário', async () => {
mocks.loadEvents.mockResolvedValue([])
renderAgenda()
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
expect(screen.queryByText('Nenhum compromisso nesta janela e neste filtro.')).toBeNull()
// The empty-state notice and the old inline create CTA were removed by
// C105/C91 respectively; the calendar shows FullCalendar's own text.
expect(screen.queryByRole('link', { name: 'Criar atividade' })).toBeNull()
})
it('registra a ponte de criação no contexto de quick actions (C123)', async () => {
mocks.loadEvents.mockResolvedValue([])
let captured: (() => void) | undefined
const Probe = () => {
const { context } = useCampaignQuickActionContext()
captured = context.openActivityCreate
return null
}
render(
<CampaignQuickActionContextProvider>
<CampaignPageChromeProvider role="coordinator">
<Probe />
<ActivityAgenda state={{}} />
</CampaignPageChromeProvider>
</CampaignQuickActionContextProvider>,
)
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
await waitFor(() => expect(captured).toBeDefined())
captured!()
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Nova atividade' })).toBeTruthy())
})
})

View file

@ -1,316 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
createInline: vi.fn(),
searchResponsibles: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
createActivityInline: (...args: unknown[]) => mocks.createInline(...args),
}))
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}))
import {
ActivityInlineCreate,
type ActivityInlineCreateDraft,
} from '@/components/campaign/activity/ActivityInlineCreate'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import type { ActivityAgendaState } from '@/utilities/activityUi'
const DRAFT: ActivityInlineCreateDraft = {
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
anchor: { x: 100, y: 200 },
}
const MUNICIPALITIES: RelationOption[] = [
{ id: 12, name: 'Camaçari' },
{ id: 13, name: 'Ilhéus' },
]
const renderOverlay = ({
draft = DRAFT,
agendaState = {},
isNarrow = false,
knownTags = [],
onCreated = vi.fn(),
onClose = vi.fn(),
}: {
draft?: ActivityInlineCreateDraft | null
agendaState?: ActivityAgendaState
isNarrow?: boolean
knownTags?: string[]
onCreated?: () => void
onClose?: () => void
} = {}) =>
render(
<ActivityInlineCreate
draft={draft}
isNarrow={isNarrow}
agendaState={agendaState}
municipalityOptions={MUNICIPALITIES}
knownTags={knownTags}
onClose={onClose}
onCreated={onCreated}
/>,
)
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('ActivityInlineCreate — overlay de criação inline', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('preenche horários do slot (24h) e usa o município do filtro ativo', () => {
renderOverlay({ agendaState: { municipality: 12 } })
// C97 — the native datetime-local is gone: the trigger button shows the
// civil label, 24h by construction (no browser locale can inject AM/PM).
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
})
it('troca dia no calendário e hora/minuto nos passos, sem perder o outro eixo', () => {
renderOverlay({ agendaState: { municipality: 12 } })
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const picker = within(dialogs[dialogs.length - 1])
fireEvent.click(picker.getByRole('button', { name: /15 de agosto de 2026/ }))
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 13:00')
fireEvent.change(picker.getByRole('combobox', { name: 'Hora' }), {
target: { value: '09' },
})
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:00')
fireEvent.change(picker.getByRole('combobox', { name: 'Minuto' }), {
target: { value: '45' },
})
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:45')
})
it('cria sem navegar e avisa o pai para recarregar a janela', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.change(screen.getByLabelText('Local (opcional)'), {
target: { value: 'Centro histórico' },
})
fireEvent.click(screen.getByLabelText('Término'))
const dialogs = screen.getAllByRole('dialog')
const endPicker = within(dialogs[dialogs.length - 1])
fireEvent.change(endPicker.getByRole('combobox', { name: 'Minuto' }), {
target: { value: '45' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Café com apoiadores',
municipality: 12,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:45:00.000Z',
locality: 'Centro histórico',
})
expect(onCreated).toHaveBeenCalledTimes(1)
})
it('bloqueia salvar sem município e não invoca a ação', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: {}, onCreated })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy())
expect(mocks.createInline).not.toHaveBeenCalled()
expect(onCreated).not.toHaveBeenCalled()
})
it('mantém o overlay aberto com erro inline quando a criação falha', async () => {
const onCreated = vi.fn()
const message = 'Já existe uma atividade com este título.'
mocks.createInline.mockResolvedValue({
ok: false,
message,
fieldErrors: { title: [message] },
})
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
await waitFor(() => expect(screen.getAllByText(message).length).toBeGreaterThan(0))
expect(onCreated).not.toHaveBeenCalled()
})
it('monta o href de "Mais detalhes" com município e título digitados', () => {
renderOverlay({ agendaState: { municipality: 12 } })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
const link = screen.getByRole('link', { name: 'Mais detalhes' })
expect(link.getAttribute('href')).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T16%3A00%3A00.000Z&endAt=2026-08-07T16%3A30%3A00.000Z&municipality=12&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('adiciona tags por Enter e as envia ao salvar (C105)', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
expect(screen.getByLabelText('Remover tag Panfletagem')).toBeTruthy()
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Panfletagem no centro' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Panfletagem no centro',
municipality: 12,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
tags: ['Panfletagem'],
})
expect(onCreated).toHaveBeenCalledTimes(1)
})
it('deduplica tags e permite remover um chip antes de salvar (C105)', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
for (const tag of ['Comício', 'Comício', 'Caminhada']) {
fireEvent.change(tagInput, { target: { value: tag } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
}
expect(screen.getByLabelText('Remover tag Comício')).toBeTruthy()
expect(screen.getByLabelText('Remover tag Caminhada')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Remover tag Comício'))
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Comício na feira' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Comício na feira',
municipality: 12,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
tags: ['Caminhada'],
})
})
it('oferece as tags já usadas como sugestões e as carrega no href de detalhes (C105)', () => {
renderOverlay({
agendaState: { municipality: 12 },
knownTags: ['Comício', 'Imprensa'],
})
// The overlay renders inside a popover portal, so the datalist lives on
// document.body, not in the container returned by render; the suggestion
// values are `value` attributes, not option text.
const datalist = document.querySelector('datalist')
const suggestionValues = [...(datalist?.querySelectorAll('option') ?? [])].map((option) =>
option.getAttribute('value'),
)
expect(suggestionValues).toContain('Comício')
expect(suggestionValues).toContain('Imprensa')
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
const link = screen.getByRole('link', { name: 'Mais detalhes' })
expect(link.getAttribute('href')).toContain('&tags=Panfletagem')
})
it('não renderiza nada sem um rascunho de slot', () => {
const { container } = renderOverlay({ draft: null })
expect(container.firstChild).toBeNull()
})
it('renderiza o sheet do topo mobile com labels ocultos e rodapé fixo', () => {
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
// C103 — labels stay in the a11y tree (sr-only), placeholders give context
// and required fields keep the asterisk.
expect(screen.getByLabelText('Título *')).toBeTruthy()
expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
'Adicionar título *',
)
expect(screen.getByLabelText('Início *').textContent).toMatch(/07\/08\/2026 às 13:00/)
expect(screen.getByLabelText('Início *').textContent).toContain('*')
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
'Município *',
)
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
'Local (opcional)',
)
expect(
screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
})
it('abre o seletor de data/hora como bottom sheet no mobile, com Pronto', () => {
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const picker = within(dialogs[dialogs.length - 1])
expect(picker.getByRole('combobox', { name: 'Hora' })).toBeTruthy()
expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
})
})

View file

@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE,
mapActivityInlineCreateError,
} from '@/utilities/activityInlineErrors'
describe('activity inline create error mapping', () => {
it('surfaces a DB unique-title violation as a title field error', () => {
expect(
mapActivityInlineCreateError(
new Error('duplicate key value violates unique constraint "activity_slug_unique"'),
),
).toEqual({
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
})
expect(mapActivityInlineCreateError(new Error('já existe uma atividade'))).toEqual({
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
})
})
it('collapses every other failure to the generic message', () => {
const generic = { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE }
expect(mapActivityInlineCreateError(new Error('Boom'))).toEqual(generic)
expect(mapActivityInlineCreateError('string')).toEqual(generic)
expect(mapActivityInlineCreateError(undefined)).toEqual(generic)
})
})

View file

@ -0,0 +1,443 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
createOverlay: vi.fn(),
updateOverlay: vi.fn(),
loadEditDraft: vi.fn(),
searchContacts: vi.fn(),
searchResponsibles: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
createActivityOverlay: (...args: unknown[]) => mocks.createOverlay(...args),
updateActivityOverlay: (...args: unknown[]) => mocks.updateOverlay(...args),
loadActivityEditDraft: (...args: unknown[]) => mocks.loadEditDraft(...args),
}))
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
searchActivityContactOptions: (query: string) => mocks.searchContacts(query),
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}))
import {
ActivityOverlay,
type ActivityOverlayRequest,
} from '@/components/campaign/activity/ActivityOverlay'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import type { ActivityAgendaState } from '@/utilities/activityUi'
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
const CREATE_REQUEST: ActivityOverlayRequest = {
kind: 'create',
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T16:30:00.000Z',
}
const EDIT_VIEW_MODEL: ActivityFormViewModel = {
id: 42,
title: 'Comício na feira',
slug: 'comicio-na-feira',
tags: ['Comício'],
status: 'confirmado',
description: 'Panfletagem no centro histórico',
deputyPresent: true,
allDay: false,
startAt: '2026-08-07T16:00:00.000Z',
endAt: '2026-08-07T17:00:00.000Z',
municipalityId: 12,
locality: 'Centro histórico',
organizationIDs: [7],
responsibles: [],
tasks: [],
}
const MUNICIPALITIES: RelationOption[] = [
{ id: 12, name: 'Camaçari' },
{ id: 13, name: 'Ilhéus' },
]
const ORGANIZATIONS: RelationOption[] = [{ id: 7, name: 'Sindmed' }]
const renderOverlay = ({
request = CREATE_REQUEST,
agendaState = {},
isNarrow = false,
knownTags = [],
onSaved = vi.fn(),
onClose = vi.fn(),
}: {
request?: ActivityOverlayRequest | null
agendaState?: ActivityAgendaState
isNarrow?: boolean
knownTags?: string[]
onSaved?: () => void
onClose?: () => void
} = {}) =>
render(
<ActivityOverlay
request={request}
isNarrow={isNarrow}
agendaState={agendaState}
municipalityOptions={MUNICIPALITIES}
organizationOptions={ORGANIZATIONS}
knownTags={knownTags}
onClose={onClose}
onSaved={onSaved}
/>,
)
const submittedFormData = (): FormData => {
const calls = mocks.createOverlay.mock.calls as unknown[][]
expect(calls.length).toBeGreaterThan(0)
return calls[0][0] as FormData
}
/**
* Opens the calendar picker of a date trigger and returns the just-opened
* dialog: the overlay dialog itself is a `role=dialog` too, so the picker is
* always the LAST one in the tree (Radix portals mount in order).
*/
const openDatePicker = (label: string) => {
fireEvent.click(screen.getByLabelText(label))
return within(screen.getAllByRole('dialog').at(-1)!)
}
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterAll(() => {
vi.unstubAllGlobals()
})
describe('ActivityOverlay — criação (modal central)', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('preenche horários do slot (24h), usa o município do filtro ativo e mostra cada seção uma vez', () => {
renderOverlay({ agendaState: { municipality: 12 } })
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
expect(
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
).toBe('13')
expect(
(screen.getByRole('combobox', { name: 'Minuto de Início' }) as HTMLSelectElement).value,
).toBe('00')
expect(
(screen.getByRole('combobox', { name: 'Hora de Término' }) as HTMLSelectElement).value,
).toBe('13')
expect(
(screen.getByRole('combobox', { name: 'Minuto de Término' }) as HTMLSelectElement).value,
).toBe('30')
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
// C123 — "Local" aparece uma única vez no overlay.
expect(screen.getAllByLabelText('Local (opcional)')).toHaveLength(1)
// Todas as seções do formulário completo estão no overlay.
expect(screen.getByText('Informações básicas')).toBeTruthy()
expect(screen.getByText('Data e horário')).toBeTruthy()
expect(screen.getByText('Onde')).toBeTruthy()
expect(screen.getByText('Pessoas e organizações')).toBeTruthy()
// 'Tarefas' também é o label do campo de tarefas — card e campo convivem.
expect(screen.getAllByText('Tarefas')).toHaveLength(2)
expect(screen.getByText('Demandas')).toBeTruthy()
expect(screen.getByLabelText('Descrição')).toBeTruthy()
expect(screen.getByLabelText('Deputado presente')).toBeTruthy()
expect(screen.getByRole('combobox', { name: /Organizações apoiadoras/ })).toBeTruthy()
})
it('troca dia no calendário e hora/minuto nos selects inline, sem perder o outro eixo', () => {
renderOverlay({ agendaState: { municipality: 12 } })
const picker = openDatePicker('Início *')
fireEvent.click(picker.getByRole('button', { name: /15 de agosto de 2026/ }))
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026')
fireEvent.change(screen.getByRole('combobox', { name: 'Hora de Início' }), {
target: { value: '09' },
})
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Início' }), {
target: { value: '45' },
})
const endPicker = openDatePicker('Término')
fireEvent.click(endPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
fireEvent.change(screen.getByRole('combobox', { name: 'Hora de Término' }), {
target: { value: '16' },
})
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
const formData = submittedFormData()
expect(formData.get('startAt')).toBe('2026-08-15T09:45')
// O minuto do término preserva o prefill (30) — só a hora mudou.
expect(formData.get('endAt')).toBe('2026-08-15T16:30')
}, 20_000) // worker load the Radix close animations can push past the 5s default. // Two calendar popovers open in sequence (start then end); under parallel
it('cria sem navegar, submete todas as seções e avisa o pai para recarregar', async () => {
const onSaved = vi.fn()
mocks.createOverlay.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.change(screen.getByLabelText('Local (opcional)'), {
target: { value: 'Centro histórico' },
})
fireEvent.change(screen.getByLabelText('Descrição'), {
target: { value: 'Café da manhã com lideranças' },
})
fireEvent.click(screen.getByLabelText('Deputado presente'))
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Término' }), {
target: { value: '45' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createOverlay).toHaveBeenCalledTimes(1))
const formData = submittedFormData()
expect(formData.get('title')).toBe('Café com apoiadores')
expect(formData.get('municipality')).toBe('12')
expect(formData.get('startAt')).toBe('2026-08-07T13:00')
expect(formData.get('endAt')).toBe('2026-08-07T13:45')
expect(formData.get('locality')).toBe('Centro histórico')
expect(formData.get('description')).toBe('Café da manhã com lideranças')
expect(formData.get('deputyPresent')).toBe('on')
expect(formData.get('status')).toBe('confirmado')
expect(formData.get('allDay')).toBeNull()
expect(formData.get('id')).toBeNull()
expect(onSaved).toHaveBeenCalledTimes(1)
})
it('bloqueia salvar sem município e não invoca a ação', async () => {
const onSaved = vi.fn()
mocks.createOverlay.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: {}, onSaved })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy())
expect(mocks.createOverlay).not.toHaveBeenCalled()
expect(onSaved).not.toHaveBeenCalled()
})
it('mantém o overlay aberto com erro inline quando a criação falha', async () => {
const onSaved = vi.fn()
const message = 'Já existe uma atividade com este título.'
mocks.createOverlay.mockResolvedValue({
ok: false,
message,
fieldErrors: { title: [message] },
})
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
await waitFor(() => expect(screen.getAllByText(message).length).toBeGreaterThan(0))
expect(onSaved).not.toHaveBeenCalled()
})
it('adiciona tags por Enter e as envia ao salvar (C105)', async () => {
const onSaved = vi.fn()
mocks.createOverlay.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
expect(screen.getByLabelText('Remover tag Panfletagem')).toBeTruthy()
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Panfletagem no centro' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createOverlay).toHaveBeenCalledTimes(1))
expect(JSON.parse(submittedFormData().get('tagsJson') as string)).toEqual(['Panfletagem'])
expect(onSaved).toHaveBeenCalledTimes(1)
})
it('oferece as tags já usadas como sugestões (C105)', () => {
renderOverlay({ agendaState: { municipality: 12 }, knownTags: ['Comício', 'Imprensa'] })
const datalist = document.querySelector('datalist')
const suggestionValues = [...(datalist?.querySelectorAll('option') ?? [])].map((option) =>
option.getAttribute('value'),
)
expect(suggestionValues).toContain('Comício')
expect(suggestionValues).toContain('Imprensa')
})
it('não renderiza nada sem um pedido de abertura', () => {
const { container } = renderOverlay({ request: null })
expect(container.firstChild).toBeNull()
})
it('fecha pelo botão Cancelar sem salvar', () => {
const onClose = vi.fn()
renderOverlay({ agendaState: { municipality: 12 }, onClose })
fireEvent.click(screen.getByRole('button', { name: 'Cancelar' }))
expect(onClose).toHaveBeenCalledTimes(1)
expect(mocks.createOverlay).not.toHaveBeenCalled()
})
})
describe('ActivityOverlay — edição (modal central)', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('carrega o view model, pré-preenche tudo, trava o título e envia o id', async () => {
const onSaved = vi.fn()
mocks.loadEditDraft.mockResolvedValue(EDIT_VIEW_MODEL)
mocks.updateOverlay.mockResolvedValue({ ok: true })
renderOverlay({ request: { kind: 'edit', activityId: 42 }, onSaved })
await waitFor(() =>
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
'Comício na feira',
),
)
expect(screen.getByRole('heading', { name: 'Editar atividade' })).toBeTruthy()
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
expect(
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
).toBe('13')
expect(
(screen.getByRole('combobox', { name: 'Hora de Término' }) as HTMLSelectElement).value,
).toBe('14')
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).value).toBe(
'Centro histórico',
)
expect((screen.getByLabelText('Descrição') as HTMLInputElement).value).toBe(
'Panfletagem no centro histórico',
)
expect((screen.getByLabelText('Título *') as HTMLInputElement).readOnly).toBe(true)
expect(screen.getByText('Comício')).toBeTruthy()
expect(screen.getByRole('link', { name: 'Ver detalhes' }).getAttribute('href')).toBe(
'/campanha/atividades/comicio-na-feira',
)
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Término' }), {
target: { value: '30' },
})
fireEvent.click(screen.getByRole('button', { name: 'Salvar alterações' }))
await waitFor(() => expect(mocks.updateOverlay).toHaveBeenCalledTimes(1))
const formData = mocks.updateOverlay.mock.calls[0][0] as FormData
expect(formData.get('id')).toBe('42')
expect(formData.get('title')).toBe('Comício na feira')
expect(formData.get('startAt')).toBe('2026-08-07T13:00')
expect(formData.get('endAt')).toBe('2026-08-07T14:30')
expect(onSaved).toHaveBeenCalledTimes(1)
})
it('mostra estado de carregamento e depois o formulário', async () => {
let resolveDraft: (value: ActivityFormViewModel) => void = () => undefined
mocks.loadEditDraft.mockImplementation(
() =>
new Promise<ActivityFormViewModel>((resolve) => {
resolveDraft = resolve
}),
)
renderOverlay({ request: { kind: 'edit', activityId: 42 } })
expect(screen.getByText('Carregando compromisso…')).toBeTruthy()
resolveDraft(EDIT_VIEW_MODEL)
await waitFor(() =>
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
'Comício na feira',
),
)
})
it('mostra erro quando o rascunho não carrega e fecha pelo botão', async () => {
const onClose = vi.fn()
mocks.loadEditDraft.mockRejectedValue(new Error('Boom'))
renderOverlay({ request: { kind: 'edit', activityId: 42 }, onClose })
await waitFor(() =>
expect(
screen.getByText('Não foi possível carregar este compromisso. Tente novamente.'),
).toBeTruthy(),
)
fireEvent.click(screen.getByRole('button', { name: 'Fechar' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
describe('ActivityOverlay — sheet mobile (C103)', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renderiza o sheet do topo com labels ocultos, hora inline e rodapé fixo', () => {
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
expect(screen.getByLabelText('Título *')).toBeTruthy()
expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
'Adicionar título *',
)
expect(screen.getByLabelText('Início *').textContent).toMatch(/^07\/08\/2026\*/)
expect(
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
).toBe('13')
expect(
(screen.getByRole('combobox', { name: 'Minuto de Início' }) as HTMLSelectElement).value,
).toBe('00')
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
'Município *',
)
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
'Local (opcional)',
)
expect(
screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
})
it('abre o calendário como bottom sheet aninhado com Pronto; a hora fica inline', () => {
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const picker = within(dialogs[dialogs.length - 1])
expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
})
})

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
} from '@/lib/activityOverlayMessages'
import { FormDataBoundaryError } from '@/lib/formData'
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
describe('activity overlay error mapping', () => {
it('surfaces a DB unique-title violation as a title field error', () => {
expect(
mapActivityOverlayError(
new Error('duplicate key value violates unique constraint "activity_slug_unique"'),
),
).toEqual({
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
})
expect(mapActivityOverlayError(new Error('já existe uma atividade'))).toEqual({
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
})
})
it('surfaces a DB unique-violation of a linked demand as a demandsJson field error', () => {
expect(
mapActivityOverlayError(
new Error('duplicate key value violates unique constraint "campaign_demand_slug_unique"'),
),
).toEqual({
message: ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
fieldErrors: { demandsJson: [ACTIVITY_DEMAND_DUPLICATE_MESSAGE] },
})
})
it('maps FormDataBoundaryError to the named field', () => {
expect(mapActivityOverlayError(new FormDataBoundaryError('startAt', 'Data inválida.'))).toEqual(
{
message: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
fieldErrors: { startAt: ['Data inválida.'] },
},
)
})
it('collapses every other failure to the generic message', () => {
const generic = { message: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE }
expect(mapActivityOverlayError(new Error('Boom'))).toEqual(generic)
expect(mapActivityOverlayError('string')).toEqual(generic)
expect(mapActivityOverlayError(undefined)).toEqual(generic)
})
})

View file

@ -4,19 +4,17 @@ import {
activityAgendaViewFcId,
activityAgendaViewLabels,
activityAgendaViews,
activityDefaultCreatePrefill,
activitySlotPrefill,
activityTabs,
buildActivityAgendaHref,
buildActivityAgendaSearchParams,
buildActivityAgendaWhere,
buildActivityCreateHref,
buildActivityFiltersKey,
buildActivityListHref,
buildActivityListSearchParams,
buildActivityListWhere,
parseActivityAgendaParams,
parseActivityAgendaReturnHref,
parseActivityCreatePrefill,
parseActivityListParams,
resolveActivityAgendaUrl,
resolveActivityListUrl,
@ -261,19 +259,6 @@ describe('activity agenda view modes (C95)', () => {
restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()),
).toEqual({ view: 'list' })
})
it('round-trips the view through the create return href', () => {
expect(
parseActivityAgendaReturnHref(
'/campanha/agenda?municipality=12&view=month',
new Set([12]),
new Set(),
),
).toBe('/campanha/agenda?municipality=12&view=month')
expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe(
'/campanha/agenda',
)
})
})
describe('buildActivityAgendaWhere', () => {
@ -306,215 +291,6 @@ describe('buildActivityAgendaWhere', () => {
})
})
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' })
})
it('carries the inline title into the creation URL (C91)', () => {
expect(
buildActivityCreateHref(
{ tag: 'Comício' },
{
startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T13:30:00.000Z',
municipalityId: 3,
title: 'Café com apoiadores',
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T13%3A30%3A00.000Z&municipality=3&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Ftag%3DCom%25C3%25ADcio',
)
})
it('prefers the inline municipality over the agenda filter', () => {
expect(buildActivityCreateHref({ municipality: 12 }, { municipalityId: 3 })).toBe(
'/campanha/atividades/nova?municipality=3&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('parses a bounded inline title and drops an oversized one', () => {
expect(parseActivityCreatePrefill({ title: ' Café com apoiadores ' }, new Set())).toEqual({
title: 'Café com apoiadores',
})
expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({})
})
it('carries inline tags into the creation URL as repeated params (C105)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
startAt: '2026-08-07T13:00:00.000Z',
municipalityId: 12,
title: 'Panfletagem',
tags: ['Panfletagem', 'Caminhada, café'],
},
),
).toBe(
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&municipality=12&title=Panfletagem&tags=Panfletagem&tags=Caminhada%2C+caf%C3%A9&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('round-trips inline tags through the create prefill (C105)', () => {
expect(
parseActivityCreatePrefill(
{ tags: [' Panfletagem ', 'Caminhada, café', 'Panfletagem'] },
new Set(),
),
).toEqual({ tags: ['Panfletagem', 'Caminhada, café'] })
})
it('drops oversized and out-of-bound tags from the prefill (C105)', () => {
const oversized = 'a'.repeat(81)
expect(parseActivityCreatePrefill({ tags: [oversized, ' '] }, new Set())).toEqual({})
expect(parseActivityCreatePrefill({ tags: ['válida', oversized] }, new Set())).toEqual({
tags: ['válida'],
})
})
it('carries the all-day choice as civil dates into the creation URL (C104)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
allDay: true,
startAt: '2026-08-10',
endAt: '2026-08-12',
municipalityId: 12,
},
),
).toBe(
'/campanha/atividades/nova?allDay=1&startAt=2026-08-10&endAt=2026-08-12&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('parses an all-day prefill into day-boundary instants', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-12',
municipality: '12',
},
new Set([12]),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
municipalityId: 12,
})
})
it('allows a single-day all-day prefill (end equals start)', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
})
})
it('drops an inverted all-day end and malformed all-day dates', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-12',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({ allDay: true, startAt: '2026-08-12T03:00:00.000Z' })
expect(
parseActivityCreatePrefill(
{ allDay: '1', startAt: '10/08/2026', endAt: '2026-08-10' },
new Set(),
),
).toEqual({ allDay: true, endAt: '2026-08-10T03:00:00.000Z' })
expect(parseActivityCreatePrefill({ allDay: '0' }, new Set())).toEqual({})
})
})
describe('activity slot prefill (C91)', () => {
it('uses the snapped slot interval for time grids', () => {
expect(activitySlotPrefill({ allDay: false, dateStr: '2026-08-07T13:00:00-03:00' })).toEqual({
@ -534,4 +310,11 @@ describe('activity slot prefill (C91)', () => {
expect(activitySlotPrefill({ allDay: false, dateStr: 'não é uma data' })).toBeNull()
expect(activitySlotPrefill({ allDay: true, dateStr: 'não é uma data' })).toBeNull()
})
it('defaults to today 09:00–10:00 for overlay hosts without a slot (C123)', () => {
expect(activityDefaultCreatePrefill(new Date('2026-08-11T18:00:00.000Z'))).toEqual({
startAt: '2026-08-11T12:00:00.000Z',
endAt: '2026-08-11T13:00:00.000Z',
})
})
})

View file

@ -28,7 +28,6 @@ import {
import {
ACTIVITY_DETAIL_ROOT_PATH,
ACTIVITY_LIST_PATH,
ACTIVITY_NEW_PATH,
ACTIVITY_TOUR_COMPOSER_PATH,
ORGANIZATIONS_LIST_PATH,
ORGANIZATION_NEW_PATH,
@ -116,7 +115,7 @@ describe('campaignQuickActionPaths (activities)', () => {
})
it('ignores nova, giros and nested edit routes', () => {
expect(parseActivityQuickActionSurface(ACTIVITY_NEW_PATH)).toBeNull()
expect(parseActivityQuickActionSurface('/campanha/atividades/nova')).toBeNull()
expect(parseActivityQuickActionSurface(ACTIVITY_TOUR_COMPOSER_PATH)).toBeNull()
expect(parseActivityQuickActionSurface('/campanha/atividades/foo/editar')).toBeNull()
})
@ -190,9 +189,20 @@ describe('organizationQuickActions (B88)', () => {
describe('activityQuickActions (B84)', () => {
it('lists vertical verbs on the activity list', () => {
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {})
// C123 — "Nova atividade" opens the agenda overlay via the page-bridged
// context; without a bridge the entry is omitted (never a dead /nova href).
expect(actions.map((action) => action.id)).toEqual(['plan-tour'])
expect(actions[0]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
})
it('bridges "Nova atividade" to the overlay when the context is registered', () => {
const openActivityCreate = () => undefined
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {
openActivityCreate,
})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
expect(actions[0]?.href).toBe(ACTIVITY_NEW_PATH)
expect(actions[1]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
expect(actions[0]?.href).toBeUndefined()
expect(actions[0]?.onAction).toBe(openActivityCreate)
})
it('prefills wizards and detail shortcuts on activity detail', () => {
@ -208,14 +218,25 @@ describe('activityQuickActions (B84)', () => {
.find((action) => action.id === 'update-votes')
?.href?.includes('municipio=feira-de-santana'),
).toBe(true)
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBe(
'/campanha/atividades/comicio-feira/editar',
)
// C123 — "Editar" is bridged by the detail page; without the bridge it is
// omitted, never a dead /editar href.
expect(actions.find((action) => action.id === 'edit-activity')).toBeUndefined()
expect(actions.find((action) => action.id === 'activity-tasks')?.href).toBe(
'/campanha/atividades/comicio-feira?tab=tasks',
)
})
it('bridges "Editar" to the overlay when the context is registered', () => {
const openActivityEdit = () => undefined
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'panfletagem' },
'advisor',
{ activitySlug: 'panfletagem', openActivityEdit },
)
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBeUndefined()
expect(actions.find((action) => action.id === 'edit-activity')?.onAction).toBe(openActivityEdit)
})
it('omits wizard prefills without municipality context', () => {
const actions = resolveActivityQuickActions(
{ kind: 'detail', activitySlug: 'panfletagem' },
@ -224,11 +245,7 @@ describe('activityQuickActions (B84)', () => {
)
expect(actions.some((action) => action.id === 'register-demand')).toBe(false)
expect(actions.map((action) => action.id)).toEqual([
'edit-activity',
'activity-tasks',
'activity-updates',
])
expect(actions.map((action) => action.id)).toEqual(['activity-tasks', 'activity-updates'])
})
})
@ -318,7 +335,13 @@ describe('campaignQuickActionRegistry', () => {
})
it('delegates activity routes to the B84 catalog', () => {
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {})
// C123 — "Nova atividade" only appears once the agenda bridge registers.
expect(
resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {}).map((action) => action.id),
).toEqual(['plan-tour'])
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {
openActivityCreate: () => undefined,
})
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
})

View file

@ -151,12 +151,8 @@ describe('campaign formActions convention', () => {
// signal — presence of the mapper is. A file that truly cannot use the wrappers
// gets an allowlist entry documenting why:
const allowlist = new Map<string, string>([
// Custom unique-violation → fieldErrors + async duplicate-title fallback
// that links to the existing activity — policy the wrappers don't grow for.
[
'src/app/(campaign)/campanha/(app)/atividades/formActions.ts',
'unique-violation fieldErrors + async duplicate-title fallback',
],
// C123 — the activities form pages died with the overlay; the mapper moved
// to `src/utilities/activityOverlayErrors.ts` (module, not a route ladder).
// Flattens field errors into message-only states for inline detail controls.
[
'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts',
@ -395,7 +391,7 @@ describe('src/utilities top-level is pinned', () => {
'activityDetailTabUi.ts',
'activityAgendaOmnibox.ts',
'activityFormData.ts',
'activityInlineErrors.ts',
'activityOverlayErrors.ts',
'activityOmnibox.ts',
'activityLeadershipOptions.ts',
'activityPageData.ts',