Merge pull request #719 from fsolla/C123-agenda-modal-central-como-unica-superficie-de-criacao-e
C123 — Agenda: overlay modal central como única superfície de criação/edição de atividade
This commit is contained in:
commit
5557199977
39 changed files with 2148 additions and 2293 deletions
|
|
@ -11,6 +11,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
**Recently resolved (2026-08-11):** **C129** (Issue #696) — dobradinha ganha **nome de legenda** (campo + display discreto sob o nome): campo `ballotName` (texto opcional, `maxLength: 30` — limite real do nome de urna do TSE; **sem index** — nunca é chave de busca/ordenação/facet, o nome real continua sendo a chave) em `stateDeputy`, ao lado de `party`. **Migration** `20260811_234822_add_state_deputy_ballot_name` (gerada; o diff do gerador re-emitia um no-op de `ADD VALUE 'lembranca'` por snapshot stale — limpa à mão para só o `ADD COLUMN`; nullable, sem backfill). **Display "discreto sob o nome"** (mesma família, `text-xs text-muted-foreground`, sem label — a posição é o rótulo) em 3 pontos: 2ª linha da célula de Nome de `/campanha/pessoas` via seam pura `peopleNameSubline` em `peopleData` (C130 estende com o fallback `?? city` — regra combinada final legenda-sobrepõe-base registrada nos dois planos; nesta branch, sem legenda nada muda na linha, aceite da intenção), sublinha fora da região click-to-edit da célula de Nome na lista de Dobradinhas (irmã da célula no flex-col — clicar nela nunca abre o editor do nome), e sublinha no Nome da seção Contato da ficha. **Edição** (onde já se edita dado de dobradinha, B163): coluna "Nome de legenda" na lista de Dobradinhas (mesma máquina de célula do partido, `editTrigger="cell"`, **default-hidden** em `DEFAULT_HIDDEN_COLUMN_IDS.dobradinhas` — precedente E-mail B197; a sublinha é o display always-on e a coluna o opt-in de edição) + campo no `StateDeputyForm` (criar e editar; schemas create/update/inline `trimmedOptionalText(30)`/`trimmedNullableText(30)` — vazio limpa o campo). Escrita: `updateStateDeputyBallotNameRecord` espelhando o partido (`runStaffEntityMutation`, staff-only, sem transação/locks/Consent novos — escrita single-collection); `CampaignInlineEditableField` ganhou `'ballotName'` (aditivo). Sem migration destrutiva/access/Consent. Testes: unit (merge carrega `ballotName`; `peopleNameSubline` legenda-only/null; default de colunas dobradinhas `['email','ballotName']`), int novo `campaignStateDeputyBallotName` (staff seta/limpa, advisor ok, leader negado, criação com legenda); verificado ao vivo em dev (pessoas/dobradinhas/ficha, edição inline persistindo na sublinha). Gates: unit 2062, int 750, build OK; knip = P3 ledgered pré-existente (idêntico no baseline). Intenção [`pessoas-nome-de-legenda-dobradinha.md`](plans/pessoas-nome-de-legenda-dobradinha.md); impl [`pessoas-nome-de-legenda-dobradinha-impl.md`](plans/pessoas-nome-de-legenda-dobradinha-impl.md). (feat(C129): nome de legenda da dobradinha — campo ballotName + display discreto sob o nome (Closes #696))
|
||||
|
||||
**Recently resolved (2026-08-11):** **C123** (Issue #667) — a agenda ganha **uma superfície única** de criação/edição de atividade: o overlay (modal central no desktop, drawer do C103 no mobile) com **todas** as seções do formulário completo; as páginas `/campanha/atividades/nova` e `/[slug]/editar` saem **sem redirecionamento** (404). `ActivityInlineCreate` virou `ActivityOverlay` (`ActivityOverlay.tsx`): slot click → criação com o intervalo do slot; **event click → edição** (Google Calendar; o detalhe fica acessível pelo link "Ver detalhes" no rodapé do overlay; middle-click no evento ainda navega ao detalhe via `url` preservada); **`Local` renderizado uma única vez** (o bloco duplicado do popover C91 morreu); Início/Término com **data e hora separados e visíveis sem rolar** (`ActivityDateTimeField` reescrito: trigger de data em popover/bottom-sheet + selects Hora/Minuto inline; contrato civil `YYYY-MM-DDTHH:mm` intacto; mobile `[dia][hora][minuto]` por linha). **Submit por FormData nas actions `createActivityOverlay`/`updateActivityOverlay`** (reusam os parsers `parseActivity*FormData` + os `*Record` transacionais; `status` só na criação — na edição não envia, evita realizado→confirmado) e **`loadActivityEditDraft`** (findByID `activityFormSelect`, `overrideAccess: false`, int testado por escopo). **Bug latente C14 consertado:** o `<input name="id">` que o update perdia (o `/editar` estava quebrado desde `adc29c03`) volta no modo edição. Quick actions `new-activity`/`edit-activity` viraram `onAction` bridged por contexto (`openActivityCreate`/`openActivityEdit`, precedente C94/C114); hosts novos: `ActivityCreateOverlayHost` (lista `/atividades`) e `ActivityEditOverlayHost` (detalhe, botão Editar + FAB). Morrem: páginas/formActions/`ActivityForm.tsx`, `buildActivityCreateHref`/`parseActivityCreatePrefill`/`parseActivityAgendaReturnHref` (activityUi), `atividadesNova` (chrome catalog), `ACTIVITY_NEW_PATH`, `getActivityEditPageData`, `createActivityInline` (vira `createActivityOverlay`), `activityInlineErrors` (vira `activityOverlayErrors`, com duplicate-demand + boundary/zod). Sem migration/access/Consent. Testes: unit overlay 14 (create/edit/erro/sheet), unit mappers/Ui/quickActions/agendaInteractions (eventClick→edit), int `loadActivityEditDraftRecord` por papel, e2e `campaignActivity` 7/7 + agenda mobile/feed/google 9/9 no modo prod (dev-mode flakeou por cold-compile do dev server sob carga da máquina — prewarm 180s estourou; `E2E_PROD=1` é o modo de verificação). Intenção [`agenda-criacao-modal-central.md`](plans/agenda-criacao-modal-central.md); impl [`agenda-criacao-modal-central-impl.md`](plans/agenda-criacao-modal-central-impl.md).
|
||||
**Recently resolved (2026-08-11):** **C126** (Issue #686) — os int specs do espelho Google (C114) deixam de flakear sob a suíte paralela: o motor ganhou **escopo injetável** `activityWhere?: Where` em `CampaignCalendarSyncOptions` (`src/utilities/googleCalendarSync.ts`) — appendado ao `and` do push set (`loadSyncActivities`) **e** do delete-guard set (`loadAliveActivityIds`); produção (hooks + action) omite o param → espelho cheio byte-identical. A race era mais ampla que o par da Issue: **5** specs criam atividades in-window no banco compartilhado (`campaignActivity`, `calendarFeed`, `homeSearchActivities`, `municipalityDossierData` + `campaignSuggestions` com `realizado` no lookback — o 5º que a Issue não nomeou), e `outcome.created` global contava todas; serialização não era construtível (vitest não serializa par-a-par; `fileParallelism:false` mataria o paralelismo da suíte) e "nunca relaxar a asserção" foi mantido à letra. O spec roda cada pass via helper `runSync(client, reason)` com `activityWhere: { title: { like: 'C114%' } }` (mesmo filtro do afterEach) e ganhou **dois pins**: (1) atividade estrangeira in-window **não** é espelhada; (2) o delete-guard honra o escopo (evento de atividade alheia viva é removido — espelho escopado é autoritativo no seu escopo). **Conflito com o C122 (#705, mergeado antes):** o C122 endereçou a C126 pela abordagem oposta (asserções globais **removidas** em favor de scoping por event-id, com delegação explícita do desfecho: "quem a detém decide") — a resolução do rebase mantém o contrato da Issue (contagens globais restauradas e determinísticas via escopo) e absorve a infra do C122 (credential compartilhada `googleCalendarTestKey`, cleanup de config por owned id, datas C127 derivadas do agora). **Contraprova:** fix 3/3 rodadas × 5 arquivos verdes (62/62); motor antigo re-flakeia 3–5 testes/rodada nas mesmas condições, e o pin 1 falha 100% — regressão viva. Gates: unit 2050, int 747, lint/typecheck/format/knip/cycles/build verdes. Sem migration/access/Consent/UI. Débito C127/#688 (datas hardcoded) já resolvido pelo C122 (#705). Impl [`c126-espelho-google-int-flake-isolamento-impl.md`](plans/c126-espelho-google-int-flake-isolamento-impl.md) (Issue = spec, sem intenção própria).
|
||||
|
||||
**Recently resolved (2026-08-11):** **OPS41** (Issue #689) — o `AGENTS.md` de main carregava **marcadores de conflito commitados** no bullet "Per-worktree environments" (` <<<<<<< HEAD` indentado + lado HEAD pré-OPS33 com artefato `- # ` + fechamento corrompido `> > > > > > > 9e18bec8`; o `=======` foi removido no próprio commit). Origem: o commit OPS33 f0f5b73f entrou com o arquivo **já conflitado** (resolve do conflito OPS33×OPS28/OPS31 commitado com marcadores); OPS37 herdou o estado. Resolução **perda zero validada por diff**: manter o lado superset (OPS28 + OPS31 + OPS33 — claim determinístico, `--issue N`, prompt citado) e descartar o lado HEAD; −3 linhas no diff; scan whitespace-tolerant de todos os arquivos trackeados confirmou `AGENTS.md` como único afetado. **Guardrail anti-reincidência** (2ª vez que marcador chega a main): novo describe `no conflict markers in committed files` em `tests/unit/codebaseConventions.unit.spec.ts` — varre `git ls-files`, falha em `<<<<<<<`/`>>>>>>>` (tolerante a indentação) e na forma corrompida commitada `^\s*(> ){7}`; roda no CI via `pnpm test:unit`. Sem migration/access/Consent/UI/schema. Intenção: body da Issue (sem plano linkado); impl [`agents-md-sem-marcadores-conflito-impl.md`](plans/agents-md-sem-marcadores-conflito-impl.md).
|
||||
|
|
|
|||
127
docs/plans/agenda-criacao-modal-central-impl.md
Normal file
127
docs/plans/agenda-criacao-modal-central-impl.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Impl: C123 — Agenda: modal central como única superfície de criação/edição de atividade
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #667
|
||||
Intenção: docs/plans/agenda-criacao-modal-central.md
|
||||
Appetite restante: herdado (~1–1,5 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** um overlay só (modal central no desktop, drawer no mobile) cria E edita compromisso com todas as configurações do formulário completo; clique no slot abre criação; clique no evento abre edição; `/nova` e `/editar` saem sem redirecionamento (404); "Local" sem duplicação; Início/Término com seletor de data e seletor de hora separados, visíveis sem rolar; salvar não navega (refetch da janela).
|
||||
- **O que NÃO negociar:** contrato civil `YYYY-MM-DDTHH:mm` de parse/validação; validação início ≤ término; modo todo-o-dia (só data); prefill do slot; permissões staff (`overrideAccess: false`); título imutável na edição; líderes intocados; detalhe `/atividades/[slug]` continua (não é formulário); recorrência fora.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- A intenção sugere "popover vira modal" — confirmado, mas o mecanismo do popover inteiro (anchor no ponto de clique) morre junto; o container passa a ser `Dialog` (desktop) + `Drawer` (mobile, C103 intacto).
|
||||
- A intenção assume `ActivityForm.tsx` como referência de seções — confirmado: as seções (cards) migram para o overlay; `ActivityForm` inteiro morre (só as duas páginas o usam).
|
||||
- **Descoberta fora da hipótese:** o update em página está **quebrado desde C14** (adc29c03 removeu o `<input type="hidden" name="id">` de `ActivityForm`; `parseActivityUpdateFormData` exige `id` e lança `FormDataBoundaryError` em todo submit). O overlay conserta o caminho de edição ao renderizar `id` explícito.
|
||||
- `activityInlineErrors.ts` só mapeia duplicate-title; o overlay (form completo) precisa do mapeamento de `FormDataBoundaryError`/ZodError que `formActions.ts` já tem — os dois mappers se unificam no módulo de erros do overlay (formActions morre).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
slot["dateClick (slot)"] -->|activitySlotPrefill| req["request: {kind:'create', startAt, endAt}"]
|
||||
event["eventClick (evento)"] -->|preventDefault + id| req2["request: {kind:'edit', activityId}"]
|
||||
req --> overlay["ActivityOverlay<br/>(Dialog desktop / Drawer mobile)"]
|
||||
req2 --> overlay
|
||||
overlay -->|edit: busca| draft["loadActivityEditDraft<br/>(action, activityFormSelect, overrideAccess:false)"]
|
||||
overlay -->|create/edit| form["ActivityOverlayForm<br/>(todas as seções do ActivityForm)"]
|
||||
form -->|FormData| create["createActivityOverlay / updateActivityOverlay<br/>(reusa parseActivity*FormData + create/updateActivityRecord)"]
|
||||
create -->|{ok}| close["fecha + refetch da janela (setReloadCount)"]
|
||||
create -->|{ok:false}| inline["overlay aberto + fieldErrors/toast"]
|
||||
draft -->|"ActivityFormViewModel"| form
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (recomendada, abaixo) | B (objeto estruturado client-side) | C (overlay no server component via RSC)
|
||||
**Recomendação:** A — **editar o dono**: `ActivityInlineCreate.tsx` é renomeado/estendido para `ActivityOverlay.tsx` (container Dialog/Drawer + form completo); o pipeline de submit vira FormData indo para as novas actions `createActivityOverlay`/`updateActivityOverlay` que **reusam os parsers server-side existentes** (`parseActivityCreateFormData`/`parseActivityUpdateFormData` — únicos, testados, usados pelo giro) e os `*Record` transacionais, retornando `{ ok }` com `fieldErrors` mapeados. O overlay renderiza os campos controlados com `name` + inputs hidden (startAt/endAt/allDay/municipality/status/id); as seções (tags/responsáveis/orgs/tarefas/demandas) já emitem hidden inputs próprios. Sem segunda parser no client.
|
||||
**Rejeitadas:** B — client teria que serializar 5 JSONs (tasks/demands/responsibles/tags/orgs) com parsing próprio, duplicando `activityFormData.ts` no browser; C — RSC não sustenta overlay com estado + fetch pós-clique.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1 — Container do overlay.**
|
||||
Opções: A) `Dialog` (desktop, central, com overlay escuro) + `Drawer` topo (mobile, C103) | B) manter `Popover`+anchor e só crescer o conteúdo | C) `Sheet` lateral nos dois.
|
||||
Recomendação: **A** — o aceite é "modal central", o popover ancorado morre (o anchor `{x,y}` sai do draft); `Dialog` do shadcn (`dialog.tsx`, `max-w-sm` default → override p/ `max-w-2xl`) com corpo rolável e rodapé fixo (corte do rabbit hole da intenção); mobile continua o sheet do topo do C103 (mesma anatomia, mesmas seções, labels sr-only). Seleção pelo mesmo `isNarrow` (ResizeObserver, 640px) de hoje.
|
||||
Rejeitadas: B — anti-aceite explícito (popover sai); C — sheet lateral não é modal central nem bottom sheet.
|
||||
|
||||
**D2 — Dados da edição (quem busca o view model).**
|
||||
Opções: A) o overlay busca sozinho no mount via action `loadActivityEditDraft(activityId)` | B) o pai (ActivityAgenda) busca no eventClick e passa por prop | C) embutir o form completo no fetch de eventos da janela.
|
||||
Recomendação: **A** — o overlay vira o dono do contrato "editar atividade por id", reutilizável no detalhe (botão Editar) sem duplicar loading no calendário; `loadActivityEditDraft` = `findByID` com `activityFormSelect`, depth 1, `overrideAccess: false` + actor → `toActivityFormViewModel` (scoping de advisor herdado do access). C deixa o fetch da agenda pesado (todo evento carregaria descrição/orgs/tarefas).
|
||||
Rejeitadas: B — espalha o fetch para cada host; C — regressão de payload da janela.
|
||||
|
||||
**D3 — Submit: FormData nas actions do overlay.**
|
||||
Opções: A) `createActivityOverlay(formData)`/`updateActivityOverlay(formData)` reusando `parseActivity*FormData` + `create/updateActivityRecord`, retorno `{ok, message, fieldErrors}` | B) manter `createActivityInline(input: object)` e criar `updateActivityInline` análogo, com parse dos JSONs no client | C) useActionState + redirect como o form antigo.
|
||||
Recomendação: **A** — um parser server-side único (os parsers já existem e o giro depende deles); o client só dá `name` aos controlados (o `ActivityDateTimeField` reescrito emite o civil via hidden input). O mapeamento de erro junta o duplicate-title (`activityInlineErrors.ts`, estendido) + duplicate-demand + `FormDataBoundaryError`/ZodError via `mapCampaignFormActionError` — o `getActionError` do formActions morre junto com as páginas, seu conteúdo migra para `activityOverlayErrors.ts`.
|
||||
Rejeitadas: B — client duplica parsing/validação de 5 campos JSON (drift client×server que o repo evitou até hoje); C — redirect é anti-aceite (salvar não navega).
|
||||
**Bug legado consertado:** o overlay de edição renderiza `<input type="hidden" name="id">` — o caminho de update estava quebrado desde C14.
|
||||
|
||||
**D4 — Seletor de data + hora separados.**
|
||||
Opções: A) reescrever `ActivityDateTimeField` internamente: trigger de data (calendar em popover desktop / bottom sheet mobile) + selects Hora/Minuto **inline** lado a lado, sempre visíveis | B) dois componentes novos (DatePicker + TimeSelects) | C) manter o picker único atual e só remover o duplicado de Local.
|
||||
Recomendação: **A** — contrato civil `YYYY-MM-DDTHH:mm` e props inalterados (quem usa hoje: só o overlay); o time sai de dentro do popover (anti-aceite atual: "horário escondido abaixo do calendário"); all-day oculta só os selects de hora. Mobile: linha `[dia][hora][minuto]` compacta (aceite C123: dia e hora lado a lado na mesma linha, Início em cima / Término embaixo).
|
||||
Rejeitadas: B — dois módulos para um único conceito "campo data+hora" (pass-through); C — não cumpre o aceite.
|
||||
|
||||
**D5 — Clique no evento abre a edição (e o detalhe continua acessível).**
|
||||
Opções: A) `eventClick` no FullCalendar com `preventDefault` → abre overlay de edição; manter `url` no EventInput (middle-click/abrir em nova aba ainda navega ao detalhe) | B) remover `url` e interceptar tudo | C) manter navegação e só o botão Editar abre overlay.
|
||||
Recomendação: **A** — decisão do gate (recomendação A da intenção); `preventDefault` no `eventClick` cobre clique simples; o `url` preserva o detalhe por atalhos de browser; o overlay de edição ganha link "Ver detalhes" (ghost, esquerda do rodapé).
|
||||
Rejeitadas: B — perde o detalhe por middle-click; C — anti-aceite (clique no evento abre overlay).
|
||||
|
||||
**D6 — Pontos de entrada que apontavam para `/nova` (não podem 404).**
|
||||
Opções: A) contexto de quick actions (precedente C94/C114 `openCalendarFeed`/`openGoogleCalendarSync`): `openActivityCreate`/`openActivityEdit` funções no `CampaignQuickActionContext`, bridged por cada página; FAB e botões viram `onAction` | B) manter href e redirecionar para a agenda com `?nova=1` | C) remover os botões.
|
||||
Recomendação: **A** — mesma ponte que a agenda já usa para os diálogos de feed/Google; cada host monta o overlay e bridga: agenda (FAB + ícone "+" dos filtros → create com draft padrão hoje 09:00–10:00 via `activitySlotPrefill({allDay:true})`), lista `/atividades` (botão "Nova atividade" + FAB), detalhe (botão "Editar" + FAB edit → overlay de edição com `view.id`). `ACTIVITY_NEW_PATH` e a entrada `atividadesNova` do catálogo de chrome morrem.
|
||||
Rejeitadas: B — param novo na agenda (estado de URL whitelisted) para um atalho que o contexto já resolve; C — regressão de feature.
|
||||
|
||||
**D7 — O que morre (remoção sem redirect, 404 — decisão do gate).**
|
||||
|
||||
- Páginas `nova/page.tsx` e `[slug]/editar/page.tsx` + `formActions.ts` (redirect-based) + `ActivityForm.tsx` (sections migram para o overlay).
|
||||
- `activityUi.ts`: `buildActivityCreateHref`, `parseActivityCreatePrefill`, `ActivityCreatePrefill`, `parseActivityAgendaReturnHref` (+ helpers privados de prefill) — "Mais detalhes" morre junto; `activitySlotPrefill` permanece (prefill do slot + draft padrão).
|
||||
- `activityPageData.ts`: `getActivityEditPageData` (só a página editar usava) — o novo `loadActivityEditDraftRecord` busca por id.
|
||||
- `campaignPageChrome.ts`: match de `/atividades/nova` + catálogo `atividadesNova` + match de `/atividades/[slug]/editar`.
|
||||
- `activityQuickActions.ts`: `new-activity` e `edit-activity` passam de `href` para `onAction` (contexto); `ACTIVITY_NEW_PATH` sai.
|
||||
- `ActivityInlineCreate.tsx` → **`ActivityOverlay.tsx`** (rename + rewrite; `ActivityInlineCreateDraft` vira `ActivityOverlayRequest` sem `anchor`).
|
||||
- `activityInlineErrors.ts` → **`activityOverlayErrors.ts`** (estende: duplicate-title + duplicate-demand + boundary/zod).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`ActivityOverlay.tsx`** (client, renomeado de `ActivityInlineCreate.tsx`): props `{ request: ActivityOverlayRequest | null, agendaState, municipalityOptions, organizationOptions, knownTags, searchContacts, searchResponsibles, onClose, onSaved }`; container `Dialog`/`Drawer` pelo `isNarrow`; `ActivityOverlayForm` com todas as seções (Informações básicas → título readOnly na edição + tags + descrição + deputado presente; Data e horário → `ActivityDateTimeField` reescrito + todo-o-dia; Onde → município `StrictCombobox` + Local **uma única vez**; Pessoas e organizações → `ResponsibleMultiSelect` + `RelationMultiSelect`; Tarefas → `ActivityTaskFields`; Demandas → `ActivityDemandFields`); modo edit busca `loadActivityEditDraft` no mount (estado de loading no modal) e renderiza `id` hidden; rodapé fixo: edição `[Ver detalhes] [Cancelar] [Salvar]`, criação `[Cancelar] [Salvar]`; hidden `status=confirmado` só na criação (na edição não envia status — evita realizar→confirmado).
|
||||
- **`ActivityDateTimeField.tsx`** (client): reescrito — trigger de data (calendar em popover desktop / nested bottom sheet mobile) + `NativeSelect` Hora + Minuto inline; `timeVisible={false}` (todo-o-dia) oculta só os selects; contrato e props preservados.
|
||||
- **`actions/activity.ts`**: `createActivityOverlay`/`updateActivityOverlay` (FormData → parse → `*Record` → `{ok}`) substituem `createActivityInline`; novo `loadActivityEditDraft` (findByID `activityFormSelect`, depth 1, `overrideAccess:false`).
|
||||
- **`utilities/activityOverlayErrors.ts`**: mapeamento unificado (duplicate-title, duplicate-demand, boundary/zod, fallback genérico).
|
||||
- **`ActivityAgenda.tsx`**: estado vira `request` (`create` com prefill do slot / `edit` com id); `eventClick` → preventDefault + edit; `organizationOptions` prop nova; ponte `openActivityCreate` no contexto (draft padrão); onSaved = fechar + refetch (já é o mecanismo de hoje).
|
||||
- **`agenda/page.tsx`**: carrega `loadOrganizationOptions` (nova) e passa ao `ActivityAgenda`.
|
||||
- **`[slug]/page.tsx` (detalhe)**: botão "Editar" vira host client `ActivityEditOverlayHost` (botão + overlay + ponte `openActivityEdit`); página passa a carregar municipality/organization options + knownTags.
|
||||
- **`atividades/page.tsx` (lista)**: botão "Nova atividade" vira host client `ActivityCreateOverlayHost` (draft padrão + ponte `openActivityCreate`); página carrega organization options + knownTags.
|
||||
- **Migration:** nenhuma — sem mudança de schema.
|
||||
- **Access / Consent:** nenhum novo — todas as escritas pelos mesmos `*Record` (`overrideAccess: false` + actor); `loadActivityEditDraft` idem (advisor escopado pelo access do payload); líderes não alcançam nenhum host (agenda/detalhe/lista são staff-gated; `isStaff` já condiciona o botão Editar).
|
||||
- **UI:** Impeccable C — fluxo redesenhado na agenda (shape → craft → critique → polish). Desktop: modal central `max-w-2xl`, seções em cards como o formulário atual, corpo rolável + rodapé fixo. Mobile: sheet do topo do C103 com as mesmas seções em linhas label-less + rodapé fixo; linhas de Início/Término `[dia][hora][minuto]`.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — superfície de escrita; nenhum dado novo de apresentação.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Server/actions** (maior parte do appetite): `activityOverlayErrors.ts`; `createActivityOverlay`/`updateActivityOverlay`/`loadActivityEditDraft`; unit dos mappers; int do `loadActivityEditDraftRecord` (scoping advisor/leader) e do update com `id`. Verificar: `tsc`, `pnpm test` int.
|
||||
2. **UI overlay** (Impeccable C): `ActivityOverlay.tsx` + `ActivityDateTimeField` reescrito + hosts (agenda/detalhe/lista) + ponte de quick actions; remoção das páginas/formActions/ActivityForm/prefill. Verificar: `tsc`, `lint`, `knip` (órfãos: `buildActivityCreateHref`, `getActivityEditPageData`, `atividadesNova`, `ACTIVITY_NEW_PATH`), `check:cycles`.
|
||||
3. **Testes** — unit: `activityOverlay.unit.spec.tsx` (rewrite do `activityInlineCreate.unit.spec.tsx`: modal central, Local único, seções completas, edição prefilled + id, título readOnly, Ver detalhes); `activityUi.unit.spec.ts` (sai prefill, fica slotPrefill); `activityAgendaInteractions.unit.spec.tsx` (+ eventClick → overlay de edição). e2e: `campaignActivity.e2e.spec.ts` (o de "/nova com demandas" vira fluxo pelo overlay; "Mais detalhes" vira edição pelo overlay; C104 atualiza clique no evento → overlay → "Ver detalhes"); `campaignAgendaMobile.e2e.spec.ts` (C103: hora inline, sem bottom sheet aninhado nem "Pronto").
|
||||
4. **Gates** — `tsc --noEmit`, `lint` (0 warnings), `format:check`, `knip`, `check:cycles`, `pnpm test`, `pnpm test:e2e`, `pnpm build` contra DB local.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Redesenho da página de detalhe (fora — leitura/resultado fica como está).
|
||||
- Recorrência, drag-and-drop de remarcação, filtros/feed iCal, Google sync — intocados.
|
||||
- Segundo estilo de card no modal (2ª variante visual) — o modal herda os cards do formulário.
|
||||
- Prefetch do view model de edição no hover do evento — barato demais para agora; o fetch no mount já é rápido (um findByID).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **e2e quebrados pela remoção das rotas:** esperado — substituir os 3 fluxos (`/nova` com demandas, "Mais detalhes", clique no evento → detalhe) por fluxos via overlay, não remover cobertura.
|
||||
- **e2e mobile C103 (bottom sheet aninhado do tempo):** o aceite C123 muda a anatomia (hora inline) — atualizar as asserções (sem "Pronto", selects visíveis na linha).
|
||||
- **Update quebrado desde C14:** o overlay renderiza `id` — o caminho de edição passa a funcionar; cobrir com e2e novo (editar via overlay, evento atualiza sem navegar).
|
||||
- **`Dialog` aninhado** (seletor de data em popover dentro do modal; `CommandDialog` dos responsáveis por cima): portais Radix separados, mesmo comportamento já validado no sheet do C103; validar no craft.
|
||||
- **knip com órfãos do form antigo:** remover no mesmo commit das páginas (grep de cada símbolo antes de apagar — `ActivityFormState`, `buildActivityCreateHref`, etc.).
|
||||
- **Clique vs drag no FullCalendar:** `eventClick` não dispara após drag (comportamento do FullCalendar, já exercitado pelo teste de remarcação que continua verde).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção coberto: modal central desktop / drawer mobile; todas as seções no overlay (criar E editar); clique no slot abre criação com prefill; clique no evento abre edição (detalhe via link no overlay); `/nova` e `/editar` → 404 sem redirect; "Local" único; data e hora em seletores separados visíveis sem rolar; salvar não navega + refetch da janela; guardrails intactos (civil, início ≤ término, todo-o-dia, título imutável, staff)
|
||||
- [ ] Invariantes AGENTS/engineering-standards: mesmos `*Record` transacionais com `overrideAccess: false` + actor; sem `Contact`/Consent novos; identificadores em inglês; copy pt-BR; sem migration; líderes intocados
|
||||
- [ ] Testes de domínio: unit (overlay create/edit, erro inline, mappers), int (scoping do `loadActivityEditDraft`, update com id), e2e (criação completa pelo overlay, edição pelo clique no evento, remoção das rotas, mobile hora inline)
|
||||
|
|
@ -30,7 +30,10 @@ import {
|
|||
restrictActivityAgendaState,
|
||||
} from '@/utilities/activityUi'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('agenda')
|
||||
|
||||
|
|
@ -47,12 +50,14 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
requireCampaignPageActor({ gate: 'staff' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
const [municipalityOptions, knownTags, feeds, googleSyncState] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
listCalendarFeeds().catch(() => []),
|
||||
getGoogleCalendarSyncState(),
|
||||
])
|
||||
const [municipalityOptions, organizationOptions, knownTags, feeds, googleSyncState] =
|
||||
await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
listCalendarFeeds().catch(() => []),
|
||||
getGoogleCalendarSyncState(),
|
||||
])
|
||||
const state = restrictActivityAgendaState(
|
||||
resolvedUrl.state,
|
||||
new Set(municipalityOptions.map((option) => option.id)),
|
||||
|
|
@ -109,6 +114,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
<ActivityAgenda
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
</CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import config from '@payload-config'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
searchActivityContactOptions,
|
||||
searchActivityResponsibleOptionsAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
|
||||
type EditActivityPageProps = {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: EditActivityPageProps) {
|
||||
const { slug } = await params
|
||||
if (!slug) return campaignPageMetadata({ title: 'Editar atividade' })
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
try {
|
||||
const view = await getActivityEditPageData(payload, user, slug)
|
||||
return campaignPageMetadata({ title: `Editar ${view.title}` })
|
||||
} catch {
|
||||
return campaignPageMetadata({ title: 'Editar atividade' })
|
||||
}
|
||||
}
|
||||
|
||||
export default async function EditActivityPage({ params }: EditActivityPageProps) {
|
||||
const [{ slug }, user, payload] = await Promise.all([
|
||||
params,
|
||||
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
if (!slug) notFound()
|
||||
|
||||
const [view, municipalityOptions, organizationOptions] = await Promise.all([
|
||||
getActivityEditPageData(payload, user, slug).catch((error) => {
|
||||
if (error instanceof ActivityNotFoundError) notFound()
|
||||
throw error
|
||||
}),
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<SetCampaignPageChrome chrome={{ title: `Editar ${view.title}` }} />
|
||||
<ActivityForm
|
||||
action={updateActivityFormAction}
|
||||
activity={view}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
submitLabel="Salvar alterações"
|
||||
searchContacts={searchActivityContactOptions}
|
||||
searchResponsibles={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import config from '@payload-config'
|
||||
import { PencilIcon, PlusIcon } from 'lucide-react'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ActivityEditOverlayHost } from '@/components/campaign/activity/ActivityEditOverlayHost'
|
||||
import {
|
||||
CancelActivityDialog,
|
||||
MarkActivityRealizedDialog,
|
||||
|
|
@ -19,7 +20,12 @@ import { Badge } from '@/components/ui/Badge'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
|
||||
import { formatActivityWhenLabel } from '@/utilities/activityViewModels'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
|
||||
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
|
||||
import {
|
||||
|
|
@ -90,6 +96,12 @@ export default async function ActivityDetailPage({
|
|||
const canonicalTabRedirect = getActivityDetailTabRedirect(view.slug, query)
|
||||
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
|
||||
|
||||
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
])
|
||||
|
||||
const isStaff = isCampaignStaff(user)
|
||||
const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
|
||||
const chromeSubtitle = view.locationLabel || view.municipality?.name
|
||||
|
|
@ -101,6 +113,8 @@ export default async function ActivityDetailPage({
|
|||
chromeSubtitle ? { title: view.title, subtitle: chromeSubtitle } : { title: view.title }
|
||||
}
|
||||
/>
|
||||
{/* C123 — must stay ABOVE ActivityEditOverlayHost: the bridge replaces
|
||||
the whole context, the host merges `openActivityEdit` on top. */}
|
||||
<CampaignQuickActionContextBridge
|
||||
activitySlug={view.slug}
|
||||
municipalitySlug={view.municipality?.slug}
|
||||
|
|
@ -122,12 +136,12 @@ export default async function ActivityDetailPage({
|
|||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isStaff ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={`/campanha/atividades/${view.slug}/editar`}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Editar
|
||||
</Link>
|
||||
</Button>
|
||||
<ActivityEditOverlayHost
|
||||
activityId={view.id}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
) : null}
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={`/campanha/atividades/${view.slug}?tab=updates&newUpdate=1`}>
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
'use server'
|
||||
|
||||
// Documented exception to `runCampaignRedirectFormAction`/`runCampaignFormAction`
|
||||
// (Pass 2 W4d): these ladders carry a custom error mapper (DB unique-violation
|
||||
// regexes surfacing as field errors) plus an async duplicate-title fallback
|
||||
// that links to the existing activity — policy the shared wrappers deliberately
|
||||
// don't grow parameters for.
|
||||
|
||||
import config from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { createActivity, updateActivity } from '@/app/(campaign)/campanha/actions/activity'
|
||||
import type { ActivityFormState } from '@/components/campaign/activity/ActivityForm'
|
||||
import { optionalFormText } from '@/lib/formData'
|
||||
import { slugify } from '@/lib/slug'
|
||||
import {
|
||||
parseActivityCreateFormData,
|
||||
parseActivityUpdateFormData,
|
||||
} from '@/utilities/activityFormData'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
|
||||
|
||||
const getActionError = (error: unknown): ActivityFormState => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
|
||||
) {
|
||||
return {
|
||||
fieldErrors: {
|
||||
demandsJson: ['Já existe uma demanda com um dos títulos informados.'],
|
||||
},
|
||||
}
|
||||
}
|
||||
// Checked before the shared mapper: a DB-level unique-violation message isn't
|
||||
// a `FormDataBoundaryError`/`ZodError`/known-safe string, so it needs its own
|
||||
// branch to surface as a field error instead of falling to the generic message.
|
||||
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
|
||||
return { fieldErrors: { title: ['Já existe uma atividade com este título.'] } }
|
||||
}
|
||||
return mapCampaignFormActionError({
|
||||
error,
|
||||
genericMessage: 'Não foi possível concluir a ação. Atualize a página e tente novamente.',
|
||||
})
|
||||
}
|
||||
|
||||
const existingActivityState = async (formData: FormData): Promise<ActivityFormState | null> => {
|
||||
const title = optionalFormText(formData, 'title')
|
||||
const slug = title ? slugify(title) : ''
|
||||
if (!slug) return null
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) return null
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'activity',
|
||||
where: { slug: { equals: slug } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
pagination: false,
|
||||
select: { slug: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
const existing = result.docs[0]
|
||||
return existing
|
||||
? {
|
||||
fieldErrors: { title: ['Já existe uma atividade com este título.'] },
|
||||
existingHref: `/campanha/atividades/${existing.slug}`,
|
||||
submittedTitle: title,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
export const createActivityFormAction = async (
|
||||
_state: ActivityFormState,
|
||||
formData: FormData,
|
||||
): Promise<ActivityFormState> => {
|
||||
let activitySlug: string
|
||||
|
||||
try {
|
||||
const { demands, ...activityInput } = parseActivityCreateFormData(formData)
|
||||
const activity = await createActivity(activityInput, demands)
|
||||
activitySlug = activity.slug
|
||||
} catch (error) {
|
||||
const existing = await existingActivityState(formData)
|
||||
if (existing) return existing
|
||||
return getActionError(error)
|
||||
}
|
||||
|
||||
redirect(`/campanha/atividades/${activitySlug}`)
|
||||
}
|
||||
|
||||
export const updateActivityFormAction = async (
|
||||
_state: ActivityFormState,
|
||||
formData: FormData,
|
||||
): Promise<ActivityFormState> => {
|
||||
let activitySlug: string
|
||||
|
||||
try {
|
||||
const { demands, ...activityInput } = parseActivityUpdateFormData(formData)
|
||||
const activity = await updateActivity(activityInput, demands)
|
||||
activitySlug = activity.slug
|
||||
} catch (error) {
|
||||
return getActionError(error)
|
||||
}
|
||||
|
||||
redirect(`/campanha/atividades/${activitySlug}`)
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
searchActivityContactOptions,
|
||||
searchActivityResponsibleOptionsAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
|
||||
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
|
||||
import { parseActivityAgendaReturnHref, parseActivityCreatePrefill } from '@/utilities/activityUi'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
|
||||
|
||||
type NewActivityPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
export default async function NewActivityPage({ searchParams }: NewActivityPageProps) {
|
||||
const rawSearchParams = await searchParams
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/agenda' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
])
|
||||
const accessibleMunicipalityIDs = new Set(municipalityOptions.map((option) => option.id))
|
||||
const accessibleTags = new Set(knownTags)
|
||||
const initialValues = parseActivityCreatePrefill(rawSearchParams, accessibleMunicipalityIDs)
|
||||
const cancelHref = parseActivityAgendaReturnHref(
|
||||
rawSearchParams.returnTo,
|
||||
accessibleMunicipalityIDs,
|
||||
accessibleTags,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
<ActivityForm
|
||||
action={createActivityFormAction}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
initialValues={initialValues}
|
||||
cancelHref={cancelHref}
|
||||
knownTags={knownTags}
|
||||
submitLabel="Criar atividade"
|
||||
searchContacts={searchActivityContactOptions}
|
||||
searchResponsibles={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,11 +3,12 @@ import {
|
|||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import config from '@payload-config'
|
||||
import { MapPinnedIcon, PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import { MapPinnedIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ActivityCreateOverlayHost } from '@/components/campaign/activity/ActivityCreateOverlayHost'
|
||||
import { ActivityFilters } from '@/components/campaign/activity/ActivityFilters'
|
||||
import { ActivityList } from '@/components/campaign/activity/ActivityList'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
|
|
@ -16,7 +17,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { Activity } from '@/payload-types'
|
||||
import { loadActivityListPageData } from '@/utilities/activityPageData'
|
||||
import { loadAccessibleActivityTags, loadActivityListPageData } from '@/utilities/activityPageData'
|
||||
import {
|
||||
buildActivityFiltersKey,
|
||||
buildActivityListHref,
|
||||
|
|
@ -25,7 +26,10 @@ import {
|
|||
import { toActivityListViewModel } from '@/utilities/activityViewModels'
|
||||
import { isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
|
||||
import {
|
||||
loadMunicipalityOptions,
|
||||
loadOrganizationOptions,
|
||||
} from '@/utilities/campaignRelationOptions'
|
||||
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('atividades')
|
||||
|
|
@ -45,10 +49,13 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
|
|||
])
|
||||
|
||||
const now = new Date()
|
||||
const [{ result, state }, municipalityOptions] = await Promise.all([
|
||||
loadActivityListPageData(payload, user, rawSearchParams, now),
|
||||
loadMunicipalityOptions(payload, user),
|
||||
])
|
||||
const [{ result, state }, municipalityOptions, organizationOptions, knownTags] =
|
||||
await Promise.all([
|
||||
loadActivityListPageData(payload, user, rawSearchParams, now),
|
||||
loadMunicipalityOptions(payload, user),
|
||||
loadOrganizationOptions(payload, user),
|
||||
loadAccessibleActivityTags(payload, user),
|
||||
])
|
||||
const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
|
||||
|
|
@ -64,12 +71,12 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
|
|||
Planejar giro
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/atividades/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Link>
|
||||
</Button>
|
||||
<ActivityCreateOverlayHost
|
||||
municipalityId={state.municipality}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,14 @@ import {
|
|||
type ActivityUpdateInput,
|
||||
} from '@/lib/schemas/activity'
|
||||
import type { Activity, CampaignUser } from '@/payload-types'
|
||||
import type { ParsedTourDraftFormData } from '@/utilities/activityFormData'
|
||||
import { mapActivityInlineCreateError } from '@/utilities/activityInlineErrors'
|
||||
import {
|
||||
parseActivityCreateFormData,
|
||||
parseActivityUpdateFormData,
|
||||
type ParsedTourDraftFormData,
|
||||
} from '@/utilities/activityFormData'
|
||||
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
|
||||
import { loadActivityAgendaEventsData } from '@/utilities/activityPageData'
|
||||
import { activityFormSelect, toActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
|
||||
import { hookFilledCreateData } from '@/utilities/hookFilledData'
|
||||
|
|
@ -434,14 +439,6 @@ const registerActivityResult = async (
|
|||
)
|
||||
}
|
||||
|
||||
export const createActivity = async (
|
||||
input: ActivityCreateInput,
|
||||
demandDrafts: ActivityDemandDraft[] = [],
|
||||
) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return createActivityRecord(payload, actor, input, demandDrafts)
|
||||
}
|
||||
|
||||
export const loadActivityAgendaEvents = async (input: ActivityAgendaRequest) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return loadActivityAgendaEventsRecord(payload, actor, input)
|
||||
|
|
@ -465,34 +462,65 @@ export const rescheduleActivity = async (
|
|||
}
|
||||
}
|
||||
|
||||
export type ActivityInlineCreateResult =
|
||||
export type ActivityOverlayResult =
|
||||
| { ok: true }
|
||||
| { ok: false; message: string; fieldErrors?: Record<string, string[]> }
|
||||
|
||||
/**
|
||||
* C91 — server surface for the agenda's inline quick create. Goes through the
|
||||
* same `createActivityRecord` (one transaction, `overrideAccess: false` with
|
||||
* the actor, the advisor's portfolio scope inherited from the full form) but
|
||||
* returns a result instead of throwing, so the overlay can stay open on a
|
||||
* failure — same `{ ok }` contract as `rescheduleActivity`.
|
||||
* C123 — server surface for the agenda's single create/edit overlay. Both
|
||||
* actions reuse the full-form FormData parsers (`parseActivity*FormData`) and
|
||||
* the transactional `*Record` writes (`overrideAccess: false` with the actor,
|
||||
* the advisor's portfolio scope inherited from the full form), returning a
|
||||
* result instead of throwing so the overlay stays open on a failure — same
|
||||
* `{ ok }` contract the old inline quick create established.
|
||||
*/
|
||||
export const createActivityInline = async (
|
||||
input: ActivityCreateInput,
|
||||
): Promise<ActivityInlineCreateResult> => {
|
||||
export const createActivityOverlay = async (formData: FormData): Promise<ActivityOverlayResult> => {
|
||||
try {
|
||||
await createActivity(input)
|
||||
const { demands, ...activityInput } = parseActivityCreateFormData(formData)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await createActivityRecord(payload, actor, activityInput, demands)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, ...mapActivityInlineCreateError(error) }
|
||||
return { ok: false, ...mapActivityOverlayError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const updateActivity = async (
|
||||
input: ActivityUpdateInput,
|
||||
demandDrafts: ActivityDemandDraft[] = [],
|
||||
export const updateActivityOverlay = async (formData: FormData): Promise<ActivityOverlayResult> => {
|
||||
try {
|
||||
const { demands, ...activityInput } = parseActivityUpdateFormData(formData)
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
await updateActivityRecord(payload, actor, activityInput, demands)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, ...mapActivityOverlayError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C123 — edit-mode data for the overlay: the full form view model of one
|
||||
* activity, read through the same access the detail page uses (`overrideAccess:
|
||||
* false` with the actor), so an advisor can only edit activities in their scope.
|
||||
*/
|
||||
export const loadActivityEditDraftRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
activityId: number,
|
||||
) => {
|
||||
const currentActor = await reloadCampaignActor(payload, actor)
|
||||
const activity = await payload.findByID({
|
||||
collection: 'activity',
|
||||
id: activityId,
|
||||
depth: 1,
|
||||
select: activityFormSelect,
|
||||
user: currentActor,
|
||||
overrideAccess: false,
|
||||
})
|
||||
return toActivityFormViewModel(activity as Activity)
|
||||
}
|
||||
|
||||
export const loadActivityEditDraft = async (activityId: number) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
return updateActivityRecord(payload, actor, input, demandDrafts)
|
||||
return loadActivityEditDraftRecord(payload, actor, activityId)
|
||||
}
|
||||
|
||||
export const cancelActivity = async (id: number) => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import FullCalendar, {
|
|||
type CalendarRef,
|
||||
type DateClickInfo,
|
||||
type DatesSetInfo,
|
||||
type EventClickInfo,
|
||||
type EventDisplayInfo,
|
||||
type EventDropInfo,
|
||||
type EventInput,
|
||||
|
|
@ -30,13 +31,14 @@ import {
|
|||
type ActivityAgendaAdjacentRange,
|
||||
} from '@/components/campaign/activity/ActivityAgendaSwipePreview'
|
||||
import {
|
||||
ActivityInlineCreate,
|
||||
type ActivityInlineCreateDraft,
|
||||
} from '@/components/campaign/activity/ActivityInlineCreate'
|
||||
ActivityOverlay,
|
||||
type ActivityOverlayRequest,
|
||||
} from '@/components/campaign/activity/ActivityOverlay'
|
||||
import { AgendaPeriodChrome } from '@/components/campaign/activity/AgendaPeriodChrome'
|
||||
import { useAgendaKeyboardNavigation } from '@/components/campaign/activity/useAgendaKeyboardNavigation'
|
||||
import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgendaSwipeNavigation'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIsMobileMeasured } from '@/hooks/use-mobile'
|
||||
import {
|
||||
|
|
@ -55,6 +57,7 @@ import {
|
|||
activityAgendaPeriodLabel,
|
||||
activityAgendaViewFcId,
|
||||
activityAgendaViewFromFcId,
|
||||
activityDefaultCreatePrefill,
|
||||
activitySlotPrefill,
|
||||
type ActivityAgendaState,
|
||||
type ActivityAgendaView,
|
||||
|
|
@ -156,10 +159,12 @@ const renderEventContent = ({ event, timeText, view }: EventDisplayInfo) => {
|
|||
export const ActivityAgenda = ({
|
||||
state,
|
||||
municipalityOptions = [],
|
||||
organizationOptions = [],
|
||||
knownTags,
|
||||
}: {
|
||||
state: ActivityAgendaState
|
||||
municipalityOptions?: RelationOption[]
|
||||
organizationOptions?: RelationOption[]
|
||||
knownTags?: string[]
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -180,7 +185,7 @@ export const ActivityAgenda = ({
|
|||
const [savingCount, setSavingCount] = useState(0)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [isNarrow, setIsNarrow] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(null)
|
||||
const [overlayRequest, setOverlayRequest] = useState<ActivityOverlayRequest | null>(null)
|
||||
// C110 — the adjacent-period reveal: which side is open and the events that
|
||||
// load (async, during the drag) for that period.
|
||||
const [swipePreview, setSwipePreview] = useState<{ direction: 'next' | 'prev' } | null>(null)
|
||||
|
|
@ -425,26 +430,40 @@ export const ActivityAgenda = ({
|
|||
// C101 — a consumed swipe ends with FullCalendar's dateClick in the day
|
||||
// view (single column: the hit never leaves the starting slot). The
|
||||
// navigation already happened — swallow the click so it does not open
|
||||
// the inline create.
|
||||
// the create overlay.
|
||||
if (suppressDateClickRef.current) return
|
||||
|
||||
const prefill = activitySlotPrefill(info)
|
||||
if (!prefill) return
|
||||
|
||||
const jsEvent = info.jsEvent as MouseEvent | undefined
|
||||
const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number'
|
||||
const anchor = hasPoint
|
||||
? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY }
|
||||
: (() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null
|
||||
})()
|
||||
|
||||
setCreateDraft({ ...prefill, anchor })
|
||||
// C123 — the click opens the modal overlay (desktop) / top drawer
|
||||
// (mobile) with the slot's window; there is no anchored popover anymore.
|
||||
setOverlayRequest({ kind: 'create', ...prefill })
|
||||
},
|
||||
[suppressDateClickRef],
|
||||
)
|
||||
|
||||
// C123 — clicking an event opens the EDIT overlay (Google Calendar style);
|
||||
// the event keeps its `url` so middle-click / open-in-new-tab still reaches
|
||||
// the detail page, and the overlay itself links to it ("Ver detalhes").
|
||||
const handleEventClick = useCallback((info: EventClickInfo) => {
|
||||
info.jsEvent.preventDefault()
|
||||
const id = Number(info.event.id)
|
||||
if (!Number.isInteger(id)) return
|
||||
setOverlayRequest({ kind: 'edit', activityId: id })
|
||||
}, [])
|
||||
|
||||
// C123 — the FAB quick action "Nova atividade" and the filter strip's
|
||||
// "+" open the same create overlay with the default window (today 09:00,
|
||||
// the historical all-day slot fallback).
|
||||
const openDefaultCreate = useCallback(() => {
|
||||
const prefill = activityDefaultCreatePrefill()
|
||||
if (!prefill) return
|
||||
setOverlayRequest({ kind: 'create', ...prefill })
|
||||
}, [])
|
||||
|
||||
useBridgedQuickAction('openActivityCreate', openDefaultCreate)
|
||||
|
||||
const persistSchedule = useCallback(
|
||||
async ({
|
||||
id,
|
||||
|
|
@ -585,6 +604,7 @@ export const ActivityAgenda = ({
|
|||
datesSet={handleDatesSet}
|
||||
eventContent={renderEventContent}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={handleScheduleChange}
|
||||
eventResize={handleScheduleChange}
|
||||
eventDragStart={handleEventDragStart}
|
||||
|
|
@ -607,15 +627,16 @@ export const ActivityAgenda = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<ActivityInlineCreate
|
||||
draft={createDraft}
|
||||
<ActivityOverlay
|
||||
request={overlayRequest}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
onClose={() => setCreateDraft(null)}
|
||||
onCreated={() => {
|
||||
setCreateDraft(null)
|
||||
onClose={() => setOverlayRequest(null)}
|
||||
onSaved={() => {
|
||||
setOverlayRequest(null)
|
||||
setReloadCount((count) => count + 1)
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
|
||||
import {
|
||||
|
|
@ -18,11 +19,7 @@ import {
|
|||
removeActivityAgendaOmniboxChip,
|
||||
type ActivityAgendaOmniboxAction,
|
||||
} from '@/utilities/activityAgendaOmnibox'
|
||||
import {
|
||||
buildActivityAgendaHref,
|
||||
buildActivityCreateHref,
|
||||
type ActivityAgendaState,
|
||||
} from '@/utilities/activityUi'
|
||||
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
type ActivityAgendaFiltersProps = {
|
||||
state: ActivityAgendaState
|
||||
|
|
@ -49,6 +46,7 @@ export const ActivityAgendaFilters = ({
|
|||
state,
|
||||
toHref: buildActivityAgendaHref,
|
||||
})
|
||||
const { context } = useCampaignQuickActionContext()
|
||||
const [draft, setDraft] = useState(state)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
|
|
@ -139,15 +137,14 @@ export const ActivityAgendaFilters = ({
|
|||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
type="button"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11"
|
||||
aria-label="Nova atividade"
|
||||
title="Nova atividade"
|
||||
onClick={() => context.openActivityCreate?.()}
|
||||
>
|
||||
<Link href={buildActivityCreateHref(state)}>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Link>
|
||||
<PlusIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import {
|
||||
ActivityOverlay,
|
||||
type ActivityOverlayRequest,
|
||||
} from '@/components/campaign/activity/ActivityOverlay'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNarrowMeasured } from '@/hooks/use-mobile'
|
||||
import { activityDefaultCreatePrefill } from '@/utilities/activityUi'
|
||||
|
||||
/**
|
||||
* C123 — the activities list page's create surface: the "Nova atividade"
|
||||
* button and the FAB quick action both open the agenda's create overlay with
|
||||
* the default window (today 09:00) and the list's municipality filter as
|
||||
* prefill. On save the page refreshes so the new row appears.
|
||||
*/
|
||||
export const ActivityCreateOverlayHost = ({
|
||||
municipalityId,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
knownTags,
|
||||
}: {
|
||||
municipalityId?: number
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
knownTags: string[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isNarrow } = useNarrowMeasured(640)
|
||||
const [request, setRequest] = useState<ActivityOverlayRequest | null>(null)
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
const prefill = activityDefaultCreatePrefill()
|
||||
if (!prefill) return
|
||||
setRequest({ kind: 'create', ...prefill })
|
||||
}, [])
|
||||
useBridgedQuickAction('openActivityCreate', openCreate)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" className="min-h-11" onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova atividade
|
||||
</Button>
|
||||
<ActivityOverlay
|
||||
request={request}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={municipalityId ? { municipality: municipalityId } : undefined}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
onClose={() => setRequest(null)}
|
||||
onSaved={() => {
|
||||
setRequest(null)
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ import {
|
|||
floorToMinuteStep,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaCivilDateLabel,
|
||||
formatBahiaCivilDateTimeLabel,
|
||||
hourOptions,
|
||||
minuteOptionsForStep,
|
||||
parseBahiaDateTimeInput,
|
||||
|
|
@ -27,20 +26,18 @@ import {
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* C97 — shadcn day + time picker for the agenda quick create. The value
|
||||
* contract is the civil `YYYY-MM-DDTHH:mm` string the old `datetime-local`
|
||||
* used, so parse/validation on submit is untouched. 24h by construction:
|
||||
* the trigger formats the civil string directly and the selects list
|
||||
* 00–23 / 00–45, so no browser locale or Intl hour cycle can inject AM/PM.
|
||||
* The parent renders the surrounding Field/FieldError; this component is the
|
||||
* control itself (trigger + popover with calendar and step selects).
|
||||
* C97/C123 — the date+time control for the agenda overlay. C123 split it: the
|
||||
* day part is a calendar trigger (popover on desktop, nested bottom sheet on
|
||||
* mobile) and the Hora/Minuto selects render INLINE beside it, always visible
|
||||
* — nothing hides below the calendar anymore. The value contract stays the
|
||||
* civil `YYYY-MM-DDTHH:mm` string, so parse/validation on submit is untouched.
|
||||
* 24h by construction: the trigger formats the civil date directly and the
|
||||
* selects list 00–23 / 00–45, so no browser locale or Intl hour cycle can
|
||||
* inject AM/PM. The parent renders the surrounding Field/FieldError; this
|
||||
* component is the control itself.
|
||||
*
|
||||
* C103 — on narrow viewports the picker opens as a nested bottom sheet
|
||||
* (calendar + hour/minute fully on screen, "Pronto" applies and closes),
|
||||
* keeping the desktop popover untouched.
|
||||
*
|
||||
* C104 — `timeVisible={false}` is the all-day mode: the trigger formats only
|
||||
* the date and the picker renders just the calendar (no time selects).
|
||||
* C104 — `timeVisible={false}` is the all-day mode: only the date trigger
|
||||
* renders, the time selects disappear.
|
||||
*/
|
||||
export const ActivityDateTimeField = ({
|
||||
id,
|
||||
|
|
@ -59,19 +56,21 @@ export const ActivityDateTimeField = ({
|
|||
invalid?: boolean
|
||||
/** FieldError id to announce on focus — parity with the input it replaces. */
|
||||
errorId?: string
|
||||
/** Renders the picker as a nested bottom sheet instead of a popover. */
|
||||
/** Renders the calendar picker as a nested bottom sheet instead of a popover. */
|
||||
isNarrow?: boolean
|
||||
/** Field name used in the sheet header (e.g. "Início"). */
|
||||
/** Field name used in the sheet header and the time-select a11y labels (e.g. "Início"). */
|
||||
label?: string
|
||||
/** Marks the required fields with a visible asterisk on the narrow trigger. */
|
||||
required?: boolean
|
||||
/** When false, hide the time selects and label the trigger with the date only. */
|
||||
/** When false, hide the time selects (all-day mode). */
|
||||
timeVisible?: boolean
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const civil = floorToMinuteStep(value)
|
||||
const datePart = civil.slice(0, 10)
|
||||
const timePart = civil.slice(11)
|
||||
// A draft without a startAt (tour drafts) renders the controls empty: the
|
||||
// selects default to 00:00 and picking a date seeds the time part.
|
||||
const timePart = civil ? civil.slice(11) : '00:00'
|
||||
const hour = timePart.slice(0, 2)
|
||||
const minute = timePart.slice(3, 5)
|
||||
|
||||
|
|
@ -80,12 +79,22 @@ export const ActivityDateTimeField = ({
|
|||
onValueChange(`${formatBahiaCivilDate(date)}T${timePart}`)
|
||||
}
|
||||
|
||||
const setHour = (next: string) => onValueChange(`${datePart}T${next}:${minute}`)
|
||||
const setMinute = (next: string) => onValueChange(`${datePart}T${hour}:${next}`)
|
||||
// Time edits are a no-op until a date exists: a draft without a startAt
|
||||
// (tour drafts) must not emit a corrupt civil string like `T09:00`.
|
||||
const setHour = (next: string) => {
|
||||
if (!datePart) return
|
||||
onValueChange(`${datePart}T${next}:${minute}`)
|
||||
}
|
||||
const setMinute = (next: string) => {
|
||||
if (!datePart) return
|
||||
onValueChange(`${datePart}T${hour}:${next}`)
|
||||
}
|
||||
|
||||
const selectedInstant = parseBahiaDateTimeInput(`${datePart}T12:00`)
|
||||
const selectedInstant = parseBahiaDateTimeInput(
|
||||
`${datePart || formatBahiaCivilDate(new Date())}T12:00`,
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
const dateTrigger = (
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
|
|
@ -104,7 +113,7 @@ export const ActivityDateTimeField = ({
|
|||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
|
||||
{civil ? formatBahiaCivilDateLabel(civil) : 'Selecionar data'}
|
||||
</span>
|
||||
{isNarrow && required ? (
|
||||
<span aria-hidden="true" className="font-semibold text-destructive">
|
||||
|
|
@ -119,90 +128,92 @@ export const ActivityDateTimeField = ({
|
|||
</Button>
|
||||
)
|
||||
|
||||
const pickerContent = (
|
||||
<>
|
||||
{selectedInstant ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={ptBR}
|
||||
timeZone="America/Bahia"
|
||||
selected={new Date(selectedInstant)}
|
||||
onSelect={setDate}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
) : null}
|
||||
{timeVisible ? (
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
const calendarContent = selectedInstant ? (
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={ptBR}
|
||||
timeZone="America/Bahia"
|
||||
selected={new Date(selectedInstant)}
|
||||
onSelect={setDate}
|
||||
className="w-full p-2"
|
||||
/>
|
||||
) : null
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>
|
||||
{label ? `${label} — ` : ''}
|
||||
{selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-2">
|
||||
{pickerContent}
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={() => setOpen(false)}>
|
||||
Pronto
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const timeSelects = timeVisible ? (
|
||||
<div
|
||||
className={cn('flex min-w-0 gap-1.5', isNarrow ? 'shrink-0' : 'shrink-0 sm:gap-2')}
|
||||
role="group"
|
||||
aria-label={`Horário de ${label ?? 'início'}`}
|
||||
>
|
||||
{(
|
||||
[
|
||||
['Hora', hour, setHour],
|
||||
['Minuto', minute, setMinute],
|
||||
] as const
|
||||
).map(([part, value, onChange]) => (
|
||||
<div key={part} className={cn('flex flex-col gap-1', isNarrow ? 'w-16' : 'w-20')}>
|
||||
{!isNarrow ? (
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">{part}</span>
|
||||
) : null}
|
||||
<NativeSelect
|
||||
aria-label={`${part} de ${label ?? 'início'}`}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={cn(
|
||||
'w-full **:data-[slot=native-select]:min-h-11',
|
||||
isNarrow &&
|
||||
'**:data-[slot=native-select]:min-h-11 **:data-[slot=native-select]:rounded-none **:data-[slot=native-select]:border-0 **:data-[slot=native-select]:bg-transparent **:data-[slot=native-select]:px-1',
|
||||
)}
|
||||
>
|
||||
{(part === 'Hora' ? hourOptions : minuteOptionsForStep()).map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{part === 'Hora' ? `${option}h` : option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
|
||||
>
|
||||
{pickerContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className={cn('flex w-full items-start gap-1.5 sm:gap-2', isNarrow && 'min-h-11')}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isNarrow ? (
|
||||
<>
|
||||
{dateTrigger}
|
||||
<Drawer open={open} onOpenChange={setOpen} showSwipeHandle>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>
|
||||
{label ? `${label} — ` : ''}
|
||||
{selectedInstant ? formatBahiaCivilDate(new Date(selectedInstant)) : datePart}
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-2">
|
||||
{calendarContent}
|
||||
</div>
|
||||
<DrawerFooter className="border-t">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={() => setOpen(false)}>
|
||||
Pronto
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{dateTrigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="max-h-[22rem] w-80 overflow-y-auto p-0 overscroll-contain"
|
||||
>
|
||||
{calendarContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{timeSelects}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
60
src/components/campaign/activity/ActivityEditOverlayHost.tsx
Normal file
60
src/components/campaign/activity/ActivityEditOverlayHost.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
import { PencilIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import {
|
||||
ActivityOverlay,
|
||||
type ActivityOverlayRequest,
|
||||
} from '@/components/campaign/activity/ActivityOverlay'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useNarrowMeasured } from '@/hooks/use-mobile'
|
||||
|
||||
/**
|
||||
* C123 — the activity detail page's edit surface: the "Editar" button and the
|
||||
* FAB quick action both open the same agenda overlay (edit mode). The overlay
|
||||
* fetches the form view model by id through `loadActivityEditDraft`; on save
|
||||
* the page refreshes (title is immutable, so the URL stays valid).
|
||||
*/
|
||||
export const ActivityEditOverlayHost = ({
|
||||
activityId,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
knownTags,
|
||||
}: {
|
||||
activityId: number
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
knownTags: string[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isNarrow } = useNarrowMeasured(640)
|
||||
const [request, setRequest] = useState<ActivityOverlayRequest | null>(null)
|
||||
|
||||
const openEdit = useCallback(() => setRequest({ kind: 'edit', activityId }), [activityId])
|
||||
useBridgedQuickAction('openActivityEdit', openEdit)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" className="min-h-11" onClick={openEdit}>
|
||||
<PencilIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Editar
|
||||
</Button>
|
||||
<ActivityOverlay
|
||||
request={request}
|
||||
isNarrow={isNarrow}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
onClose={() => setRequest(null)}
|
||||
onSaved={() => {
|
||||
setRequest(null)
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useActionState, useState } from 'react'
|
||||
|
||||
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
||||
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
|
||||
import type { ContactComboboxOption } from '@/components/campaign/shared/ContactCombobox'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
} from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import {
|
||||
ResponsibleMultiSelect,
|
||||
type ResponsibleOption,
|
||||
} from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime'
|
||||
import type { ActivityCreatePrefill } from '@/utilities/activityUi'
|
||||
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
||||
export type ActivityFormState = {
|
||||
message?: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
existingHref?: string
|
||||
submittedTitle?: string
|
||||
}
|
||||
|
||||
type ActivityFormAction = (
|
||||
state: ActivityFormState,
|
||||
formData: FormData,
|
||||
) => Promise<ActivityFormState>
|
||||
|
||||
export type ActivityFormFieldsProps = {
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
activity?: ActivityFormViewModel
|
||||
initialValues?: ActivityCreatePrefill
|
||||
fieldErrors?: Record<string, string[]>
|
||||
submittedTitle?: string
|
||||
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
|
||||
searchResponsibles: (query: string) => Promise<ResponsibleOption[]>
|
||||
/** Tags already used across activities, for autocomplete. */
|
||||
knownTags?: string[]
|
||||
}
|
||||
|
||||
const ActivityFormFields = ({
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
activity,
|
||||
initialValues,
|
||||
fieldErrors = {},
|
||||
submittedTitle,
|
||||
searchContacts,
|
||||
searchResponsibles,
|
||||
knownTags = [],
|
||||
}: ActivityFormFieldsProps) => {
|
||||
const errorFor = (name: string) => fieldError(fieldErrors, name)
|
||||
const initialResponsibles = activity?.responsibles ?? []
|
||||
const initialStartAt = activity?.startAt ?? initialValues?.startAt
|
||||
const initialEndAt = activity?.endAt ?? initialValues?.endAt
|
||||
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
|
||||
const [allDay, setAllDay] = useState(activity?.allDay ?? initialValues?.allDay ?? false)
|
||||
// C104 — controlled so switching the toggle keeps the date the staff typed:
|
||||
// the date input reads/preserves the date part, the datetime input the
|
||||
// whole civil value; on submit the parser branches on the allDay checkbox.
|
||||
const [startAt, setStartAt] = useState(
|
||||
initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : '',
|
||||
)
|
||||
const [endAt, setEndAt] = useState(
|
||||
initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : '',
|
||||
)
|
||||
const startDateOnly = startAt.slice(0, 10)
|
||||
const endDateOnly = endAt.slice(0, 10)
|
||||
const setStartDate = (date: string) => setStartAt(`${date}T${startAt.slice(11) || '00:00'}`)
|
||||
const setEndDate = (date: string) => setEndAt(`${date}T${endAt.slice(11) || '00:00'}`)
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
<input type="hidden" name="status" value="confirmado" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações básicas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="title">Título *</FieldLabel>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={submittedTitle ?? initialValues?.title ?? activity?.title}
|
||||
minLength={2}
|
||||
maxLength={160}
|
||||
className="min-h-11"
|
||||
required
|
||||
readOnly={Boolean(activity)}
|
||||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
|
||||
<FieldLabel>Tags</FieldLabel>
|
||||
<ActivityTagInput
|
||||
initialTags={initialValues?.tags ?? activity?.tags ?? []}
|
||||
knownTags={knownTags}
|
||||
error={errorFor('tagsJson')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('description'))}>
|
||||
<FieldLabel htmlFor="description">Descrição</FieldLabel>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={activity?.description ?? ''}
|
||||
maxLength={4000}
|
||||
className="min-h-24"
|
||||
aria-invalid={Boolean(errorFor('description'))}
|
||||
aria-describedby={errorFor('description') ? 'description-error' : undefined}
|
||||
/>
|
||||
{errorFor('description') ? (
|
||||
<FieldError id="description-error">{errorFor('description')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<FieldLabel>
|
||||
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
|
||||
<Checkbox name="deputyPresent" defaultChecked={activity?.deputyPresent ?? false} />
|
||||
<span>Deputado presente</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data e horário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FieldLabel>
|
||||
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
name="allDay"
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
|
||||
{allDay ? (
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
type="date"
|
||||
value={startDateOnly}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
required
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
type="datetime-local"
|
||||
value={startAt}
|
||||
onChange={(event) => setStartAt(event.target.value)}
|
||||
required
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
|
||||
/>
|
||||
)}
|
||||
{errorFor('startAt') ? (
|
||||
<FieldError id="startAt-error">{errorFor('startAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('endAt'))}>
|
||||
<FieldLabel htmlFor="endAt">Término</FieldLabel>
|
||||
{allDay ? (
|
||||
<Input
|
||||
id="endAt"
|
||||
name="endAt"
|
||||
type="date"
|
||||
value={endDateOnly}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('endAt'))}
|
||||
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="endAt"
|
||||
name="endAt"
|
||||
type="datetime-local"
|
||||
value={endAt}
|
||||
onChange={(event) => setEndAt(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('endAt'))}
|
||||
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
|
||||
/>
|
||||
)}
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Onde</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="municipality">Município *</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality"
|
||||
name="municipality"
|
||||
defaultValue={initialMunicipalityId ? String(initialMunicipalityId) : ''}
|
||||
required
|
||||
aria-invalid={Boolean(errorFor('municipality'))}
|
||||
aria-describedby={errorFor('municipality') ? 'municipality-error' : undefined}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
>
|
||||
<NativeSelectOption value="">Selecione o município</NativeSelectOption>
|
||||
{municipalityOptions.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('locality'))}>
|
||||
<FieldLabel htmlFor="locality">Local (bairro, endereço ou referência)</FieldLabel>
|
||||
<Input
|
||||
id="locality"
|
||||
name="locality"
|
||||
defaultValue={activity?.locality ?? ''}
|
||||
maxLength={160}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('locality'))}
|
||||
aria-describedby={errorFor('locality') ? 'locality-error' : undefined}
|
||||
/>
|
||||
{errorFor('locality') ? (
|
||||
<FieldError id="locality-error">{errorFor('locality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pessoas e organizações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
value={initialResponsibles}
|
||||
error={errorFor('responsiblesJson')}
|
||||
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
|
||||
search={searchResponsibles}
|
||||
/>
|
||||
|
||||
<RelationMultiSelect
|
||||
name="organizations"
|
||||
label="Organizações apoiadoras"
|
||||
options={organizationOptions}
|
||||
initialSelectedIDs={activity?.organizationIDs ?? []}
|
||||
error={errorFor('organizations')}
|
||||
placeholder="Adicionar organização…"
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tarefas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityTaskFields
|
||||
initialTasks={activity?.tasks ?? []}
|
||||
searchContacts={searchContacts}
|
||||
error={errorFor('tasksJson')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demandas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActivityDemandFields error={errorFor('demandsJson')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActivityForm = ({
|
||||
action,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
activity,
|
||||
initialValues,
|
||||
fieldErrors: externalFieldErrors,
|
||||
submittedTitle,
|
||||
searchContacts,
|
||||
searchResponsibles,
|
||||
knownTags,
|
||||
submitLabel,
|
||||
cancelHref,
|
||||
}: ActivityFormFieldsProps & {
|
||||
action: ActivityFormAction
|
||||
submitLabel?: string
|
||||
cancelHref?: string
|
||||
}) => {
|
||||
const [state, submitAction, isPending] = useActionState(action, {})
|
||||
|
||||
return (
|
||||
<form action={submitAction} className="flex flex-col gap-5">
|
||||
<ActivityFormFields
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
activity={activity}
|
||||
initialValues={initialValues}
|
||||
fieldErrors={state.fieldErrors ?? externalFieldErrors}
|
||||
submittedTitle={state.submittedTitle ?? submittedTitle}
|
||||
searchContacts={searchContacts}
|
||||
searchResponsibles={searchResponsibles}
|
||||
knownTags={knownTags}
|
||||
/>
|
||||
|
||||
{state.existingHref ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Já existe uma atividade com este título</AlertTitle>
|
||||
<AlertDescription>
|
||||
<a
|
||||
href={state.existingHref}
|
||||
className="font-medium text-primary underline underline-offset-4"
|
||||
>
|
||||
Ver atividade existente
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Nada foi gravado</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link
|
||||
href={
|
||||
activity
|
||||
? `/campanha/atividades/${activity.slug}`
|
||||
: (cancelHref ?? '/campanha/agenda')
|
||||
}
|
||||
>
|
||||
Cancelar
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending} className="min-h-11">
|
||||
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
{submitLabel ?? (activity ? 'Salvar alterações' : 'Criar compromisso')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,527 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
|
||||
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { allDayEndInstant, allDayRangeValid, allDayStartInstant } from '@/lib/activityAllDay'
|
||||
import {
|
||||
formatBahiaDateTimeLabel,
|
||||
formatIsoAsBahiaDateTimeInput,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import { activityResponsibleSchema, type ActivityCreateInput } from '@/lib/schemas/activity'
|
||||
import { buildActivityCreateHref, type ActivityAgendaState } from '@/utilities/activityUi'
|
||||
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
|
||||
|
||||
/**
|
||||
* C91 — the Google-Calendar-like quick create anchored on a calendar slot.
|
||||
* One content component is rendered inside a Popover (desktop, anchored at the
|
||||
* click) or a bottom Drawer (mobile); saving never navigates and the parent
|
||||
* refetches the visible window (`onCreated`).
|
||||
*
|
||||
* C103 — on narrow viewports the drawer opens from the TOP (`swipeDirection="up"`)
|
||||
* filling the usable height, the form switches to a label-less list variant
|
||||
* (placeholders + divider rows, `sr-only` labels) with a fixed footer, and the
|
||||
* date/time picker opens as a nested bottom sheet.
|
||||
*/
|
||||
|
||||
export type ActivityInlineCreateDraft = {
|
||||
startAt: string
|
||||
endAt: string
|
||||
anchor: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
type ActivityInlineCreateProps = {
|
||||
draft: ActivityInlineCreateDraft | null
|
||||
isNarrow: boolean
|
||||
agendaState: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
knownTags?: string[]
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
|
||||
options.map((option) => ({ value: String(option.id), label: option.name }))
|
||||
|
||||
/**
|
||||
* Bounded JSON-array read for the inline sheet's `responsiblesJson` hidden
|
||||
* input: same 4 KB cap as the full form's `boundedJsonFormValue`, fail-closed
|
||||
* on malformed or oversized payloads (the component generates the JSON, so a
|
||||
* violation means tampering, not user input).
|
||||
*/
|
||||
const INLINE_JSON_MAX_BYTES = 4_000
|
||||
|
||||
const parseInlineJsonArray = (raw: FormDataEntryValue | null): unknown[] | undefined => {
|
||||
if (typeof raw !== 'string' || raw.length > INLINE_JSON_MAX_BYTES) return undefined
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const parseInlineResponsibles = (
|
||||
raw: FormDataEntryValue | null,
|
||||
): ActivityCreateInput['responsible'] => {
|
||||
const parsed = parseInlineJsonArray(raw)
|
||||
if (!parsed) return undefined
|
||||
|
||||
const entries: Array<{
|
||||
relationTo: 'campaignUser' | 'leadership' | 'stateDeputy'
|
||||
value: number
|
||||
}> = []
|
||||
for (const item of parsed) {
|
||||
const result = activityResponsibleSchema.safeParse(item)
|
||||
if (result.success) entries.push(result.data)
|
||||
}
|
||||
return entries.length > 0 ? entries : undefined
|
||||
}
|
||||
|
||||
/** List-row styling for the label-less mobile sheet (C103). The focus ring is
|
||||
* kept (subtle) so keyboard users keep a visible focus indicator. */
|
||||
const sheetFieldInputClass =
|
||||
'rounded-none border-0 bg-transparent px-0 py-0 focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
|
||||
type InlineDateTimeFieldProps = {
|
||||
sheet: boolean
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
invalid: boolean
|
||||
error?: string
|
||||
required?: boolean
|
||||
/** C104 — false hides the time selects (all-day mode). */
|
||||
timeVisible?: boolean
|
||||
}
|
||||
|
||||
const InlineDateTimeField = ({
|
||||
sheet,
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
invalid,
|
||||
error,
|
||||
required = false,
|
||||
timeVisible = true,
|
||||
}: InlineDateTimeFieldProps) => (
|
||||
<Field data-invalid={invalid}>
|
||||
<FieldLabel htmlFor={id} className={sheet ? 'sr-only' : undefined}>
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
</FieldLabel>
|
||||
<ActivityDateTimeField
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
invalid={invalid}
|
||||
errorId={error ? `${id}-error` : undefined}
|
||||
// The sheet variant implies the narrow viewport (C103); the picker keeps
|
||||
// the popover whenever the create overlay is a popover.
|
||||
isNarrow={sheet}
|
||||
label={label}
|
||||
required={required}
|
||||
timeVisible={timeVisible}
|
||||
/>
|
||||
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
|
||||
const ActivityInlineCreateForm = ({
|
||||
startAt,
|
||||
endAt,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
variant,
|
||||
onCreated,
|
||||
}: {
|
||||
startAt: string
|
||||
endAt: string
|
||||
agendaState: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
knownTags?: string[]
|
||||
variant: 'popover' | 'sheet'
|
||||
onCreated: () => void
|
||||
}) => {
|
||||
const sheet = variant === 'sheet'
|
||||
const [title, setTitle] = useState('')
|
||||
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
|
||||
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
|
||||
const [allDay, setAllDay] = useState(false)
|
||||
const [locality, setLocality] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const preselectedMunicipalityId =
|
||||
agendaState.municipality &&
|
||||
municipalityOptions.some((option) => option.id === agendaState.municipality)
|
||||
? String(agendaState.municipality)
|
||||
: ''
|
||||
const [municipalityValue, setMunicipalityValue] = useState(preselectedMunicipalityId)
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
|
||||
const [failedMessage, setFailedMessage] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const errorFor = (name: string) => fieldErrors[name]?.[0]
|
||||
|
||||
const allDayStartDate = start.slice(0, 10)
|
||||
const allDayEndDate = end ? end.slice(0, 10) : allDayStartDate
|
||||
|
||||
// C105 — the href mirrors the LIVE state (times edited in the sheet, typed
|
||||
// tags), not the slot draft, so "Mais detalhes" carries everything the user
|
||||
// actually filled.
|
||||
const moreDetailsHref = buildActivityCreateHref(agendaState, {
|
||||
...(allDay
|
||||
? { allDay: true, startAt: allDayStartDate, endAt: allDayEndDate }
|
||||
: {
|
||||
startAt: parseBahiaDateTimeInput(start) ?? undefined,
|
||||
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
|
||||
}),
|
||||
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
|
||||
title: title.trim() || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
})
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const trimmedTitle = title.trim()
|
||||
const municipalityId = Number(municipalityValue)
|
||||
const startIso = parseBahiaDateTimeInput(start)
|
||||
const endIso = end ? parseBahiaDateTimeInput(end) : undefined
|
||||
|
||||
const nextFieldErrors: Record<string, string[]> = {}
|
||||
if (!trimmedTitle) {
|
||||
nextFieldErrors.title = ['Informe o título do compromisso.']
|
||||
} else if (trimmedTitle.length < 2) {
|
||||
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
|
||||
}
|
||||
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
|
||||
if (allDay) {
|
||||
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
|
||||
if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
|
||||
nextFieldErrors.endAt = ['A data de término deve ser igual ou posterior à de início.']
|
||||
}
|
||||
} else {
|
||||
if (!startIso)
|
||||
nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.']
|
||||
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) {
|
||||
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.']
|
||||
}
|
||||
}
|
||||
if (Object.keys(nextFieldErrors).length > 0) {
|
||||
setFieldErrors(nextFieldErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setFieldErrors({})
|
||||
setFailedMessage(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const responsible = parseInlineResponsibles(formData.get('responsiblesJson'))
|
||||
const result = await createActivityInline({
|
||||
title: trimmedTitle,
|
||||
municipality: municipalityId,
|
||||
...(allDay
|
||||
? {
|
||||
allDay: true,
|
||||
startAt: allDayStartInstant(allDayStartDate),
|
||||
...(end ? { endAt: allDayEndInstant(allDayEndDate) } : {}),
|
||||
}
|
||||
: {
|
||||
startAt: startIso as string,
|
||||
...(endIso ? { endAt: endIso } : {}),
|
||||
}),
|
||||
...(locality.trim() ? { locality: locality.trim() } : {}),
|
||||
...(responsible ? { responsible } : {}),
|
||||
// C105 — the tags mirror state (every chip mutation fires `onChange`),
|
||||
// so the payload reads the same source the "Mais detalhes" href uses.
|
||||
...(tags.length > 0 ? { tags } : {}),
|
||||
})
|
||||
if (result.ok) {
|
||||
onCreated()
|
||||
return
|
||||
}
|
||||
setSaving(false)
|
||||
setFieldErrors(result.fieldErrors ?? {})
|
||||
setFailedMessage(result.message)
|
||||
toast.error(result.message)
|
||||
} catch {
|
||||
setSaving(false)
|
||||
const message = 'Não foi possível criar o compromisso. Tente novamente.'
|
||||
setFailedMessage(message)
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
const startEndFields = (
|
||||
<>
|
||||
<InlineDateTimeField
|
||||
sheet={sheet}
|
||||
id="inline-startAt"
|
||||
label="Início"
|
||||
value={start}
|
||||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
error={errorFor('startAt')}
|
||||
required
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
<InlineDateTimeField
|
||||
sheet={sheet}
|
||||
id="inline-endAt"
|
||||
label="Término"
|
||||
value={end}
|
||||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
error={errorFor('endAt')}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col gap-4'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
|
||||
: 'flex flex-col gap-4'
|
||||
}
|
||||
>
|
||||
<div className={sheet ? 'divide-y divide-border' : undefined}>
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="inline-title" className={sheet ? 'sr-only' : undefined}>
|
||||
Título *
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={160}
|
||||
minLength={2}
|
||||
placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'inline-title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="inline-title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
{/* C104 — all-day toggle: hides the time part of Início/Término. */}
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className={
|
||||
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
aria-label="Todo o dia"
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
|
||||
{sheet ? (
|
||||
startEndFields
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>
|
||||
)}
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="inline-municipality" className={sheet ? 'sr-only' : undefined}>
|
||||
Município *
|
||||
</FieldLabel>
|
||||
<StrictCombobox
|
||||
id="inline-municipality"
|
||||
options={inlineMunicipalityOptions(municipalityOptions)}
|
||||
value={municipalityValue}
|
||||
onValueChange={setMunicipalityValue}
|
||||
error={errorFor('municipality')}
|
||||
placeholder={sheet ? 'Município *' : undefined}
|
||||
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
|
||||
/>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="inline-municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
|
||||
Local (opcional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality" className={sheet ? 'sr-only' : undefined}>
|
||||
Local (opcional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className={sheet ? 'py-1.5' : undefined}>
|
||||
<Field>
|
||||
<FieldLabel className={sheet ? 'sr-only' : undefined}>Tags</FieldLabel>
|
||||
<ActivityTagInput
|
||||
knownTags={knownTags}
|
||||
onChange={setTags}
|
||||
compact={sheet}
|
||||
placeholder={sheet ? 'Adicionar tags' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
search={searchActivityResponsibleOptionsAction}
|
||||
labelClassName={sheet ? 'sr-only' : undefined}
|
||||
emptyText={sheet ? 'Adicionar responsáveis' : undefined}
|
||||
triggerClassName={
|
||||
sheet
|
||||
? 'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{failedMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{failedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
|
||||
: 'mt-1 flex items-center justify-between gap-3 border-t pt-3'
|
||||
}
|
||||
>
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={moreDetailsHref}>Mais detalhes</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
{saving ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActivityInlineCreate = ({
|
||||
draft,
|
||||
isNarrow,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: ActivityInlineCreateProps) => {
|
||||
if (!draft) return null
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} swipeDirection="up">
|
||||
<DrawerContent className="h-[calc(100dvh-1rem)] max-h-[calc(100dvh-1rem)]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Nova atividade</DrawerTitle>
|
||||
<DrawerDescription>{formatBahiaDateTimeLabel(draft.startAt)}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
variant="sheet"
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(next) => (next ? undefined : onClose())}>
|
||||
<div
|
||||
className="fixed size-0"
|
||||
style={{ left: draft.anchor?.x ?? 0, top: draft.anchor?.y ?? 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<PopoverAnchor />
|
||||
</div>
|
||||
{/* C105 — the tags field made the sheet taller. The height is capped at
|
||||
the space Radix computed for the current placement
|
||||
(`--radix-popper-available-height`, set by its `size` middleware):
|
||||
the sheet always fits the viewport on its placed side and scrolls
|
||||
internally instead of clipping fields off-screen (a static cap is
|
||||
not enough — Radix runs shift before flip, so an overflowing
|
||||
popover can end up with its top fields out of view). */}
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="max-h-(--radix-popper-available-height) w-96 overflow-y-auto p-4"
|
||||
>
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
knownTags={knownTags}
|
||||
variant="popover"
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
659
src/components/campaign/activity/ActivityOverlay.tsx
Normal file
659
src/components/campaign/activity/ActivityOverlay.tsx
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
searchActivityContactOptions,
|
||||
searchActivityResponsibleOptionsAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import {
|
||||
createActivityOverlay,
|
||||
loadActivityEditDraft,
|
||||
updateActivityOverlay,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { ActivityDateTimeField } from '@/components/campaign/activity/ActivityDateTimeField'
|
||||
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
||||
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||
import { ActivityTaskFields } from '@/components/campaign/activity/ActivityTaskFields'
|
||||
import {
|
||||
RelationMultiSelect,
|
||||
type RelationOption,
|
||||
} from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { allDayRangeValid } from '@/lib/activityAllDay'
|
||||
import { ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE } from '@/lib/activityOverlayMessages'
|
||||
import {
|
||||
formatBahiaDateTimeLabel,
|
||||
formatIsoAsBahiaDateTimeInput,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
|
||||
|
||||
/**
|
||||
* C123 — the agenda's ONE create/edit surface. A modal Dialog (desktop) or the
|
||||
* C103 top drawer (mobile) hosting every section the full form had; creating
|
||||
* (slot click) and editing (event click / detail button) live here, and the
|
||||
* `/atividades/nova` / `/atividades/[slug]/editar` pages are gone.
|
||||
*
|
||||
* The submit posts FormData to `createActivityOverlay` / `updateActivityOverlay`
|
||||
* (the same server parsers the old full form used), never navigates, and the
|
||||
* parent refetches the visible window (`onSaved`). Edit mode loads the form
|
||||
* view model through `loadActivityEditDraft` (staff-scoped).
|
||||
*/
|
||||
|
||||
export type ActivityOverlayRequest =
|
||||
| { kind: 'create'; startAt: string; endAt: string }
|
||||
| { kind: 'edit'; activityId: number }
|
||||
|
||||
type ActivityOverlayProps = {
|
||||
request: ActivityOverlayRequest | null
|
||||
isNarrow: boolean
|
||||
/** Create-mode prefill: the agenda's active municipality filter. Edit mode ignores it. */
|
||||
agendaState?: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
knownTags?: string[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] =>
|
||||
options.map((option) => ({ value: String(option.id), label: option.name }))
|
||||
|
||||
/** List-row styling for the label-less mobile sheet (C103). */
|
||||
const sheetFieldInputClass =
|
||||
'rounded-none border-0 bg-transparent px-0 py-0 focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
|
||||
type OverlayDateTimeFieldProps = {
|
||||
sheet: boolean
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
invalid: boolean
|
||||
error?: string
|
||||
required?: boolean
|
||||
/** C104 — false hides the time selects (all-day mode). */
|
||||
timeVisible?: boolean
|
||||
}
|
||||
|
||||
const OverlayDateTimeField = ({
|
||||
sheet,
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
invalid,
|
||||
error,
|
||||
required = false,
|
||||
timeVisible = true,
|
||||
}: OverlayDateTimeFieldProps) => (
|
||||
<Field data-invalid={invalid}>
|
||||
<FieldLabel htmlFor={id} className={sheet ? 'sr-only' : undefined}>
|
||||
{label}
|
||||
{required ? ' *' : ''}
|
||||
</FieldLabel>
|
||||
<ActivityDateTimeField
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
invalid={invalid}
|
||||
errorId={error ? `${id}-error` : undefined}
|
||||
isNarrow={sheet}
|
||||
label={label}
|
||||
required={required}
|
||||
timeVisible={timeVisible}
|
||||
/>
|
||||
{error ? <FieldError id={`${id}-error`}>{error}</FieldError> : null}
|
||||
</Field>
|
||||
)
|
||||
|
||||
type ActivityOverlayFormProps = {
|
||||
variant: 'modal' | 'sheet'
|
||||
mode: 'create' | 'edit'
|
||||
createDraft?: { startAt: string; endAt: string }
|
||||
editDraft?: ActivityFormViewModel
|
||||
/** Create-mode prefill: the active municipality filter. Edit mode ignores it. */
|
||||
agendaState?: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
organizationOptions: RelationOption[]
|
||||
knownTags?: string[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const ActivityOverlayForm = ({
|
||||
variant,
|
||||
mode,
|
||||
createDraft,
|
||||
editDraft,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
knownTags,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: ActivityOverlayFormProps) => {
|
||||
const sheet = variant === 'sheet'
|
||||
const isEdit = mode === 'edit'
|
||||
const initialStartIso = isEdit ? editDraft?.startAt : createDraft?.startAt
|
||||
const initialEndIso = isEdit ? editDraft?.endAt : createDraft?.endAt
|
||||
const [title, setTitle] = useState(isEdit ? (editDraft?.title ?? '') : '')
|
||||
const [start, setStart] = useState(
|
||||
initialStartIso ? formatIsoAsBahiaDateTimeInput(initialStartIso) : '',
|
||||
)
|
||||
const [end, setEnd] = useState(initialEndIso ? formatIsoAsBahiaDateTimeInput(initialEndIso) : '')
|
||||
const [allDay, setAllDay] = useState(isEdit ? Boolean(editDraft?.allDay) : false)
|
||||
const [locality, setLocality] = useState(isEdit ? (editDraft?.locality ?? '') : '')
|
||||
const initialMunicipalityId = isEdit ? editDraft?.municipalityId : agendaState?.municipality
|
||||
const [municipalityValue, setMunicipalityValue] = useState(
|
||||
initialMunicipalityId &&
|
||||
municipalityOptions.some((option) => option.id === initialMunicipalityId)
|
||||
? String(initialMunicipalityId)
|
||||
: '',
|
||||
)
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({})
|
||||
const [failedMessage, setFailedMessage] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const errorFor = (name: string) => fieldErrors[name]?.[0]
|
||||
|
||||
const allDayStartDate = start.slice(0, 10)
|
||||
const allDayEndDate = end ? end.slice(0, 10) : allDayStartDate
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const formData = new FormData(event.currentTarget)
|
||||
const trimmedTitle = title.trim()
|
||||
const municipalityId = Number(municipalityValue)
|
||||
const startIso = parseBahiaDateTimeInput(start)
|
||||
const endIso = end ? parseBahiaDateTimeInput(end) : undefined
|
||||
|
||||
const nextFieldErrors: Record<string, string[]> = {}
|
||||
if (!isEdit && !trimmedTitle) {
|
||||
nextFieldErrors.title = ['Informe o título do compromisso.']
|
||||
} else if (!isEdit && trimmedTitle.length < 2) {
|
||||
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
|
||||
}
|
||||
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
|
||||
if (allDay) {
|
||||
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
|
||||
if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
|
||||
nextFieldErrors.endAt = ['A data de término deve ser igual ou posterior à de início.']
|
||||
}
|
||||
} else {
|
||||
if (!startIso)
|
||||
nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.']
|
||||
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) {
|
||||
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.']
|
||||
}
|
||||
}
|
||||
if (Object.keys(nextFieldErrors).length > 0) {
|
||||
setFieldErrors(nextFieldErrors)
|
||||
return
|
||||
}
|
||||
|
||||
setFieldErrors({})
|
||||
setFailedMessage(null)
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = isEdit
|
||||
? await updateActivityOverlay(formData)
|
||||
: await createActivityOverlay(formData)
|
||||
if (result.ok) {
|
||||
onSaved()
|
||||
return
|
||||
}
|
||||
setSaving(false)
|
||||
setFieldErrors(result.fieldErrors ?? {})
|
||||
setFailedMessage(result.message)
|
||||
toast.error(result.message)
|
||||
} catch {
|
||||
setSaving(false)
|
||||
setFailedMessage(ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE)
|
||||
toast.error(ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
const startEndFields = (
|
||||
<>
|
||||
<OverlayDateTimeField
|
||||
sheet={sheet}
|
||||
id="overlay-startAt"
|
||||
label="Início"
|
||||
value={start}
|
||||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
error={errorFor('startAt')}
|
||||
required
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
<OverlayDateTimeField
|
||||
sheet={sheet}
|
||||
id="overlay-endAt"
|
||||
label="Término"
|
||||
value={end}
|
||||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
error={errorFor('endAt')}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const basicInfoSection = (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="overlay-title" className={sheet ? 'sr-only' : undefined}>
|
||||
Título *
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="overlay-title"
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
maxLength={160}
|
||||
minLength={2}
|
||||
readOnly={isEdit}
|
||||
placeholder={sheet ? 'Adicionar título *' : 'Ex.: Café com apoiadores'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
autoComplete="off"
|
||||
aria-invalid={Boolean(errorFor('title'))}
|
||||
aria-describedby={errorFor('title') ? 'overlay-title-error' : undefined}
|
||||
/>
|
||||
{errorFor('title') ? (
|
||||
<FieldError id="overlay-title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<div className={sheet ? 'py-1.5' : undefined}>
|
||||
<Field>
|
||||
<FieldLabel className={sheet ? 'sr-only' : undefined}>Tags</FieldLabel>
|
||||
<ActivityTagInput
|
||||
initialTags={isEdit ? (editDraft?.tags ?? []) : []}
|
||||
knownTags={knownTags}
|
||||
compact={sheet}
|
||||
placeholder={sheet ? 'Adicionar tags' : undefined}
|
||||
error={errorFor('tagsJson')}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="overlay-description" className={sheet ? 'sr-only' : undefined}>
|
||||
Descrição
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="overlay-description"
|
||||
name="description"
|
||||
defaultValue={isEdit ? (editDraft?.description ?? '') : ''}
|
||||
maxLength={4000}
|
||||
className={sheet ? 'min-h-24 rounded-none border-0 px-0' : 'min-h-24'}
|
||||
placeholder={sheet ? 'Descrição' : undefined}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className={
|
||||
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
name="deputyPresent"
|
||||
defaultChecked={isEdit ? editDraft?.deputyPresent : false}
|
||||
aria-label="Deputado presente"
|
||||
/>
|
||||
<span>Deputado presente</span>
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
|
||||
const scheduleSection = (
|
||||
<>
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className={
|
||||
sheet ? 'min-h-11 gap-2 rounded-none border-0 px-0' : 'min-h-11 rounded-lg border p-3'
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
aria-label="Todo o dia"
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
|
||||
{sheet ? startEndFields : <div className="grid gap-3 sm:grid-cols-2">{startEndFields}</div>}
|
||||
</>
|
||||
)
|
||||
|
||||
const whereSection = (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="overlay-municipality" className={sheet ? 'sr-only' : undefined}>
|
||||
Município *
|
||||
</FieldLabel>
|
||||
<StrictCombobox
|
||||
id="overlay-municipality"
|
||||
options={inlineMunicipalityOptions(municipalityOptions)}
|
||||
value={municipalityValue}
|
||||
onValueChange={setMunicipalityValue}
|
||||
error={errorFor('municipality')}
|
||||
placeholder={sheet ? 'Município *' : undefined}
|
||||
className={sheet ? 'min-h-11 rounded-none border-0' : undefined}
|
||||
/>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="overlay-municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('locality'))}>
|
||||
<FieldLabel htmlFor="overlay-locality" className={sheet ? 'sr-only' : undefined}>
|
||||
Local (opcional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="overlay-locality"
|
||||
name="locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder={sheet ? 'Local (opcional)' : 'Bairro, endereço ou referência'}
|
||||
className={sheet ? `min-h-11 ${sheetFieldInputClass}` : 'min-h-11'}
|
||||
/>
|
||||
{errorFor('locality') ? (
|
||||
<FieldError id="overlay-locality-error">{errorFor('locality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
|
||||
const peopleSection = (
|
||||
<>
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
value={isEdit ? (editDraft?.responsibles ?? []) : []}
|
||||
error={errorFor('responsiblesJson')}
|
||||
description="Quem conduz este compromisso: equipe da campanha, liderança ou dobradinha — um ou vários."
|
||||
labelClassName={sheet ? 'sr-only' : undefined}
|
||||
emptyText={sheet ? 'Adicionar responsáveis' : undefined}
|
||||
search={searchActivityResponsibleOptionsAction}
|
||||
triggerClassName={
|
||||
sheet
|
||||
? 'rounded-none border-0 bg-transparent px-0 hover:bg-transparent focus-visible:ring-2 focus-visible:ring-primary/30'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<RelationMultiSelect
|
||||
name="organizations"
|
||||
label="Organizações apoiadoras"
|
||||
options={organizationOptions}
|
||||
initialSelectedIDs={isEdit ? (editDraft?.organizationIDs ?? []) : []}
|
||||
error={errorFor('organizations')}
|
||||
placeholder="Adicionar organização…"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const tasksSection = (
|
||||
<ActivityTaskFields
|
||||
initialTasks={isEdit ? (editDraft?.tasks ?? []) : []}
|
||||
searchContacts={searchActivityContactOptions}
|
||||
error={errorFor('tasksJson')}
|
||||
/>
|
||||
)
|
||||
|
||||
const demandsSection = <ActivityDemandFields error={errorFor('demandsJson')} />
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'}
|
||||
>
|
||||
{isEdit ? <input type="hidden" name="id" value={editDraft?.id ?? ''} /> : null}
|
||||
<input type="hidden" name="status" value="confirmado" />
|
||||
{allDay ? <input type="hidden" name="allDay" value="on" /> : null}
|
||||
<input type="hidden" name="startAt" value={allDay ? allDayStartDate : start} />
|
||||
<input type="hidden" name="endAt" value={allDay ? allDayEndDate : end} />
|
||||
<input type="hidden" name="municipality" value={municipalityValue} />
|
||||
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
|
||||
: 'max-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6'
|
||||
}
|
||||
>
|
||||
{sheet ? (
|
||||
<div className="divide-y divide-border">
|
||||
{basicInfoSection}
|
||||
{scheduleSection}
|
||||
{whereSection}
|
||||
<div className="py-1.5">{peopleSection}</div>
|
||||
<div className="py-1.5">{tasksSection}</div>
|
||||
<div className="py-1.5">{demandsSection}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informações básicas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{basicInfoSection}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data e horário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{scheduleSection}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Onde</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{whereSection}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pessoas e organizações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{peopleSection}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tarefas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{tasksSection}</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Demandas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{demandsSection}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failedMessage ? (
|
||||
<Alert variant="destructive" className={sheet ? undefined : 'mt-5'}>
|
||||
<AlertDescription>{failedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
sheet
|
||||
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
|
||||
: 'flex shrink-0 items-center justify-between gap-3 border-t px-6 py-4'
|
||||
}
|
||||
>
|
||||
{isEdit && editDraft ? (
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={`/campanha/atividades/${editDraft.slug}`}>Ver detalhes</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="outline" className="min-h-11" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
{saving ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
{isEdit ? 'Salvar alterações' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActivityOverlay = ({
|
||||
request,
|
||||
isNarrow,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
organizationOptions,
|
||||
knownTags,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: ActivityOverlayProps) => {
|
||||
const [editDraft, setEditDraft] = useState<ActivityFormViewModel | 'loading' | 'error' | null>(
|
||||
null,
|
||||
)
|
||||
const isEdit = request?.kind === 'edit'
|
||||
const activityId = isEdit ? request.activityId : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || activityId === null) return
|
||||
let cancelled = false
|
||||
setEditDraft('loading')
|
||||
loadActivityEditDraft(activityId)
|
||||
.then((viewModel) => {
|
||||
if (!cancelled) setEditDraft(viewModel)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setEditDraft('error')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isEdit, activityId])
|
||||
|
||||
if (!request) return null
|
||||
|
||||
const sheetTitle = isEdit ? 'Editar atividade' : 'Nova atividade'
|
||||
|
||||
const dialog = (children: ReactNode) =>
|
||||
isNarrow ? (
|
||||
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} swipeDirection="up">
|
||||
<DrawerContent className="h-[calc(100dvh-1rem)] max-h-[calc(100dvh-1rem)]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{sheetTitle}</DrawerTitle>
|
||||
{request.kind === 'create' ? (
|
||||
<DrawerDescription>{formatBahiaDateTimeLabel(request.startAt)}</DrawerDescription>
|
||||
) : editDraft && editDraft !== 'loading' && editDraft !== 'error' ? (
|
||||
<DrawerDescription>{editDraft.title}</DrawerDescription>
|
||||
) : null}
|
||||
</DrawerHeader>
|
||||
{children}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{isEdit ? 'Editar atividade' : 'Nova atividade'}</DialogTitle>
|
||||
{request.kind === 'create' ? (
|
||||
<DialogDescription>{formatBahiaDateTimeLabel(request.startAt)}</DialogDescription>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
if (isEdit) {
|
||||
if (editDraft === 'loading' || editDraft === null) {
|
||||
return dialog(
|
||||
<div className="flex min-h-48 flex-1 items-center justify-center" role="status">
|
||||
<Spinner aria-hidden="true" />
|
||||
<span className="sr-only">Carregando compromisso…</span>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
if (editDraft === 'error') {
|
||||
return dialog(
|
||||
<div className="flex min-h-48 flex-1 flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Não foi possível carregar este compromisso. Tente novamente.
|
||||
</p>
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
return dialog(
|
||||
<ActivityOverlayForm
|
||||
variant={isNarrow ? 'sheet' : 'modal'}
|
||||
mode="edit"
|
||||
editDraft={editDraft}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
return dialog(
|
||||
<ActivityOverlayForm
|
||||
variant={isNarrow ? 'sheet' : 'modal'}
|
||||
mode="create"
|
||||
createDraft={{ startAt: request.startAt, endAt: request.endAt }}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
knownTags={knownTags}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { CalendarIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
CalendarFeedDialog,
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type CreateCalendarFeedResult,
|
||||
} from '@/components/campaign/activity/CalendarFeedDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type AgendaFeedChromeProps = {
|
||||
|
|
@ -25,16 +25,10 @@ type AgendaFeedChromeProps = {
|
|||
* dialog instance.
|
||||
*/
|
||||
export const AgendaFeedChrome = ({ feeds, onCreateFeed, onRevokeFeed }: AgendaFeedChromeProps) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const openFeed = useCallback(() => setOpen(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
setContext((current) => ({ ...current, openCalendarFeed: openFeed }))
|
||||
return () => {
|
||||
setContext((current) => ({ ...current, openCalendarFeed: undefined }))
|
||||
}
|
||||
}, [setContext, openFeed])
|
||||
useBridgedQuickAction('openCalendarFeed', openFeed)
|
||||
|
||||
const headerButton = useMemo(
|
||||
() => (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import type { GoogleCalendarSyncActionResult } from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
||||
import { GoogleCalendarSyncDialog } from '@/components/campaign/activity/GoogleCalendarSyncDialog'
|
||||
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { useBridgedQuickAction } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import type { GoogleCalendarSyncStatus } from '@/utilities/googleCalendarSync'
|
||||
|
||||
type AgendaGoogleSyncChromeProps = {
|
||||
|
|
@ -51,19 +51,13 @@ export const AgendaGoogleSyncChrome = ({
|
|||
onSyncNow,
|
||||
onSetDisabled,
|
||||
}: AgendaGoogleSyncChromeProps) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [state, setState] = useState(initialState)
|
||||
const autoRetriedRef = useRef(false)
|
||||
|
||||
const openSync = useCallback(() => setOpen(true), [])
|
||||
|
||||
useEffect(() => {
|
||||
setContext((current) => ({ ...current, openGoogleCalendarSync: openSync }))
|
||||
return () => {
|
||||
setContext((current) => ({ ...current, openGoogleCalendarSync: undefined }))
|
||||
}
|
||||
}, [setContext, openSync])
|
||||
useBridgedQuickAction('openGoogleCalendarSync', openSync)
|
||||
|
||||
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
|
||||
// page loads (the staff is operating — the natural retry moment).
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
|
|
@ -38,3 +39,24 @@ export const useCampaignQuickActionContext = (): CampaignQuickActionContextValue
|
|||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type CampaignBridgedQuickActionKey =
|
||||
| 'openCalendarFeed'
|
||||
| 'openGoogleCalendarSync'
|
||||
| 'openActivityCreate'
|
||||
| 'openActivityEdit'
|
||||
|
||||
/**
|
||||
* Registers a dialog-style quick action into the shared context while the
|
||||
* owning surface is mounted (precedent C94/C114, reused by C123 hosts). The
|
||||
* same key is unregistered on unmount so the FAB never fires a stale closure.
|
||||
*/
|
||||
export const useBridgedQuickAction = (key: CampaignBridgedQuickActionKey, open: () => void) => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
useEffect(() => {
|
||||
setContext((current) => ({ ...current, [key]: open }))
|
||||
return () => {
|
||||
setContext((current) => ({ ...current, [key]: undefined }))
|
||||
}
|
||||
}, [setContext, key, open])
|
||||
}
|
||||
|
|
|
|||
11
src/lib/activityOverlayMessages.ts
Normal file
11
src/lib/activityOverlayMessages.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* C123 — client-safe overlay messages. Split from `activityOverlayErrors.ts`
|
||||
* (whose mapper imports the server-only FormData ladder) so the overlay
|
||||
* component can reference the generic failure copy without dragging the
|
||||
* server graph into the client bundle.
|
||||
*/
|
||||
export const ACTIVITY_DUPLICATE_TITLE_MESSAGE = 'Já existe uma atividade com este título.'
|
||||
export const ACTIVITY_DEMAND_DUPLICATE_MESSAGE =
|
||||
'Já existe uma demanda com um dos títulos informados.'
|
||||
export const ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE =
|
||||
'Não foi possível salvar o compromisso. Tente novamente.'
|
||||
|
|
@ -16,7 +16,6 @@ import {
|
|||
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
|
||||
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
|
||||
import {
|
||||
ACTIVITY_NEW_PATH,
|
||||
ACTIVITY_TOUR_COMPOSER_PATH,
|
||||
type ActivityQuickActionSurface,
|
||||
} from '@/lib/campaignQuickActionPaths'
|
||||
|
|
@ -32,13 +31,21 @@ const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
|
|||
]
|
||||
|
||||
const listQuickActions = (context: CampaignQuickActionContext): readonly CampaignQuickAction[] => [
|
||||
{
|
||||
id: 'new-activity',
|
||||
label: 'Nova atividade',
|
||||
icon: Plus,
|
||||
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
|
||||
href: ACTIVITY_NEW_PATH,
|
||||
},
|
||||
// C123 — the create surface is the agenda overlay; the FAB opens it via the
|
||||
// page-bridged context (same pattern as openCalendarFeed/openGoogleCalendarSync).
|
||||
// Conditional like the dialog neighbors: while the bridge is not registered
|
||||
// yet (initial load), the entry must not render as a dead button.
|
||||
...(context.openActivityCreate
|
||||
? [
|
||||
{
|
||||
id: 'new-activity',
|
||||
label: 'Nova atividade',
|
||||
icon: Plus,
|
||||
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
|
||||
onAction: context.openActivityCreate,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(context.openCalendarFeed
|
||||
? [
|
||||
{
|
||||
|
|
@ -93,16 +100,25 @@ const wizardActionsWithMunicipalityPrefill = (
|
|||
})
|
||||
}
|
||||
|
||||
const detailVerticalQuickActions = (activitySlug: string): readonly CampaignQuickAction[] => {
|
||||
const detailVerticalQuickActions = (
|
||||
activitySlug: string,
|
||||
context: CampaignQuickActionContext,
|
||||
): readonly CampaignQuickAction[] => {
|
||||
const base = `/campanha/atividades/${activitySlug}`
|
||||
const actions: CampaignQuickAction[] = [
|
||||
{
|
||||
id: 'edit-activity',
|
||||
label: 'Editar',
|
||||
icon: Pencil,
|
||||
description: 'Abrir o formulário completo da atividade',
|
||||
href: `${base}/editar`,
|
||||
},
|
||||
// C123 — set by the detail page bridge (opens the edit overlay). Conditional
|
||||
// like the list entry: no dead "Editar" while the bridge is not registered.
|
||||
...(context.openActivityEdit
|
||||
? [
|
||||
{
|
||||
id: 'edit-activity',
|
||||
label: 'Editar',
|
||||
icon: Pencil,
|
||||
description: 'Abrir o formulário completo da atividade',
|
||||
onAction: context.openActivityEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'activity-tasks',
|
||||
label: 'Tarefas',
|
||||
|
|
@ -138,7 +154,7 @@ export const resolveActivityQuickActions = (
|
|||
const municipalitySlug = context.municipalitySlug
|
||||
const returnPath = pathname ?? `/campanha/atividades/${activitySlug}`
|
||||
|
||||
const detailActions = detailVerticalQuickActions(activitySlug)
|
||||
const detailActions = detailVerticalQuickActions(activitySlug, context)
|
||||
|
||||
if (!municipalitySlug) {
|
||||
return detailActions
|
||||
|
|
|
|||
|
|
@ -57,9 +57,6 @@ export const campaignPageChromeCatalog = {
|
|||
agenda: {
|
||||
title: 'Agenda',
|
||||
},
|
||||
atividadesNova: {
|
||||
title: 'Nova atividade',
|
||||
},
|
||||
atividadesGiros: {
|
||||
title: 'Planejar giro',
|
||||
},
|
||||
|
|
@ -185,18 +182,10 @@ const pathRules: PathRule[] = [
|
|||
match: (pathname) => pathname === '/campanha/pessoas',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.pessoas),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades/nova',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),
|
||||
},
|
||||
{
|
||||
match: (pathname) => pathname === '/campanha/atividades/giros',
|
||||
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros),
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
|
||||
resolve: () => null,
|
||||
},
|
||||
{
|
||||
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
|
||||
resolve: () => null,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export type CampaignQuickActionContext = {
|
|||
openCalendarFeed?: () => void
|
||||
/** C114 — opens the Google Calendar mirror dialog (same bridge as above). */
|
||||
openGoogleCalendarSync?: () => void
|
||||
/**
|
||||
* C123 — opens the agenda's create overlay (bridged by the agenda and the
|
||||
* activities list page, which host the overlay).
|
||||
*/
|
||||
openActivityCreate?: () => void
|
||||
/** C123 — opens the agenda's edit overlay for the current activity (bridged by the detail page). */
|
||||
openActivityEdit?: () => void
|
||||
}
|
||||
|
||||
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ export const ACTIVITY_LIST_PATH = CAMPAIGN_AGENDA_HOME
|
|||
|
||||
export const ACTIVITY_DETAIL_ROOT_PATH = '/campanha/atividades' as const
|
||||
|
||||
export const ACTIVITY_NEW_PATH = `${ACTIVITY_DETAIL_ROOT_PATH}/nova` as const
|
||||
|
||||
export const ACTIVITY_TOUR_COMPOSER_PATH = `${ACTIVITY_DETAIL_ROOT_PATH}/giros` as const
|
||||
|
||||
export const ORGANIZATIONS_LIST_PATH = '/campanha/organizacoes' as const
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* C91 — safe-message mapping for the agenda's inline quick create. Pure, so
|
||||
* both the server action and the unit tests share one source of truth: a DB
|
||||
* unique-title violation surfaces as a field error named like the full form's,
|
||||
* anything else collapses to a generic message.
|
||||
*/
|
||||
export const ACTIVITY_DUPLICATE_TITLE_MESSAGE = 'Já existe uma atividade com este título.'
|
||||
export const ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE =
|
||||
'Não foi possível criar o compromisso. Tente novamente.'
|
||||
|
||||
export type ActivityInlineCreateErrorResult = {
|
||||
message: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const mapActivityInlineCreateError = (error: unknown): ActivityInlineCreateErrorResult => {
|
||||
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
|
||||
return {
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
}
|
||||
}
|
||||
return { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE }
|
||||
}
|
||||
43
src/utilities/activityOverlayErrors.ts
Normal file
43
src/utilities/activityOverlayErrors.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
|
||||
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
|
||||
} from '@/lib/activityOverlayMessages'
|
||||
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* C123 — unified error mapping for the agenda's single create/edit overlay.
|
||||
* Pure, so both the server actions and the unit tests share one source of
|
||||
* truth. DB unique-violations surface as named field errors (same strings the
|
||||
* old full-form actions used), everything else collapses to a generic message.
|
||||
*/
|
||||
export type ActivityOverlayErrorResult = {
|
||||
message: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const mapActivityOverlayError = (error: unknown): ActivityOverlayErrorResult => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
/campaign_demand.*(?:slug|title)|campaignDemand.*(?:slug|title)/i.test(error.message)
|
||||
) {
|
||||
return {
|
||||
message: ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
|
||||
fieldErrors: { demandsJson: [ACTIVITY_DEMAND_DUPLICATE_MESSAGE] },
|
||||
}
|
||||
}
|
||||
if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) {
|
||||
return {
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
}
|
||||
}
|
||||
const mapped = mapCampaignFormActionError({
|
||||
error,
|
||||
genericMessage: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
|
||||
})
|
||||
return {
|
||||
message: mapped.message ?? ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
|
||||
...(mapped.fieldErrors ? { fieldErrors: mapped.fieldErrors } : {}),
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,9 @@ import {
|
|||
} from '@/utilities/activityUi'
|
||||
import {
|
||||
activityAgendaSelect,
|
||||
activityFormSelect,
|
||||
activityListSelect,
|
||||
getActivityDetailSelect,
|
||||
toActivityAgendaEvent,
|
||||
toActivityFormViewModel,
|
||||
} from '@/utilities/activityViewModels'
|
||||
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
|
||||
|
||||
|
|
@ -100,7 +98,7 @@ const loadAccessibleActivityBySlug = async (
|
|||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
activitySlug: string,
|
||||
select: ReturnType<typeof getActivityDetailSelect> | typeof activityFormSelect,
|
||||
select: ReturnType<typeof getActivityDetailSelect>,
|
||||
depth: number,
|
||||
): Promise<Activity> => {
|
||||
const result = await payload.find({
|
||||
|
|
@ -138,12 +136,3 @@ export const resolveAccessibleActivityContext = async (
|
|||
document,
|
||||
}
|
||||
}
|
||||
|
||||
export const getActivityEditPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
activitySlug: string,
|
||||
) =>
|
||||
toActivityFormViewModel(
|
||||
await loadAccessibleActivityBySlug(payload, user, activitySlug, activityFormSelect, 1),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/co
|
|||
import {
|
||||
activityStatuses,
|
||||
MAX_ACTIVITY_TAG_LENGTH,
|
||||
MAX_ACTIVITY_TAGS,
|
||||
type ActivityStatus,
|
||||
} from '@/lib/schemas/activity'
|
||||
import {
|
||||
|
|
@ -354,39 +353,6 @@ export const buildActivityAgendaWhere = (
|
|||
return { and: filters }
|
||||
}
|
||||
|
||||
export type ActivityCreatePrefill = {
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
municipalityId?: number
|
||||
title?: string
|
||||
tags?: string[]
|
||||
/** C104 — the sheet opened the toggle; startAt/endAt are civil dates. */
|
||||
allDay?: boolean
|
||||
}
|
||||
|
||||
export const buildActivityCreateHref = (
|
||||
agendaState: ActivityAgendaState,
|
||||
prefill: Partial<ActivityCreatePrefill> = {},
|
||||
): string => {
|
||||
const params = new URLSearchParams()
|
||||
if (prefill.allDay) {
|
||||
params.set('allDay', '1')
|
||||
if (prefill.startAt) params.set('startAt', prefill.startAt.slice(0, 10))
|
||||
if (prefill.endAt) params.set('endAt', prefill.endAt.slice(0, 10))
|
||||
} else {
|
||||
if (prefill.startAt) params.set('startAt', prefill.startAt)
|
||||
if (prefill.endAt) params.set('endAt', prefill.endAt)
|
||||
}
|
||||
const municipalityId = prefill.municipalityId ?? agendaState.municipality
|
||||
if (municipalityId) params.set('municipality', String(municipalityId))
|
||||
if (prefill.title) params.set('title', prefill.title)
|
||||
// C105 — repeated `tags` params: a free-form tag may contain a comma, so a
|
||||
// comma-joined single value would corrupt the boundary between tags.
|
||||
for (const tag of prefill.tags ?? []) params.append('tags', tag)
|
||||
params.set('returnTo', buildActivityAgendaHref(agendaState))
|
||||
return `/campanha/atividades/nova?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* C91 — the inline create overlay prefills the slot the staff clicked. The
|
||||
* FullCalendar `dateClick` already snaps the click to a slot boundary, so for
|
||||
|
|
@ -410,105 +376,15 @@ export const activitySlotPrefill = (info: {
|
|||
}
|
||||
}
|
||||
|
||||
export const parseActivityAgendaReturnHref = (
|
||||
value: string | string[] | undefined,
|
||||
accessibleMunicipalityIDs: ReadonlySet<number>,
|
||||
accessibleTags: ReadonlySet<string>,
|
||||
): string => {
|
||||
const raw = firstValue(value)
|
||||
if (!raw) return CAMPAIGN_AGENDA_HOME
|
||||
|
||||
const base = new URL('https://campaign.invalid')
|
||||
let returnUrl: URL
|
||||
try {
|
||||
returnUrl = new URL(raw, base)
|
||||
} catch {
|
||||
return CAMPAIGN_AGENDA_HOME
|
||||
}
|
||||
if (returnUrl.origin !== base.origin || returnUrl.pathname !== CAMPAIGN_AGENDA_HOME) {
|
||||
return CAMPAIGN_AGENDA_HOME
|
||||
}
|
||||
|
||||
const state = restrictActivityAgendaState(
|
||||
parseActivityAgendaParams({
|
||||
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
|
||||
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
|
||||
tag: returnUrl.searchParams.get('tag') ?? undefined,
|
||||
view: returnUrl.searchParams.get('view') ?? undefined,
|
||||
}),
|
||||
accessibleMunicipalityIDs,
|
||||
accessibleTags,
|
||||
)
|
||||
return buildActivityAgendaHref(state)
|
||||
}
|
||||
|
||||
const isoInstantPattern = /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/
|
||||
|
||||
const normalizedIsoInstant = (value: string | undefined): string | undefined => {
|
||||
if (!value || !isoInstantPattern.test(value)) return undefined
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* C105 — bounded prefill tags: repeated `tags` params, trimmed, deduped,
|
||||
* capped at the same limits the create schema enforces. Anything out of bounds
|
||||
* is dropped (same drop semantics as the agenda filter's `activityTag`),
|
||||
* never rejected — a prefill is a convenience, not a contract.
|
||||
* C123 — the default create window for overlay hosts with no slot clicked
|
||||
* (FAB "Nova atividade", list-page button): today 09:00–10:00, the same
|
||||
* historical all-day-slot fallback `activitySlotPrefill` uses.
|
||||
*/
|
||||
const parseActivityPrefillTags = (value: string | string[] | undefined): string[] | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
const tags: string[] = []
|
||||
for (const entry of Array.isArray(value) ? value : [value]) {
|
||||
if (tags.length >= MAX_ACTIVITY_TAGS) break
|
||||
const tag = activityTag(entry)
|
||||
if (tag && !tags.includes(tag)) tags.push(tag)
|
||||
}
|
||||
return tags.length > 0 ? tags : undefined
|
||||
}
|
||||
|
||||
export const parseActivityCreatePrefill = (
|
||||
params: RawSearchParams,
|
||||
accessibleMunicipalityIDs: ReadonlySet<number>,
|
||||
): ActivityCreatePrefill => {
|
||||
const rawAllDay = firstValue(params.allDay)
|
||||
const allDay = rawAllDay === '1'
|
||||
const startAt = allDay
|
||||
? civilDatePrefillInstant(firstValue(params.startAt))
|
||||
: normalizedIsoInstant(firstValue(params.startAt))
|
||||
const rawEndAt = firstValue(params.endAt)
|
||||
const endAt = allDay
|
||||
? civilDatePrefillInstant(rawEndAt, startAt)
|
||||
: startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt)
|
||||
? normalizedIsoInstant(rawEndAt)
|
||||
: undefined
|
||||
const rawMunicipality = strictDecimalInteger(firstValue(params.municipality))
|
||||
const municipalityId =
|
||||
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
|
||||
|
||||
const title = normalizedText(firstValue(params.title))
|
||||
const trimmedTitle = title && title.length <= 160 ? title : undefined
|
||||
const tags = parseActivityPrefillTags(params.tags)
|
||||
|
||||
return {
|
||||
...(allDay ? { allDay } : {}),
|
||||
...(startAt ? { startAt } : {}),
|
||||
...(endAt ? { endAt } : {}),
|
||||
...(municipalityId ? { municipalityId } : {}),
|
||||
...(trimmedTitle ? { title: trimmedTitle } : {}),
|
||||
...(tags ? { tags } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const civilDatePrefillInstant = (
|
||||
value: string | undefined,
|
||||
startAt?: string,
|
||||
): string | undefined => {
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
|
||||
const instant = allDayStartInstant(value)
|
||||
if (startAt && instant < startAt) return undefined
|
||||
return instant
|
||||
}
|
||||
export const activityDefaultCreatePrefill = (
|
||||
now = new Date(),
|
||||
): { startAt: string; endAt: string } | null =>
|
||||
activitySlotPrefill({ allDay: true, dateStr: formatBahiaCivilDate(now) })
|
||||
|
||||
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,37 @@ import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
|||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
import { dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js'
|
||||
|
||||
/**
|
||||
* Agenda prologue shared by every journey in this file: opens the agenda
|
||||
* filtered to one município, waits for the window load to land (FullCalendar
|
||||
* renders the grid lazily and re-lays out on mount — the grid first paints
|
||||
* empty and re-renders, replacing its gridcells, when the events land) and
|
||||
* clicks the 14:00 slot. `force` bypasses the C104 all-day lane, which paints
|
||||
* day fills over the time grid: real clicks still land on the slot
|
||||
* (FullCalendar resolves by data-time), but Playwright's actionability check
|
||||
* refuses them.
|
||||
*/
|
||||
const openAgendaSlot = async ({
|
||||
campaign,
|
||||
page,
|
||||
municipalityId,
|
||||
}: {
|
||||
campaign: { baseURL: string }
|
||||
page: import('@playwright/test').Page
|
||||
municipalityId: number
|
||||
}) => {
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipalityId}`)
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
}
|
||||
|
||||
test.describe('Atividades — registro-fundação', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
||||
test('cria compromisso com demandas vinculadas e exibe os sinais no município', async ({
|
||||
test('cria compromisso com demandas vinculadas pelo overlay e exibe os sinais no município', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -21,11 +48,15 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
const materialDemand = fixtures.value('Material de rua')
|
||||
|
||||
await campaign.login(page, coordinator.email!, password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
|
||||
// C123 — the agenda overlay is the only create surface; /nova is gone.
|
||||
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
|
||||
|
||||
// The central modal (desktop) hosts every section of the old full form.
|
||||
const modal = page.getByRole('dialog', { name: 'Nova atividade' })
|
||||
await expect(modal).toBeVisible()
|
||||
await page.getByLabel('Título *').fill(activityTitle)
|
||||
await page.getByLabel('Início *').fill('2026-08-15T10:00')
|
||||
await page.getByLabel('Município *').selectOption(String(municipality.id))
|
||||
// The municipality filter prefills the field; the times come from the slot.
|
||||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||||
|
||||
// C90 — the unified polymorphic selector replaces the old Contact
|
||||
// responsável + assessores/liderança fields.
|
||||
|
|
@ -39,8 +70,20 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
await page.getByLabel('Título da demanda 2').fill(materialDemand)
|
||||
await page.getByLabel('Tipo da demanda 2').selectOption('material')
|
||||
|
||||
await page.getByRole('button', { name: 'Criar atividade' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
|
||||
// Save never navigates: the event lands in the calendar and the URL stays.
|
||||
await expect(page.getByText(activityTitle, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
|
||||
// Clicking the event opens the edit overlay; the detail stays reachable
|
||||
// through the overlay's own "Ver detalhes" link.
|
||||
await page.getByText(activityTitle, { exact: true }).first().click()
|
||||
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
|
||||
await expect(editModal).toBeVisible()
|
||||
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
|
||||
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
|
||||
await expect(campaignPageChrome(page, activityTitle)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
|
@ -166,8 +209,16 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
).toBe(true)
|
||||
|
||||
// C123 — clicking the event opens the edit overlay; the detail is reached
|
||||
// through the overlay's own link.
|
||||
await page.getByText(title, { exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/)
|
||||
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
|
||||
await expect(editModal).toBeVisible()
|
||||
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
|
||||
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(campaignPageChrome(page, title)).toBeVisible()
|
||||
})
|
||||
|
||||
|
|
@ -181,65 +232,38 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
const title = fixtures.value('Café inline na agenda')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
|
||||
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(slotLocator).toBeVisible()
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
// The click no longer navigates: the inline overlay opens at the slot.
|
||||
// The click no longer navigates: the central modal opens with the slot's
|
||||
// date and the split controls (date trigger + inline hour/minute).
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
// C97 — the native datetime-local is gone; the trigger buttons show the
|
||||
// civil 24h label (slot = 14:00–14:30 Bahia, whatever the browser locale).
|
||||
const startTrigger = page.getByLabel('Início *')
|
||||
await expect(startTrigger).toBeVisible()
|
||||
const [startLabel, endLabel] = await Promise.all([
|
||||
startTrigger.textContent(),
|
||||
page.getByLabel('Término').textContent(),
|
||||
page.getByRole('button', { name: 'Término', exact: true }).textContent(),
|
||||
])
|
||||
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:00/)
|
||||
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:30/)
|
||||
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
|
||||
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/)
|
||||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||||
// C123 — the time selects are INLINE, visible without opening anything.
|
||||
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
|
||||
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
|
||||
await expect(page.getByLabel('Hora de Término')).toHaveValue('14')
|
||||
await expect(page.getByLabel('Minuto de Término')).toHaveValue('30')
|
||||
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
|
||||
// The acceptance center: pick a different time through the shadcn picker
|
||||
// (calendar keeps the day; the step selects change hour/minute) and the
|
||||
// saved event lands at the chosen hour. Both fields move together so the
|
||||
// "Término posterior ao Início" validation stays satisfied. After
|
||||
// selectOption the focus sits in the native select, where Escape never
|
||||
// reaches Radix — re-clicking the selected day moves focus into the
|
||||
// calendar, then Escape closes only the picker (never the overlay).
|
||||
await startTrigger.click()
|
||||
// Scope to the live picker: a just-closed popover lingers in the DOM for
|
||||
// its 100 ms close animation, so a bare combobox query can match twice.
|
||||
const openPicker = () => page.locator('[data-slot="popover-content"][data-state="open"]').last()
|
||||
const hourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||||
await expect(hourSelect).toBeVisible()
|
||||
await hourSelect.selectOption('15')
|
||||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 15:00/)
|
||||
await openPicker().locator('[data-selected-single="true"]').click()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByLabel('Término').click()
|
||||
const endHourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||||
await expect(endHourSelect).toBeVisible()
|
||||
await endHourSelect.selectOption('16')
|
||||
// The acceptance center: pick a different time through the inline selects
|
||||
// and the saved event lands at the chosen hour. Both fields move together
|
||||
// so the "Término posterior ao Início" validation stays satisfied.
|
||||
await page.getByLabel('Hora de Início').selectOption('15')
|
||||
await expect(page.getByLabel('Hora de Início')).toHaveValue('15')
|
||||
await page.getByLabel('Hora de Término').selectOption('16')
|
||||
// The minute keeps its prefill (30) — only the hour changed.
|
||||
await expect(page.getByLabel('Término')).toHaveText(/\d{2}\/\d{2}\/\d{4} às 16:30/)
|
||||
await openPicker().locator('[data-selected-single="true"]').click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByLabel('Minuto de Término')).toHaveValue('30')
|
||||
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
// The saved event landed at the chosen times (24h chip, no meridiem).
|
||||
|
|
@ -257,27 +281,24 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
const title = fixtures.value('Giro de dia inteiro')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
|
||||
|
||||
const startTrigger = page.getByLabel('Início *')
|
||||
await expect(startTrigger).toBeVisible()
|
||||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 14:00/)
|
||||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4}/)
|
||||
// C123 — the time selects are inline; the slot window shows in them.
|
||||
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
|
||||
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
|
||||
|
||||
// C104 — toggling "Todo o dia" hides the times: both triggers show only
|
||||
// the civil date, and the picker no longer offers Hora/Minuto selects.
|
||||
// the civil date, the inline selects disappear and the picker no longer
|
||||
// offers Hora/Minuto selects.
|
||||
await page.getByLabel('Todo o dia').click()
|
||||
await expect(startTrigger).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
|
||||
await expect(page.getByLabel('Término')).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
|
||||
await expect(page.getByRole('button', { name: 'Término', exact: true })).toHaveText(
|
||||
/^\d{2}\/\d{2}\/\d{4}$/,
|
||||
)
|
||||
await expect(page.getByLabel('Hora de Início')).toHaveCount(0)
|
||||
await startTrigger.click()
|
||||
await expect(
|
||||
page.locator('[data-slot="popover-content"][data-state="open"]').last().getByRole('combobox'),
|
||||
|
|
@ -285,14 +306,16 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await page.keyboard.press('Escape')
|
||||
|
||||
// Multi-day: the end picker lands on a later day of the same month.
|
||||
await page.getByLabel('Término').click()
|
||||
await page.getByRole('button', { name: 'Término', exact: true }).click()
|
||||
const endPicker = page.locator('[data-slot="popover-content"][data-state="open"]').last()
|
||||
await endPicker.getByRole('button', { name: /17 de agosto de 2026/ }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByLabel('Término')).toHaveText(/17\/08\/2026/)
|
||||
await expect(page.getByRole('button', { name: 'Término', exact: true })).toHaveText(
|
||||
/17\/08\/2026/,
|
||||
)
|
||||
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
|
||||
// The band lands without a time chip: the all-day event carries no
|
||||
// "14:00 – 14:30" text (the timed events render it; allDay:true does not).
|
||||
|
|
@ -300,55 +323,71 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
await expect(page.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toHaveCount(0)
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
|
||||
// The detail page preserves the all-day choice: date-only labels.
|
||||
// C123 — clicking the event opens the edit overlay (no navigation); the
|
||||
// detail page stays reachable through the overlay link and preserves the
|
||||
// all-day choice: date-only labels.
|
||||
await page.getByText(title, { exact: true }).first().click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
|
||||
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
|
||||
await expect(editModal).toBeVisible()
|
||||
await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
|
||||
await editModal.getByRole('link', { name: 'Ver detalhes' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
|
||||
await expect(page.getByText(/^\d{2}\/\d{2}\/\d{4}$/).first()).toBeVisible()
|
||||
await expect(page.getByText(/\d{2}\/\d{2}\/\d{4} às /)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({
|
||||
test('edita o compromisso pelo overlay no clique do evento, sem navegar (C123)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
const title = fixtures.value('Detalhes inline da agenda')
|
||||
const title = fixtures.value('Compromisso editado no overlay')
|
||||
const newLocality = fixtures.value('Nova sede do comitê')
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
|
||||
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
await expect(slotLocator).toBeVisible()
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
await expect(page.getByLabel('Título *')).toBeVisible()
|
||||
// Create first through the same overlay.
|
||||
await expect(page.getByRole('dialog', { name: 'Nova atividade' })).toBeVisible()
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
await page.getByRole('link', { name: 'Mais detalhes' }).click()
|
||||
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
|
||||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/nova\?startAt=/)
|
||||
await expect(page.getByLabel('Título *')).toHaveValue(title)
|
||||
await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id))
|
||||
const [startValue, endValue] = await Promise.all([
|
||||
page.getByLabel('Início *').inputValue(),
|
||||
page.getByLabel('Término').inputValue(),
|
||||
])
|
||||
const [startAt, endAt] = [startValue, endValue].map(parseBahiaDateTimeInput)
|
||||
expect(startAt).not.toBeNull()
|
||||
expect(endAt).not.toBeNull()
|
||||
if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.')
|
||||
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000)
|
||||
// Clicking the event opens the EDIT overlay prefilled with the saved
|
||||
// values — no navigation, no /editar page.
|
||||
await page.getByText(title, { exact: true }).first().click()
|
||||
const editModal = page.getByRole('dialog', { name: 'Editar atividade' })
|
||||
await expect(editModal).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
await expect(editModal.getByLabel('Título *')).toHaveValue(title, { timeout: 30_000 })
|
||||
await expect(editModal.getByLabel('Município *')).toHaveValue(municipality.name)
|
||||
await expect(editModal.getByLabel('Hora de Início')).toHaveValue('14')
|
||||
await expect(editModal.getByLabel('Minuto de Término')).toHaveValue('30')
|
||||
// C123 — the edit overlay carries the id the old /editar page lost (C14
|
||||
// regression): saving must actually persist.
|
||||
await editModal.getByLabel('Hora de Término').selectOption('15')
|
||||
await editModal.getByLabel('Local (opcional)').fill(newLocality)
|
||||
await editModal.getByRole('button', { name: 'Salvar alterações' }).click()
|
||||
|
||||
// Save closes the overlay, never navigates; the calendar reflects the edit.
|
||||
await expect(page.getByRole('dialog', { name: 'Editar atividade' })).toBeHidden()
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
await expect(page.getByText('14:00 – 15:30', { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(page.getByText('14:00 – 14:30', { exact: true })).toHaveCount(0)
|
||||
|
||||
// The detail page reflects the persisted edit.
|
||||
await page.getByText(title, { exact: true }).first().click()
|
||||
const detailModal = page.getByRole('dialog', { name: 'Editar atividade' })
|
||||
await expect(detailModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 })
|
||||
await detailModal.getByRole('link', { name: 'Ver detalhes' }).click()
|
||||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/, { timeout: 30_000 })
|
||||
// The location shows in the overview's own <dd> (exact); the chrome
|
||||
// subtitle carries "município · local" as one string.
|
||||
await expect(page.getByText(newLocality, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({
|
||||
|
|
@ -447,17 +486,7 @@ test.describe('Atividades — agenda mobile (C103)', () => {
|
|||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||||
|
||||
// FullCalendar renders the grid lazily and re-lays out on mount: wait for
|
||||
// the load to finish before interacting (same pattern as the desktop test).
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
// Click the time slot itself — C104's all-day lane sits above the timed
|
||||
// grid, so the old day-cell nth() offset no longer lands on the slot.
|
||||
// `force` bypasses the timed-row content overlay; the FullCalendar grid
|
||||
// still receives the click and resolves the 14:00 date from the position.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
await openAgendaSlot({ campaign, page, municipalityId: municipality.id })
|
||||
|
||||
// C103 — the sheet opens from the TOP, hugging the usable viewport edge.
|
||||
const sheet = page.getByRole('dialog', { name: /Nova atividade/ })
|
||||
|
|
@ -475,11 +504,15 @@ test.describe('Atividades — agenda mobile (C103)', () => {
|
|||
// viewport — the acceptance center of C103.
|
||||
await expect(page.getByRole('button', { name: 'Salvar' })).toBeVisible()
|
||||
|
||||
// Date/time opens as a nested bottom sheet (no popover) with "Pronto".
|
||||
await page.getByLabel('Início *').click()
|
||||
// C123 — the time selects are INLINE in the sheet row (no nested sheet for
|
||||
// the time); the calendar still opens as a nested bottom sheet with
|
||||
// "Pronto" when the date trigger is tapped.
|
||||
await expect(page.getByLabel('Hora de Início')).toHaveValue('14')
|
||||
await expect(page.getByLabel('Minuto de Início')).toHaveValue('00')
|
||||
await page.getByRole('button', { name: 'Início *', exact: true }).click()
|
||||
const picker = page.getByRole('dialog', { name: /Início —/ })
|
||||
await expect(picker).toBeVisible()
|
||||
await expect(picker.getByRole('combobox', { name: 'Hora' })).toBeVisible()
|
||||
await expect(picker.getByRole('button', { name: 'Pronto' })).toBeVisible()
|
||||
await picker.getByRole('button', { name: 'Pronto' }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
createActivityRecord,
|
||||
createTourDraftActivitiesRecord,
|
||||
loadActivityAgendaEventsRecord,
|
||||
loadActivityEditDraftRecord,
|
||||
rescheduleActivityRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { allDayEndInstant, allDayStartInstant } from '@/lib/activityAllDay'
|
||||
|
|
@ -376,6 +377,41 @@ describe('activity domain', () => {
|
|||
expect(result.docs.map((doc) => doc.id)).toEqual([visibleByResponsibility.id])
|
||||
})
|
||||
|
||||
it('loads the edit draft only inside the actor scope (C123 overlay)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const otherAdvisor = await fixtures.createCampaignUser('advisor')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(municipality, [advisor])
|
||||
|
||||
// Far-future startAt: this spec is about read scoping, not the agenda
|
||||
// window — and keeping the rows OUTSIDE the Google sync engine's
|
||||
// 90/365-day scan window avoids feeding the C126 parallel-flake class.
|
||||
const farFuture = new Date(Date.now() + 400 * 86_400_000).toISOString()
|
||||
|
||||
const inScope = await createActivityRecord(payload, coordinator, {
|
||||
...validActivityInput(municipality.id),
|
||||
title: fixtures.value('No portfólio do assessor'),
|
||||
startAt: farFuture,
|
||||
})
|
||||
fixtures.own('activity', inScope.id)
|
||||
const outOfScope = await createActivityRecord(payload, coordinator, {
|
||||
...validActivityInput((await fixtures.getMunicipality()).id),
|
||||
title: fixtures.value('Fora do portfólio do assessor'),
|
||||
startAt: farFuture,
|
||||
})
|
||||
fixtures.own('activity', outOfScope.id)
|
||||
|
||||
const advisorDraft = await loadActivityEditDraftRecord(payload, advisor, inScope.id)
|
||||
expect(advisorDraft.id).toBe(inScope.id)
|
||||
expect(advisorDraft.title).toBe(inScope.title)
|
||||
expect(advisorDraft.municipalityId).toBe(municipality.id)
|
||||
|
||||
await expect(loadActivityEditDraftRecord(payload, otherAdvisor, inScope.id)).rejects.toThrow()
|
||||
await expect(loadActivityEditDraftRecord(payload, advisor, outOfScope.id)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('lets a responsible advisor re-save an activity whose leadership they cannot read (C90 existence validation)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
const mocks = vi.hoisted(() => ({
|
||||
loadEvents: vi.fn(),
|
||||
reschedule: vi.fn(),
|
||||
createInline: vi.fn(),
|
||||
createOverlay: vi.fn(),
|
||||
updateOverlay: vi.fn(),
|
||||
loadEditDraft: vi.fn(),
|
||||
searchContacts: vi.fn(),
|
||||
searchResponsibles: vi.fn(),
|
||||
revertDrop: vi.fn(),
|
||||
revertResize: vi.fn(),
|
||||
|
|
@ -17,10 +20,13 @@ const mocks = vi.hoisted(() => ({
|
|||
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
|
||||
loadActivityAgendaEvents: (...args: unknown[]) => mocks.loadEvents(...args),
|
||||
rescheduleActivity: (...args: unknown[]) => mocks.reschedule(...args),
|
||||
createActivityInline: (...args: unknown[]) => mocks.createInline(...args),
|
||||
createActivityOverlay: (...args: unknown[]) => mocks.createOverlay(...args),
|
||||
updateActivityOverlay: (...args: unknown[]) => mocks.updateOverlay(...args),
|
||||
loadActivityEditDraft: (...args: unknown[]) => mocks.loadEditDraft(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
|
||||
searchActivityContactOptions: (query: string) => mocks.searchContacts(query),
|
||||
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
|
||||
}))
|
||||
|
||||
|
|
@ -38,6 +44,11 @@ type DateClickInfo = {
|
|||
jsEvent: MouseEvent
|
||||
}
|
||||
|
||||
type EventClickInfo = {
|
||||
event: { id: string }
|
||||
jsEvent: MouseEvent
|
||||
}
|
||||
|
||||
type ScheduleMutationInfo = {
|
||||
event: { id: string; startStr: string; endStr: string }
|
||||
revert: () => void
|
||||
|
|
@ -52,6 +63,7 @@ type CalendarProps = {
|
|||
eventDrop: (info: ScheduleMutationInfo) => void
|
||||
eventResize: (info: ScheduleMutationInfo) => void
|
||||
dateClick: (info: DateClickInfo) => void
|
||||
eventClick: (info: EventClickInfo) => void
|
||||
}
|
||||
|
||||
vi.mock('@fullcalendar/react', () => {
|
||||
|
|
@ -89,6 +101,18 @@ vi.mock('@fullcalendar/react', () => {
|
|||
},
|
||||
'Clicar slot',
|
||||
),
|
||||
createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () =>
|
||||
props.eventClick({
|
||||
event: { id: '7' },
|
||||
jsEvent: new MouseEvent('click'),
|
||||
}),
|
||||
},
|
||||
'Clicar evento',
|
||||
),
|
||||
createElement(
|
||||
'button',
|
||||
{ type: 'button', onClick: () => props.eventDrop({ event, revert: mocks.revertDrop }) },
|
||||
|
|
@ -109,13 +133,19 @@ vi.mock('@fullcalendar/react', () => {
|
|||
|
||||
import { ActivityAgenda, tagsLabel } from '@/components/campaign/activity/ActivityAgenda'
|
||||
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
|
||||
import {
|
||||
CampaignQuickActionContextProvider,
|
||||
useCampaignQuickActionContext,
|
||||
} from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { stubMatchMedia } from '../helpers/matchMedia'
|
||||
|
||||
const renderAgenda = (props: { state?: object } = {}) =>
|
||||
render(
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<ActivityAgenda state={props.state ?? {}} />
|
||||
</CampaignPageChromeProvider>,
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<ActivityAgenda state={props.state ?? {}} />
|
||||
</CampaignPageChromeProvider>
|
||||
</CampaignQuickActionContextProvider>,
|
||||
)
|
||||
|
||||
describe('tagsLabel (C105)', () => {
|
||||
|
|
@ -142,7 +172,7 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
stubMatchMedia()
|
||||
// C104 — the toggle (radix Checkbox) measures its indicator via
|
||||
// react-use-size, which needs ResizeObserver — same stub the
|
||||
// inline-create spec uses. Stubbed per test because the afterEach below
|
||||
// overlay spec uses. Stubbed per test because the afterEach below
|
||||
// restores every stubGlobal.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
|
|
@ -188,7 +218,7 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
expect(mocks.toastError).toHaveBeenCalledWith('Remarcação recusada.')
|
||||
})
|
||||
|
||||
it('abre a criação inline no clique de um slot vazio, sem navegar', async () => {
|
||||
it('abre o overlay de criação no clique de um slot vazio, sem navegar', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
renderAgenda()
|
||||
|
||||
|
|
@ -197,20 +227,86 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clicar slot' }))
|
||||
|
||||
// C123 — the slot click opens the central modal (dialog) with the split
|
||||
// date/time controls prefilled from the slot.
|
||||
expect(screen.getByRole('dialog', { name: 'Nova atividade' })).toBeTruthy()
|
||||
const startTrigger = await screen.findByLabelText('Início *')
|
||||
// C97 — the overlay trigger is a button showing the civil label, 24h.
|
||||
expect(startTrigger.textContent).toBe('07/08/2026 às 13:00')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
|
||||
expect(startTrigger.textContent).toBe('07/08/2026')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('13')
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('abre o overlay de edição no clique de um evento e carrega o rascunho (C123)', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
mocks.loadEditDraft.mockResolvedValue({
|
||||
id: 7,
|
||||
title: 'Comício na feira',
|
||||
slug: 'comicio-na-feira',
|
||||
tags: [],
|
||||
status: 'confirmado',
|
||||
description: null,
|
||||
deputyPresent: false,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T17:00:00.000Z',
|
||||
municipalityId: 12,
|
||||
locality: null,
|
||||
organizationIDs: [],
|
||||
responsibles: [],
|
||||
tasks: [],
|
||||
})
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clicar evento' }))
|
||||
|
||||
await waitFor(() => expect(mocks.loadEditDraft).toHaveBeenCalledWith(7))
|
||||
expect(screen.getByRole('dialog', { name: 'Editar atividade' })).toBeTruthy()
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
|
||||
'Comício na feira',
|
||||
),
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'Ver detalhes' }).getAttribute('href')).toBe(
|
||||
'/campanha/atividades/comicio-na-feira',
|
||||
)
|
||||
})
|
||||
|
||||
it('não exibe o aviso de janela vazia nem o botão de criação acima do calendário', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
renderAgenda()
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
|
||||
expect(screen.queryByText('Nenhum compromisso nesta janela e neste filtro.')).toBeNull()
|
||||
// The empty-state notice and the old inline create CTA were removed by
|
||||
// C105/C91 respectively; the calendar shows FullCalendar's own text.
|
||||
expect(screen.queryByRole('link', { name: 'Criar atividade' })).toBeNull()
|
||||
})
|
||||
|
||||
it('registra a ponte de criação no contexto de quick actions (C123)', async () => {
|
||||
mocks.loadEvents.mockResolvedValue([])
|
||||
let captured: (() => void) | undefined
|
||||
const Probe = () => {
|
||||
const { context } = useCampaignQuickActionContext()
|
||||
captured = context.openActivityCreate
|
||||
return null
|
||||
}
|
||||
render(
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignPageChromeProvider role="coordinator">
|
||||
<Probe />
|
||||
<ActivityAgenda state={{}} />
|
||||
</CampaignPageChromeProvider>
|
||||
</CampaignQuickActionContextProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled())
|
||||
await waitFor(() => expect(captured).toBeDefined())
|
||||
|
||||
captured!()
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Nova atividade' })).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createInline: vi.fn(),
|
||||
searchResponsibles: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
|
||||
createActivityInline: (...args: unknown[]) => mocks.createInline(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
|
||||
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: mocks.toastError, success: mocks.toastSuccess },
|
||||
}))
|
||||
|
||||
import {
|
||||
ActivityInlineCreate,
|
||||
type ActivityInlineCreateDraft,
|
||||
} from '@/components/campaign/activity/ActivityInlineCreate'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
|
||||
const DRAFT: ActivityInlineCreateDraft = {
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:30:00.000Z',
|
||||
anchor: { x: 100, y: 200 },
|
||||
}
|
||||
|
||||
const MUNICIPALITIES: RelationOption[] = [
|
||||
{ id: 12, name: 'Camaçari' },
|
||||
{ id: 13, name: 'Ilhéus' },
|
||||
]
|
||||
|
||||
const renderOverlay = ({
|
||||
draft = DRAFT,
|
||||
agendaState = {},
|
||||
isNarrow = false,
|
||||
knownTags = [],
|
||||
onCreated = vi.fn(),
|
||||
onClose = vi.fn(),
|
||||
}: {
|
||||
draft?: ActivityInlineCreateDraft | null
|
||||
agendaState?: ActivityAgendaState
|
||||
isNarrow?: boolean
|
||||
knownTags?: string[]
|
||||
onCreated?: () => void
|
||||
onClose?: () => void
|
||||
} = {}) =>
|
||||
render(
|
||||
<ActivityInlineCreate
|
||||
draft={draft}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={MUNICIPALITIES}
|
||||
knownTags={knownTags}
|
||||
onClose={onClose}
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
)
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ActivityInlineCreate — overlay de criação inline', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('preenche horários do slot (24h) e usa o município do filtro ativo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
// C97 — the native datetime-local is gone: the trigger button shows the
|
||||
// civil label, 24h by construction (no browser locale can inject AM/PM).
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026 às 13:30')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
|
||||
})
|
||||
|
||||
it('troca dia no calendário e hora/minuto nos passos, sem perder o outro eixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const picker = within(dialogs[dialogs.length - 1])
|
||||
|
||||
fireEvent.click(picker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 13:00')
|
||||
|
||||
fireEvent.change(picker.getByRole('combobox', { name: 'Hora' }), {
|
||||
target: { value: '09' },
|
||||
})
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:00')
|
||||
|
||||
fireEvent.change(picker.getByRole('combobox', { name: 'Minuto' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026 às 09:45')
|
||||
})
|
||||
|
||||
it('cria sem navegar e avisa o pai para recarregar a janela', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Local (opcional)'), {
|
||||
target: { value: 'Centro histórico' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Término'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const endPicker = within(dialogs[dialogs.length - 1])
|
||||
fireEvent.change(endPicker.getByRole('combobox', { name: 'Minuto' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.createInline).toHaveBeenCalledWith({
|
||||
title: 'Café com apoiadores',
|
||||
municipality: 12,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:45:00.000Z',
|
||||
locality: 'Centro histórico',
|
||||
})
|
||||
expect(onCreated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bloqueia salvar sem município e não invoca a ação', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: {}, onCreated })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy())
|
||||
expect(mocks.createInline).not.toHaveBeenCalled()
|
||||
expect(onCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mantém o overlay aberto com erro inline quando a criação falha', async () => {
|
||||
const onCreated = vi.fn()
|
||||
const message = 'Já existe uma atividade com este título.'
|
||||
mocks.createInline.mockResolvedValue({
|
||||
ok: false,
|
||||
message,
|
||||
fieldErrors: { title: [message] },
|
||||
})
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
|
||||
await waitFor(() => expect(screen.getAllByText(message).length).toBeGreaterThan(0))
|
||||
expect(onCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('monta o href de "Mais detalhes" com município e título digitados', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
const link = screen.getByRole('link', { name: 'Mais detalhes' })
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'/campanha/atividades/nova?startAt=2026-08-07T16%3A00%3A00.000Z&endAt=2026-08-07T16%3A30%3A00.000Z&municipality=12&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
|
||||
)
|
||||
})
|
||||
|
||||
it('adiciona tags por Enter e as envia ao salvar (C105)', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
|
||||
|
||||
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
|
||||
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
|
||||
fireEvent.keyDown(tagInput, { key: 'Enter' })
|
||||
expect(screen.getByLabelText('Remover tag Panfletagem')).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Panfletagem no centro' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.createInline).toHaveBeenCalledWith({
|
||||
title: 'Panfletagem no centro',
|
||||
municipality: 12,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:30:00.000Z',
|
||||
tags: ['Panfletagem'],
|
||||
})
|
||||
expect(onCreated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('deduplica tags e permite remover um chip antes de salvar (C105)', async () => {
|
||||
const onCreated = vi.fn()
|
||||
mocks.createInline.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
|
||||
|
||||
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
|
||||
for (const tag of ['Comício', 'Comício', 'Caminhada']) {
|
||||
fireEvent.change(tagInput, { target: { value: tag } })
|
||||
fireEvent.keyDown(tagInput, { key: 'Enter' })
|
||||
}
|
||||
expect(screen.getByLabelText('Remover tag Comício')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Remover tag Caminhada')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Remover tag Comício'))
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Comício na feira' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
|
||||
expect(mocks.createInline).toHaveBeenCalledWith({
|
||||
title: 'Comício na feira',
|
||||
municipality: 12,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:30:00.000Z',
|
||||
tags: ['Caminhada'],
|
||||
})
|
||||
})
|
||||
|
||||
it('oferece as tags já usadas como sugestões e as carrega no href de detalhes (C105)', () => {
|
||||
renderOverlay({
|
||||
agendaState: { municipality: 12 },
|
||||
knownTags: ['Comício', 'Imprensa'],
|
||||
})
|
||||
|
||||
// The overlay renders inside a popover portal, so the datalist lives on
|
||||
// document.body, not in the container returned by render; the suggestion
|
||||
// values are `value` attributes, not option text.
|
||||
const datalist = document.querySelector('datalist')
|
||||
const suggestionValues = [...(datalist?.querySelectorAll('option') ?? [])].map((option) =>
|
||||
option.getAttribute('value'),
|
||||
)
|
||||
expect(suggestionValues).toContain('Comício')
|
||||
expect(suggestionValues).toContain('Imprensa')
|
||||
|
||||
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
|
||||
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
|
||||
fireEvent.keyDown(tagInput, { key: 'Enter' })
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Mais detalhes' })
|
||||
expect(link.getAttribute('href')).toContain('&tags=Panfletagem')
|
||||
})
|
||||
|
||||
it('não renderiza nada sem um rascunho de slot', () => {
|
||||
const { container } = renderOverlay({ draft: null })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renderiza o sheet do topo mobile com labels ocultos e rodapé fixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
// C103 — labels stay in the a11y tree (sr-only), placeholders give context
|
||||
// and required fields keep the asterisk.
|
||||
expect(screen.getByLabelText('Título *')).toBeTruthy()
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
|
||||
'Adicionar título *',
|
||||
)
|
||||
expect(screen.getByLabelText('Início *').textContent).toMatch(/07\/08\/2026 às 13:00/)
|
||||
expect(screen.getByLabelText('Início *').textContent).toContain('*')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
|
||||
'Município *',
|
||||
)
|
||||
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
|
||||
'Local (opcional)',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('abre o seletor de data/hora como bottom sheet no mobile, com Pronto', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const picker = within(dialogs[dialogs.length - 1])
|
||||
expect(picker.getByRole('combobox', { name: 'Hora' })).toBeTruthy()
|
||||
expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
|
||||
expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE,
|
||||
mapActivityInlineCreateError,
|
||||
} from '@/utilities/activityInlineErrors'
|
||||
|
||||
describe('activity inline create error mapping', () => {
|
||||
it('surfaces a DB unique-title violation as a title field error', () => {
|
||||
expect(
|
||||
mapActivityInlineCreateError(
|
||||
new Error('duplicate key value violates unique constraint "activity_slug_unique"'),
|
||||
),
|
||||
).toEqual({
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
})
|
||||
expect(mapActivityInlineCreateError(new Error('já existe uma atividade'))).toEqual({
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses every other failure to the generic message', () => {
|
||||
const generic = { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE }
|
||||
expect(mapActivityInlineCreateError(new Error('Boom'))).toEqual(generic)
|
||||
expect(mapActivityInlineCreateError('string')).toEqual(generic)
|
||||
expect(mapActivityInlineCreateError(undefined)).toEqual(generic)
|
||||
})
|
||||
})
|
||||
443
tests/unit/activityOverlay.unit.spec.tsx
Normal file
443
tests/unit/activityOverlay.unit.spec.tsx
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createOverlay: vi.fn(),
|
||||
updateOverlay: vi.fn(),
|
||||
loadEditDraft: vi.fn(),
|
||||
searchContacts: vi.fn(),
|
||||
searchResponsibles: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({
|
||||
createActivityOverlay: (...args: unknown[]) => mocks.createOverlay(...args),
|
||||
updateActivityOverlay: (...args: unknown[]) => mocks.updateOverlay(...args),
|
||||
loadActivityEditDraft: (...args: unknown[]) => mocks.loadEditDraft(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({
|
||||
searchActivityContactOptions: (query: string) => mocks.searchContacts(query),
|
||||
searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: mocks.toastError, success: mocks.toastSuccess },
|
||||
}))
|
||||
|
||||
import {
|
||||
ActivityOverlay,
|
||||
type ActivityOverlayRequest,
|
||||
} from '@/components/campaign/activity/ActivityOverlay'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import type { ActivityAgendaState } from '@/utilities/activityUi'
|
||||
import type { ActivityFormViewModel } from '@/utilities/activityViewModels'
|
||||
|
||||
const CREATE_REQUEST: ActivityOverlayRequest = {
|
||||
kind: 'create',
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T16:30:00.000Z',
|
||||
}
|
||||
|
||||
const EDIT_VIEW_MODEL: ActivityFormViewModel = {
|
||||
id: 42,
|
||||
title: 'Comício na feira',
|
||||
slug: 'comicio-na-feira',
|
||||
tags: ['Comício'],
|
||||
status: 'confirmado',
|
||||
description: 'Panfletagem no centro histórico',
|
||||
deputyPresent: true,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T16:00:00.000Z',
|
||||
endAt: '2026-08-07T17:00:00.000Z',
|
||||
municipalityId: 12,
|
||||
locality: 'Centro histórico',
|
||||
organizationIDs: [7],
|
||||
responsibles: [],
|
||||
tasks: [],
|
||||
}
|
||||
|
||||
const MUNICIPALITIES: RelationOption[] = [
|
||||
{ id: 12, name: 'Camaçari' },
|
||||
{ id: 13, name: 'Ilhéus' },
|
||||
]
|
||||
|
||||
const ORGANIZATIONS: RelationOption[] = [{ id: 7, name: 'Sindmed' }]
|
||||
|
||||
const renderOverlay = ({
|
||||
request = CREATE_REQUEST,
|
||||
agendaState = {},
|
||||
isNarrow = false,
|
||||
knownTags = [],
|
||||
onSaved = vi.fn(),
|
||||
onClose = vi.fn(),
|
||||
}: {
|
||||
request?: ActivityOverlayRequest | null
|
||||
agendaState?: ActivityAgendaState
|
||||
isNarrow?: boolean
|
||||
knownTags?: string[]
|
||||
onSaved?: () => void
|
||||
onClose?: () => void
|
||||
} = {}) =>
|
||||
render(
|
||||
<ActivityOverlay
|
||||
request={request}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={MUNICIPALITIES}
|
||||
organizationOptions={ORGANIZATIONS}
|
||||
knownTags={knownTags}
|
||||
onClose={onClose}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
|
||||
const submittedFormData = (): FormData => {
|
||||
const calls = mocks.createOverlay.mock.calls as unknown[][]
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
return calls[0][0] as FormData
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the calendar picker of a date trigger and returns the just-opened
|
||||
* dialog: the overlay dialog itself is a `role=dialog` too, so the picker is
|
||||
* always the LAST one in the tree (Radix portals mount in order).
|
||||
*/
|
||||
const openDatePicker = (label: string) => {
|
||||
fireEvent.click(screen.getByLabelText(label))
|
||||
return within(screen.getAllByRole('dialog').at(-1)!)
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ActivityOverlay — criação (modal central)', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('preenche horários do slot (24h), usa o município do filtro ativo e mostra cada seção uma vez', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
|
||||
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('13')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Minuto de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('00')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Término' }) as HTMLSelectElement).value,
|
||||
).toBe('13')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Minuto de Término' }) as HTMLSelectElement).value,
|
||||
).toBe('30')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
|
||||
|
||||
// C123 — "Local" aparece uma única vez no overlay.
|
||||
expect(screen.getAllByLabelText('Local (opcional)')).toHaveLength(1)
|
||||
|
||||
// Todas as seções do formulário completo estão no overlay.
|
||||
expect(screen.getByText('Informações básicas')).toBeTruthy()
|
||||
expect(screen.getByText('Data e horário')).toBeTruthy()
|
||||
expect(screen.getByText('Onde')).toBeTruthy()
|
||||
expect(screen.getByText('Pessoas e organizações')).toBeTruthy()
|
||||
// 'Tarefas' também é o label do campo de tarefas — card e campo convivem.
|
||||
expect(screen.getAllByText('Tarefas')).toHaveLength(2)
|
||||
expect(screen.getByText('Demandas')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Descrição')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Deputado presente')).toBeTruthy()
|
||||
expect(screen.getByRole('combobox', { name: /Organizações apoiadoras/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('troca dia no calendário e hora/minuto nos selects inline, sem perder o outro eixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 } })
|
||||
|
||||
const picker = openDatePicker('Início *')
|
||||
fireEvent.click(picker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('15/08/2026')
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Hora de Início' }), {
|
||||
target: { value: '09' },
|
||||
})
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Início' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
|
||||
const endPicker = openDatePicker('Término')
|
||||
fireEvent.click(endPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Hora de Término' }), {
|
||||
target: { value: '16' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
const formData = submittedFormData()
|
||||
expect(formData.get('startAt')).toBe('2026-08-15T09:45')
|
||||
// O minuto do término preserva o prefill (30) — só a hora mudou.
|
||||
expect(formData.get('endAt')).toBe('2026-08-15T16:30')
|
||||
}, 20_000) // worker load the Radix close animations can push past the 5s default. // Two calendar popovers open in sequence (start then end); under parallel
|
||||
|
||||
it('cria sem navegar, submete todas as seções e avisa o pai para recarregar', async () => {
|
||||
const onSaved = vi.fn()
|
||||
mocks.createOverlay.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Local (opcional)'), {
|
||||
target: { value: 'Centro histórico' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Descrição'), {
|
||||
target: { value: 'Café da manhã com lideranças' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText('Deputado presente'))
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Término' }), {
|
||||
target: { value: '45' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createOverlay).toHaveBeenCalledTimes(1))
|
||||
const formData = submittedFormData()
|
||||
expect(formData.get('title')).toBe('Café com apoiadores')
|
||||
expect(formData.get('municipality')).toBe('12')
|
||||
expect(formData.get('startAt')).toBe('2026-08-07T13:00')
|
||||
expect(formData.get('endAt')).toBe('2026-08-07T13:45')
|
||||
expect(formData.get('locality')).toBe('Centro histórico')
|
||||
expect(formData.get('description')).toBe('Café da manhã com lideranças')
|
||||
expect(formData.get('deputyPresent')).toBe('on')
|
||||
expect(formData.get('status')).toBe('confirmado')
|
||||
expect(formData.get('allDay')).toBeNull()
|
||||
expect(formData.get('id')).toBeNull()
|
||||
expect(onSaved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bloqueia salvar sem município e não invoca a ação', async () => {
|
||||
const onSaved = vi.fn()
|
||||
mocks.createOverlay.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: {}, onSaved })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy())
|
||||
expect(mocks.createOverlay).not.toHaveBeenCalled()
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mantém o overlay aberto com erro inline quando a criação falha', async () => {
|
||||
const onSaved = vi.fn()
|
||||
const message = 'Já existe uma atividade com este título.'
|
||||
mocks.createOverlay.mockResolvedValue({
|
||||
ok: false,
|
||||
message,
|
||||
fieldErrors: { title: [message] },
|
||||
})
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Café com apoiadores' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message))
|
||||
await waitFor(() => expect(screen.getAllByText(message).length).toBeGreaterThan(0))
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adiciona tags por Enter e as envia ao salvar (C105)', async () => {
|
||||
const onSaved = vi.fn()
|
||||
mocks.createOverlay.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onSaved })
|
||||
|
||||
const tagInput = screen.getByPlaceholderText('Ex.: comício, imprensa…')
|
||||
fireEvent.change(tagInput, { target: { value: 'Panfletagem' } })
|
||||
fireEvent.keyDown(tagInput, { key: 'Enter' })
|
||||
expect(screen.getByLabelText('Remover tag Panfletagem')).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Título *'), {
|
||||
target: { value: 'Panfletagem no centro' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
|
||||
|
||||
await waitFor(() => expect(mocks.createOverlay).toHaveBeenCalledTimes(1))
|
||||
expect(JSON.parse(submittedFormData().get('tagsJson') as string)).toEqual(['Panfletagem'])
|
||||
expect(onSaved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('oferece as tags já usadas como sugestões (C105)', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, knownTags: ['Comício', 'Imprensa'] })
|
||||
|
||||
const datalist = document.querySelector('datalist')
|
||||
const suggestionValues = [...(datalist?.querySelectorAll('option') ?? [])].map((option) =>
|
||||
option.getAttribute('value'),
|
||||
)
|
||||
expect(suggestionValues).toContain('Comício')
|
||||
expect(suggestionValues).toContain('Imprensa')
|
||||
})
|
||||
|
||||
it('não renderiza nada sem um pedido de abertura', () => {
|
||||
const { container } = renderOverlay({ request: null })
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('fecha pelo botão Cancelar sem salvar', () => {
|
||||
const onClose = vi.fn()
|
||||
renderOverlay({ agendaState: { municipality: 12 }, onClose })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancelar' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.createOverlay).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityOverlay — edição (modal central)', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('carrega o view model, pré-preenche tudo, trava o título e envia o id', async () => {
|
||||
const onSaved = vi.fn()
|
||||
mocks.loadEditDraft.mockResolvedValue(EDIT_VIEW_MODEL)
|
||||
mocks.updateOverlay.mockResolvedValue({ ok: true })
|
||||
renderOverlay({ request: { kind: 'edit', activityId: 42 }, onSaved })
|
||||
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
|
||||
'Comício na feira',
|
||||
),
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Editar atividade' })).toBeTruthy()
|
||||
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('13')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Término' }) as HTMLSelectElement).value,
|
||||
).toBe('14')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari')
|
||||
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).value).toBe(
|
||||
'Centro histórico',
|
||||
)
|
||||
expect((screen.getByLabelText('Descrição') as HTMLInputElement).value).toBe(
|
||||
'Panfletagem no centro histórico',
|
||||
)
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).readOnly).toBe(true)
|
||||
expect(screen.getByText('Comício')).toBeTruthy()
|
||||
expect(screen.getByRole('link', { name: 'Ver detalhes' }).getAttribute('href')).toBe(
|
||||
'/campanha/atividades/comicio-na-feira',
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: 'Minuto de Término' }), {
|
||||
target: { value: '30' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Salvar alterações' }))
|
||||
|
||||
await waitFor(() => expect(mocks.updateOverlay).toHaveBeenCalledTimes(1))
|
||||
const formData = mocks.updateOverlay.mock.calls[0][0] as FormData
|
||||
expect(formData.get('id')).toBe('42')
|
||||
expect(formData.get('title')).toBe('Comício na feira')
|
||||
expect(formData.get('startAt')).toBe('2026-08-07T13:00')
|
||||
expect(formData.get('endAt')).toBe('2026-08-07T14:30')
|
||||
expect(onSaved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mostra estado de carregamento e depois o formulário', async () => {
|
||||
let resolveDraft: (value: ActivityFormViewModel) => void = () => undefined
|
||||
mocks.loadEditDraft.mockImplementation(
|
||||
() =>
|
||||
new Promise<ActivityFormViewModel>((resolve) => {
|
||||
resolveDraft = resolve
|
||||
}),
|
||||
)
|
||||
renderOverlay({ request: { kind: 'edit', activityId: 42 } })
|
||||
|
||||
expect(screen.getByText('Carregando compromisso…')).toBeTruthy()
|
||||
resolveDraft(EDIT_VIEW_MODEL)
|
||||
await waitFor(() =>
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).value).toBe(
|
||||
'Comício na feira',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('mostra erro quando o rascunho não carrega e fecha pelo botão', async () => {
|
||||
const onClose = vi.fn()
|
||||
mocks.loadEditDraft.mockRejectedValue(new Error('Boom'))
|
||||
renderOverlay({ request: { kind: 'edit', activityId: 42 }, onClose })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('Não foi possível carregar este compromisso. Tente novamente.'),
|
||||
).toBeTruthy(),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fechar' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActivityOverlay — sheet mobile (C103)', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renderiza o sheet do topo com labels ocultos, hora inline e rodapé fixo', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
expect(screen.getByLabelText('Título *')).toBeTruthy()
|
||||
expect((screen.getByLabelText('Título *') as HTMLInputElement).placeholder).toBe(
|
||||
'Adicionar título *',
|
||||
)
|
||||
expect(screen.getByLabelText('Início *').textContent).toMatch(/^07\/08\/2026\*/)
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Hora de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('13')
|
||||
expect(
|
||||
(screen.getByRole('combobox', { name: 'Minuto de Início' }) as HTMLSelectElement).value,
|
||||
).toBe('00')
|
||||
expect((screen.getByLabelText('Município *') as HTMLInputElement).placeholder).toBe(
|
||||
'Município *',
|
||||
)
|
||||
expect((screen.getByLabelText('Local (opcional)') as HTMLInputElement).placeholder).toBe(
|
||||
'Local (opcional)',
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Responsáveis: Adicionar responsáveis' }),
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('abre o calendário como bottom sheet aninhado com Pronto; a hora fica inline', () => {
|
||||
renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true })
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Início *'))
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const picker = within(dialogs[dialogs.length - 1])
|
||||
expect(picker.getByRole('button', { name: 'Pronto' })).toBeTruthy()
|
||||
|
||||
fireEvent.click(picker.getByRole('button', { name: 'Pronto' }))
|
||||
expect(screen.queryByRole('button', { name: 'Pronto' })).toBeNull()
|
||||
})
|
||||
})
|
||||
53
tests/unit/activityOverlayErrors.unit.spec.ts
Normal file
53
tests/unit/activityOverlayErrors.unit.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
|
||||
ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
|
||||
} from '@/lib/activityOverlayMessages'
|
||||
import { FormDataBoundaryError } from '@/lib/formData'
|
||||
import { mapActivityOverlayError } from '@/utilities/activityOverlayErrors'
|
||||
|
||||
describe('activity overlay error mapping', () => {
|
||||
it('surfaces a DB unique-title violation as a title field error', () => {
|
||||
expect(
|
||||
mapActivityOverlayError(
|
||||
new Error('duplicate key value violates unique constraint "activity_slug_unique"'),
|
||||
),
|
||||
).toEqual({
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
})
|
||||
expect(mapActivityOverlayError(new Error('já existe uma atividade'))).toEqual({
|
||||
message: ACTIVITY_DUPLICATE_TITLE_MESSAGE,
|
||||
fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a DB unique-violation of a linked demand as a demandsJson field error', () => {
|
||||
expect(
|
||||
mapActivityOverlayError(
|
||||
new Error('duplicate key value violates unique constraint "campaign_demand_slug_unique"'),
|
||||
),
|
||||
).toEqual({
|
||||
message: ACTIVITY_DEMAND_DUPLICATE_MESSAGE,
|
||||
fieldErrors: { demandsJson: [ACTIVITY_DEMAND_DUPLICATE_MESSAGE] },
|
||||
})
|
||||
})
|
||||
|
||||
it('maps FormDataBoundaryError to the named field', () => {
|
||||
expect(mapActivityOverlayError(new FormDataBoundaryError('startAt', 'Data inválida.'))).toEqual(
|
||||
{
|
||||
message: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE,
|
||||
fieldErrors: { startAt: ['Data inválida.'] },
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('collapses every other failure to the generic message', () => {
|
||||
const generic = { message: ACTIVITY_OVERLAY_GENERIC_FAILURE_MESSAGE }
|
||||
expect(mapActivityOverlayError(new Error('Boom'))).toEqual(generic)
|
||||
expect(mapActivityOverlayError('string')).toEqual(generic)
|
||||
expect(mapActivityOverlayError(undefined)).toEqual(generic)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,19 +4,17 @@ import {
|
|||
activityAgendaViewFcId,
|
||||
activityAgendaViewLabels,
|
||||
activityAgendaViews,
|
||||
activityDefaultCreatePrefill,
|
||||
activitySlotPrefill,
|
||||
activityTabs,
|
||||
buildActivityAgendaHref,
|
||||
buildActivityAgendaSearchParams,
|
||||
buildActivityAgendaWhere,
|
||||
buildActivityCreateHref,
|
||||
buildActivityFiltersKey,
|
||||
buildActivityListHref,
|
||||
buildActivityListSearchParams,
|
||||
buildActivityListWhere,
|
||||
parseActivityAgendaParams,
|
||||
parseActivityAgendaReturnHref,
|
||||
parseActivityCreatePrefill,
|
||||
parseActivityListParams,
|
||||
resolveActivityAgendaUrl,
|
||||
resolveActivityListUrl,
|
||||
|
|
@ -261,19 +259,6 @@ describe('activity agenda view modes (C95)', () => {
|
|||
restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()),
|
||||
).toEqual({ view: 'list' })
|
||||
})
|
||||
|
||||
it('round-trips the view through the create return href', () => {
|
||||
expect(
|
||||
parseActivityAgendaReturnHref(
|
||||
'/campanha/agenda?municipality=12&view=month',
|
||||
new Set([12]),
|
||||
new Set(),
|
||||
),
|
||||
).toBe('/campanha/agenda?municipality=12&view=month')
|
||||
expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe(
|
||||
'/campanha/agenda',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildActivityAgendaWhere', () => {
|
||||
|
|
@ -306,215 +291,6 @@ describe('buildActivityAgendaWhere', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('activity create prefill', () => {
|
||||
it('carries the canonical agenda return URL into creation', () => {
|
||||
expect(
|
||||
buildActivityCreateHref(
|
||||
{ municipality: 12, deputyPresent: true, tag: 'Comício' },
|
||||
{
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: '2026-08-07T14:00:00.000Z',
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T14%3A00%3A00.000Z&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12%26deputyPresent%3D1%26tag%3DCom%25C3%25ADcio',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts only accessible canonical agenda return URLs', () => {
|
||||
expect(
|
||||
parseActivityAgendaReturnHref(
|
||||
'/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio',
|
||||
new Set([12]),
|
||||
new Set(['Comício']),
|
||||
),
|
||||
).toBe('/campanha/agenda?municipality=12&deputyPresent=1&tag=Com%C3%ADcio')
|
||||
expect(
|
||||
parseActivityAgendaReturnHref(
|
||||
'https://example.com/campanha/agenda?municipality=12',
|
||||
new Set([12]),
|
||||
new Set(),
|
||||
),
|
||||
).toBe('/campanha/agenda')
|
||||
})
|
||||
|
||||
it('normalizes a valid slot and accepts only an accessible municipality', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
startAt: '2026-08-07T10:00:00-03:00',
|
||||
endAt: '2026-08-07T11:00:00-03:00',
|
||||
municipality: '12',
|
||||
},
|
||||
new Set([12]),
|
||||
),
|
||||
).toEqual({
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: '2026-08-07T14:00:00.000Z',
|
||||
municipalityId: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops an invalid slot and an out-of-scope municipality', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
startAt: 'sexta de manhã',
|
||||
endAt: '2026-08-07T11:00:00-03:00',
|
||||
municipality: '99',
|
||||
},
|
||||
new Set([12]),
|
||||
),
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a valid start but drops an inverted end', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
startAt: '2026-08-07T10:00:00-03:00',
|
||||
endAt: '2026-08-07T09:00:00-03:00',
|
||||
},
|
||||
new Set(),
|
||||
),
|
||||
).toEqual({ startAt: '2026-08-07T13:00:00.000Z' })
|
||||
})
|
||||
|
||||
it('carries the inline title into the creation URL (C91)', () => {
|
||||
expect(
|
||||
buildActivityCreateHref(
|
||||
{ tag: 'Comício' },
|
||||
{
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: '2026-08-07T13:30:00.000Z',
|
||||
municipalityId: 3,
|
||||
title: 'Café com apoiadores',
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T13%3A30%3A00.000Z&municipality=3&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Ftag%3DCom%25C3%25ADcio',
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the inline municipality over the agenda filter', () => {
|
||||
expect(buildActivityCreateHref({ municipality: 12 }, { municipalityId: 3 })).toBe(
|
||||
'/campanha/atividades/nova?municipality=3&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
|
||||
)
|
||||
})
|
||||
|
||||
it('parses a bounded inline title and drops an oversized one', () => {
|
||||
expect(parseActivityCreatePrefill({ title: ' Café com apoiadores ' }, new Set())).toEqual({
|
||||
title: 'Café com apoiadores',
|
||||
})
|
||||
expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({})
|
||||
})
|
||||
|
||||
it('carries inline tags into the creation URL as repeated params (C105)', () => {
|
||||
expect(
|
||||
buildActivityCreateHref(
|
||||
{ municipality: 12 },
|
||||
{
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
municipalityId: 12,
|
||||
title: 'Panfletagem',
|
||||
tags: ['Panfletagem', 'Caminhada, café'],
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
'/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&municipality=12&title=Panfletagem&tags=Panfletagem&tags=Caminhada%2C+caf%C3%A9&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
|
||||
)
|
||||
})
|
||||
|
||||
it('round-trips inline tags through the create prefill (C105)', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{ tags: [' Panfletagem ', 'Caminhada, café', 'Panfletagem'] },
|
||||
new Set(),
|
||||
),
|
||||
).toEqual({ tags: ['Panfletagem', 'Caminhada, café'] })
|
||||
})
|
||||
|
||||
it('drops oversized and out-of-bound tags from the prefill (C105)', () => {
|
||||
const oversized = 'a'.repeat(81)
|
||||
expect(parseActivityCreatePrefill({ tags: [oversized, ' '] }, new Set())).toEqual({})
|
||||
expect(parseActivityCreatePrefill({ tags: ['válida', oversized] }, new Set())).toEqual({
|
||||
tags: ['válida'],
|
||||
})
|
||||
})
|
||||
|
||||
it('carries the all-day choice as civil dates into the creation URL (C104)', () => {
|
||||
expect(
|
||||
buildActivityCreateHref(
|
||||
{ municipality: 12 },
|
||||
{
|
||||
allDay: true,
|
||||
startAt: '2026-08-10',
|
||||
endAt: '2026-08-12',
|
||||
municipalityId: 12,
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
'/campanha/atividades/nova?allDay=1&startAt=2026-08-10&endAt=2026-08-12&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
|
||||
)
|
||||
})
|
||||
|
||||
it('parses an all-day prefill into day-boundary instants', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
allDay: '1',
|
||||
startAt: '2026-08-10',
|
||||
endAt: '2026-08-12',
|
||||
municipality: '12',
|
||||
},
|
||||
new Set([12]),
|
||||
),
|
||||
).toEqual({
|
||||
allDay: true,
|
||||
startAt: '2026-08-10T03:00:00.000Z',
|
||||
endAt: '2026-08-12T03:00:00.000Z',
|
||||
municipalityId: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it('allows a single-day all-day prefill (end equals start)', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
allDay: '1',
|
||||
startAt: '2026-08-10',
|
||||
endAt: '2026-08-10',
|
||||
},
|
||||
new Set(),
|
||||
),
|
||||
).toEqual({
|
||||
allDay: true,
|
||||
startAt: '2026-08-10T03:00:00.000Z',
|
||||
endAt: '2026-08-10T03:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops an inverted all-day end and malformed all-day dates', () => {
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{
|
||||
allDay: '1',
|
||||
startAt: '2026-08-12',
|
||||
endAt: '2026-08-10',
|
||||
},
|
||||
new Set(),
|
||||
),
|
||||
).toEqual({ allDay: true, startAt: '2026-08-12T03:00:00.000Z' })
|
||||
expect(
|
||||
parseActivityCreatePrefill(
|
||||
{ allDay: '1', startAt: '10/08/2026', endAt: '2026-08-10' },
|
||||
new Set(),
|
||||
),
|
||||
).toEqual({ allDay: true, endAt: '2026-08-10T03:00:00.000Z' })
|
||||
expect(parseActivityCreatePrefill({ allDay: '0' }, new Set())).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity slot prefill (C91)', () => {
|
||||
it('uses the snapped slot interval for time grids', () => {
|
||||
expect(activitySlotPrefill({ allDay: false, dateStr: '2026-08-07T13:00:00-03:00' })).toEqual({
|
||||
|
|
@ -534,4 +310,11 @@ describe('activity slot prefill (C91)', () => {
|
|||
expect(activitySlotPrefill({ allDay: false, dateStr: 'não é uma data' })).toBeNull()
|
||||
expect(activitySlotPrefill({ allDay: true, dateStr: 'não é uma data' })).toBeNull()
|
||||
})
|
||||
|
||||
it('defaults to today 09:00–10:00 for overlay hosts without a slot (C123)', () => {
|
||||
expect(activityDefaultCreatePrefill(new Date('2026-08-11T18:00:00.000Z'))).toEqual({
|
||||
startAt: '2026-08-11T12:00:00.000Z',
|
||||
endAt: '2026-08-11T13:00:00.000Z',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
import {
|
||||
ACTIVITY_DETAIL_ROOT_PATH,
|
||||
ACTIVITY_LIST_PATH,
|
||||
ACTIVITY_NEW_PATH,
|
||||
ACTIVITY_TOUR_COMPOSER_PATH,
|
||||
ORGANIZATIONS_LIST_PATH,
|
||||
ORGANIZATION_NEW_PATH,
|
||||
|
|
@ -116,7 +115,7 @@ describe('campaignQuickActionPaths (activities)', () => {
|
|||
})
|
||||
|
||||
it('ignores nova, giros and nested edit routes', () => {
|
||||
expect(parseActivityQuickActionSurface(ACTIVITY_NEW_PATH)).toBeNull()
|
||||
expect(parseActivityQuickActionSurface('/campanha/atividades/nova')).toBeNull()
|
||||
expect(parseActivityQuickActionSurface(ACTIVITY_TOUR_COMPOSER_PATH)).toBeNull()
|
||||
expect(parseActivityQuickActionSurface('/campanha/atividades/foo/editar')).toBeNull()
|
||||
})
|
||||
|
|
@ -190,9 +189,20 @@ describe('organizationQuickActions (B88)', () => {
|
|||
describe('activityQuickActions (B84)', () => {
|
||||
it('lists vertical verbs on the activity list', () => {
|
||||
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {})
|
||||
// C123 — "Nova atividade" opens the agenda overlay via the page-bridged
|
||||
// context; without a bridge the entry is omitted (never a dead /nova href).
|
||||
expect(actions.map((action) => action.id)).toEqual(['plan-tour'])
|
||||
expect(actions[0]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
|
||||
})
|
||||
|
||||
it('bridges "Nova atividade" to the overlay when the context is registered', () => {
|
||||
const openActivityCreate = () => undefined
|
||||
const actions = resolveActivityQuickActions({ kind: 'list' }, 'coordinator', {
|
||||
openActivityCreate,
|
||||
})
|
||||
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
|
||||
expect(actions[0]?.href).toBe(ACTIVITY_NEW_PATH)
|
||||
expect(actions[1]?.href).toBe(ACTIVITY_TOUR_COMPOSER_PATH)
|
||||
expect(actions[0]?.href).toBeUndefined()
|
||||
expect(actions[0]?.onAction).toBe(openActivityCreate)
|
||||
})
|
||||
|
||||
it('prefills wizards and detail shortcuts on activity detail', () => {
|
||||
|
|
@ -208,14 +218,25 @@ describe('activityQuickActions (B84)', () => {
|
|||
.find((action) => action.id === 'update-votes')
|
||||
?.href?.includes('municipio=feira-de-santana'),
|
||||
).toBe(true)
|
||||
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBe(
|
||||
'/campanha/atividades/comicio-feira/editar',
|
||||
)
|
||||
// C123 — "Editar" is bridged by the detail page; without the bridge it is
|
||||
// omitted, never a dead /editar href.
|
||||
expect(actions.find((action) => action.id === 'edit-activity')).toBeUndefined()
|
||||
expect(actions.find((action) => action.id === 'activity-tasks')?.href).toBe(
|
||||
'/campanha/atividades/comicio-feira?tab=tasks',
|
||||
)
|
||||
})
|
||||
|
||||
it('bridges "Editar" to the overlay when the context is registered', () => {
|
||||
const openActivityEdit = () => undefined
|
||||
const actions = resolveActivityQuickActions(
|
||||
{ kind: 'detail', activitySlug: 'panfletagem' },
|
||||
'advisor',
|
||||
{ activitySlug: 'panfletagem', openActivityEdit },
|
||||
)
|
||||
expect(actions.find((action) => action.id === 'edit-activity')?.href).toBeUndefined()
|
||||
expect(actions.find((action) => action.id === 'edit-activity')?.onAction).toBe(openActivityEdit)
|
||||
})
|
||||
|
||||
it('omits wizard prefills without municipality context', () => {
|
||||
const actions = resolveActivityQuickActions(
|
||||
{ kind: 'detail', activitySlug: 'panfletagem' },
|
||||
|
|
@ -224,11 +245,7 @@ describe('activityQuickActions (B84)', () => {
|
|||
)
|
||||
|
||||
expect(actions.some((action) => action.id === 'register-demand')).toBe(false)
|
||||
expect(actions.map((action) => action.id)).toEqual([
|
||||
'edit-activity',
|
||||
'activity-tasks',
|
||||
'activity-updates',
|
||||
])
|
||||
expect(actions.map((action) => action.id)).toEqual(['activity-tasks', 'activity-updates'])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -318,7 +335,13 @@ describe('campaignQuickActionRegistry', () => {
|
|||
})
|
||||
|
||||
it('delegates activity routes to the B84 catalog', () => {
|
||||
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {})
|
||||
// C123 — "Nova atividade" only appears once the agenda bridge registers.
|
||||
expect(
|
||||
resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {}).map((action) => action.id),
|
||||
).toEqual(['plan-tour'])
|
||||
const actions = resolveQuickActionsForPath(ACTIVITY_LIST_PATH, 'coordinator', {
|
||||
openActivityCreate: () => undefined,
|
||||
})
|
||||
expect(actions.map((action) => action.id)).toEqual(['new-activity', 'plan-tour'])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -151,12 +151,8 @@ describe('campaign formActions convention', () => {
|
|||
// signal — presence of the mapper is. A file that truly cannot use the wrappers
|
||||
// gets an allowlist entry documenting why:
|
||||
const allowlist = new Map<string, string>([
|
||||
// Custom unique-violation → fieldErrors + async duplicate-title fallback
|
||||
// that links to the existing activity — policy the wrappers don't grow for.
|
||||
[
|
||||
'src/app/(campaign)/campanha/(app)/atividades/formActions.ts',
|
||||
'unique-violation fieldErrors + async duplicate-title fallback',
|
||||
],
|
||||
// C123 — the activities form pages died with the overlay; the mapper moved
|
||||
// to `src/utilities/activityOverlayErrors.ts` (module, not a route ladder).
|
||||
// Flattens field errors into message-only states for inline detail controls.
|
||||
[
|
||||
'src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts',
|
||||
|
|
@ -395,7 +391,7 @@ describe('src/utilities top-level is pinned', () => {
|
|||
'activityDetailTabUi.ts',
|
||||
'activityAgendaOmnibox.ts',
|
||||
'activityFormData.ts',
|
||||
'activityInlineErrors.ts',
|
||||
'activityOverlayErrors.ts',
|
||||
'activityOmnibox.ts',
|
||||
'activityLeadershipOptions.ts',
|
||||
'activityPageData.ts',
|
||||
|
|
|
|||
Loading…
Reference in a new issue