diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index b0d16a74..40be08ed 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -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 `` 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).
diff --git a/docs/plans/agenda-criacao-modal-central-impl.md b/docs/plans/agenda-criacao-modal-central-impl.md
new file mode 100644
index 00000000..917e1892
--- /dev/null
+++ b/docs/plans/agenda-criacao-modal-central-impl.md
@@ -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 `` 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 (Dialog desktop / Drawer mobile)"]
+ req2 --> overlay
+ overlay -->|edit: busca| draft["loadActivityEditDraft (action, activityFormSelect, overrideAccess:false)"]
+ overlay -->|create/edit| form["ActivityOverlayForm (todas as seções do ActivityForm)"]
+ form -->|FormData| create["createActivityOverlay / updateActivityOverlay (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 `` — 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)
diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx
index 20a3f1f4..3e7d819e 100644
--- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx
@@ -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) {
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx
deleted file mode 100644
index 14a275fd..00000000
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- )
-}
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
index 8599adaf..18fadcfb 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
@@ -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. */}
{isStaff ? (
-
+
) : null}
-
+
) : null}
diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts
index 299133e0..76347934 100644
--- a/src/app/(campaign)/campanha/actions/activity.ts
+++ b/src/app/(campaign)/campanha/actions/activity.ts
@@ -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 }
/**
- * 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 => {
+export const createActivityOverlay = async (formData: FormData): Promise => {
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 => {
+ 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) => {
diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx
index 268c9b96..c62c29ae 100644
--- a/src/components/campaign/activity/ActivityAgenda.tsx
+++ b/src/components/campaign/activity/ActivityAgenda.tsx
@@ -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(null)
@@ -180,7 +185,7 @@ export const ActivityAgenda = ({
const [savingCount, setSavingCount] = useState(0)
const [loadError, setLoadError] = useState(null)
const [isNarrow, setIsNarrow] = useState(false)
- const [createDraft, setCreateDraft] = useState(null)
+ const [overlayRequest, setOverlayRequest] = useState(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}
- setCreateDraft(null)}
- onCreated={() => {
- setCreateDraft(null)
+ onClose={() => setOverlayRequest(null)}
+ onSaved={() => {
+ setOverlayRequest(null)
setReloadCount((count) => count + 1)
}}
/>
diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx
index 1c807a83..edc13cac 100644
--- a/src/components/campaign/activity/ActivityAgendaFilters.tsx
+++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx
@@ -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 = ({
}
diff --git a/src/components/campaign/activity/ActivityCreateOverlayHost.tsx b/src/components/campaign/activity/ActivityCreateOverlayHost.tsx
new file mode 100644
index 00000000..637e4ed3
--- /dev/null
+++ b/src/components/campaign/activity/ActivityCreateOverlayHost.tsx
@@ -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(null)
+
+ const openCreate = useCallback(() => {
+ const prefill = activityDefaultCreatePrefill()
+ if (!prefill) return
+ setRequest({ kind: 'create', ...prefill })
+ }, [])
+ useBridgedQuickAction('openActivityCreate', openCreate)
+
+ return (
+ <>
+
+ setRequest(null)}
+ onSaved={() => {
+ setRequest(null)
+ router.refresh()
+ }}
+ />
+ >
+ )
+}
diff --git a/src/components/campaign/activity/ActivityDateTimeField.tsx b/src/components/campaign/activity/ActivityDateTimeField.tsx
index 9a3ab854..e8dd492d 100644
--- a/src/components/campaign/activity/ActivityDateTimeField.tsx
+++ b/src/components/campaign/activity/ActivityDateTimeField.tsx
@@ -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 = (
)
- const pickerContent = (
- <>
- {selectedInstant ? (
-
- ) : null}
- {timeVisible ? (
-