From db8102b47e4b60f8c1b6c73385400bdd0b17f4a2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 22:43:25 -0300 Subject: [PATCH] =?UTF-8?q?feat(C123):=20agenda=20=E2=80=94=20overlay=20mo?= =?UTF-8?q?dal=20central=20como=20=C3=BAnica=20superf=C3=ADcie=20de=20cria?= =?UTF-8?q?=C3=A7=C3=A3o/edi=C3=A7=C3=A3o=20de=20atividade=20(Closes=20#66?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 1 + .../agenda-criacao-modal-central-impl.md | 127 ++++ .../(campaign)/campanha/(app)/agenda/page.tsx | 20 +- .../(app)/atividades/[slug]/editar/page.tsx | 72 -- .../campanha/(app)/atividades/[slug]/page.tsx | 28 +- .../campanha/(app)/atividades/formActions.ts | 108 --- .../campanha/(app)/atividades/nova/page.tsx | 61 -- .../campanha/(app)/atividades/page.tsx | 33 +- .../(campaign)/campanha/actions/activity.ts | 78 ++- .../campaign/activity/ActivityAgenda.tsx | 61 +- .../activity/ActivityAgendaFilters.tsx | 15 +- .../activity/ActivityCreateOverlayHost.tsx | 66 ++ .../activity/ActivityDateTimeField.tsx | 221 +++--- .../activity/ActivityEditOverlayHost.tsx | 60 ++ .../campaign/activity/ActivityForm.tsx | 405 ----------- .../activity/ActivityInlineCreate.tsx | 527 -------------- .../campaign/activity/ActivityOverlay.tsx | 659 ++++++++++++++++++ .../campaign/activity/AgendaFeedChrome.tsx | 12 +- .../activity/AgendaGoogleSyncChrome.tsx | 10 +- .../shell/CampaignQuickActionContext.tsx | 22 + src/lib/activityOverlayMessages.ts | 11 + src/lib/activityQuickActions.ts | 50 +- src/lib/campaignPageChrome.ts | 11 - src/lib/campaignQuickActionContext.ts | 7 + src/lib/campaignQuickActionPaths.ts | 2 - src/utilities/activityInlineErrors.ts | 24 - src/utilities/activityOverlayErrors.ts | 43 ++ src/utilities/activityPageData.ts | 13 +- src/utilities/activityUi.ts | 138 +--- tests/e2e/campaignActivity.e2e.spec.ts | 267 +++---- tests/int/campaignActivity.int.spec.ts | 29 + .../activityAgendaInteractions.unit.spec.tsx | 118 +++- tests/unit/activityInlineCreate.unit.spec.tsx | 316 --------- tests/unit/activityInlineErrors.unit.spec.ts | 31 - tests/unit/activityOverlay.unit.spec.tsx | 443 ++++++++++++ tests/unit/activityOverlayErrors.unit.spec.ts | 53 ++ tests/unit/activityUi.unit.spec.ts | 233 +------ tests/unit/campaignQuickAction.unit.spec.ts | 49 +- tests/unit/codebaseConventions.unit.spec.ts | 10 +- 39 files changed, 2141 insertions(+), 2293 deletions(-) create mode 100644 docs/plans/agenda-criacao-modal-central-impl.md delete mode 100644 src/app/(campaign)/campanha/(app)/atividades/[slug]/editar/page.tsx delete mode 100644 src/app/(campaign)/campanha/(app)/atividades/formActions.ts delete mode 100644 src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx create mode 100644 src/components/campaign/activity/ActivityCreateOverlayHost.tsx create mode 100644 src/components/campaign/activity/ActivityEditOverlayHost.tsx delete mode 100644 src/components/campaign/activity/ActivityForm.tsx delete mode 100644 src/components/campaign/activity/ActivityInlineCreate.tsx create mode 100644 src/components/campaign/activity/ActivityOverlay.tsx create mode 100644 src/lib/activityOverlayMessages.ts delete mode 100644 src/utilities/activityInlineErrors.ts create mode 100644 src/utilities/activityOverlayErrors.ts delete mode 100644 tests/unit/activityInlineCreate.unit.spec.tsx delete mode 100644 tests/unit/activityInlineErrors.unit.spec.ts create mode 100644 tests/unit/activityOverlay.unit.spec.tsx create mode 100644 tests/unit/activityOverlayErrors.unit.spec.ts 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 ? ( -
-
-
- Hora - setHour(event.target.value)} - > - {hourOptions.map((option) => ( - - {option}h - - ))} - -
-
- Minuto - setMinute(event.target.value)} - > - {minuteOptionsForStep().map((option) => ( - - {option} - - ))} - -
-
-
- ) : null} - - ) + const calendarContent = selectedInstant ? ( + + ) : null - if (isNarrow) { - return ( - <> - {trigger} - - - - - {label ? `${label} — ` : ''} - {selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart} - - -
- {pickerContent} -
- - - -
-
- - ) - } + const timeSelects = timeVisible ? ( +
+ {( + [ + ['Hora', hour, setHour], + ['Minuto', minute, setMinute], + ] as const + ).map(([part, value, onChange]) => ( +
+ {!isNarrow ? ( + {part} + ) : null} + 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) => ( + + {part === 'Hora' ? `${option}h` : option} + + ))} + +
+ ))} +
+ ) : null return ( - - {trigger} - - {pickerContent} - - +
+
+ {isNarrow ? ( + <> + {dateTrigger} + + + + + {label ? `${label} — ` : ''} + {selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart} + + +
+ {calendarContent} +
+ + + +
+
+ + ) : ( + + {dateTrigger} + + {calendarContent} + + + )} +
+ {timeSelects} +
) } diff --git a/src/components/campaign/activity/ActivityEditOverlayHost.tsx b/src/components/campaign/activity/ActivityEditOverlayHost.tsx new file mode 100644 index 00000000..753c4ffd --- /dev/null +++ b/src/components/campaign/activity/ActivityEditOverlayHost.tsx @@ -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(null) + + const openEdit = useCallback(() => setRequest({ kind: 'edit', activityId }), [activityId]) + useBridgedQuickAction('openActivityEdit', openEdit) + + return ( + <> + + setRequest(null)} + onSaved={() => { + setRequest(null) + router.refresh() + }} + /> + + ) +} diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx deleted file mode 100644 index 5e63fa9c..00000000 --- a/src/components/campaign/activity/ActivityForm.tsx +++ /dev/null @@ -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 - existingHref?: string - submittedTitle?: string -} - -type ActivityFormAction = ( - state: ActivityFormState, - formData: FormData, -) => Promise - -export type ActivityFormFieldsProps = { - municipalityOptions: RelationOption[] - organizationOptions: RelationOption[] - activity?: ActivityFormViewModel - initialValues?: ActivityCreatePrefill - fieldErrors?: Record - submittedTitle?: string - searchContacts: (query: string) => Promise - searchResponsibles: (query: string) => Promise - /** 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 ( - - - - - - Informações básicas - - - - - Título * - - {errorFor('title') ? ( - {errorFor('title')} - ) : null} - - - - Tags - - - - - Descrição -