From adc29c0379229cc766be97214375d6d298fe73b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 6 Aug 2026 05:20:05 -0300 Subject: [PATCH] feat(campaign): C14 remodel activity for agenda paradigm (#389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace kind enum with free-form tags (text[] hasMany) - Remove origin and deadline fields - Status: remove rascunho/planejado, keep confirmado/realizado/cancelado - startAt required for user-facing creates (optional for tour drafts) - Add deputyPresent time gate (only coordinator/candidate can reschedule) - UI: calendar-like form with tag input, no origin/deadline, no draft status - E13 giros: tourStopRoleActivityKind → tourStopRoleActivityTags - Migration: kind→tags with label mapping, status remap, enum recreation - Activity texts table creation (was missing from migration history) Closes #389 --- .../remodelar-atividades-para-agenda-impl.md | 157 +++++++++++ .../atividades/[slug]/ActivityOverviewTab.tsx | 19 +- .../campanha/(app)/atividades/[slug]/page.tsx | 8 +- .../(app)/atividades/giros/formActions.ts | 2 +- .../campanha/(app)/atividades/giros/page.tsx | 4 +- .../(campaign)/campanha/actions/activity.ts | 40 ++- src/collections/Activity.ts | 162 ++++++----- .../campaign/activity/ActivityCard.tsx | 7 +- .../campaign/activity/ActivityForm.tsx | 265 +++++++++--------- .../campaign/activity/ActivityStatusBadge.tsx | 2 - .../municipality/MunicipalityDossier.tsx | 8 +- .../campaign/tour/TourComposerForm.tsx | 65 +---- src/lib/schemas/activity.ts | 101 ++----- ...0806_120000_c14_remodel_activity_agenda.ts | 165 +++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 26 +- src/utilities/activityFormData.ts | 27 +- src/utilities/activityOmnibox.ts | 41 +-- src/utilities/activityUi.ts | 30 +- src/utilities/activityViewModels.ts | 31 +- src/utilities/ai/campaignNavigationUrls.ts | 8 +- src/utilities/ai/tools/buildCampaignLinks.ts | 4 +- .../municipality/municipalityTriggers.ts | 2 +- src/utilities/visit/visitPlannerViews.ts | 14 +- tests/int/campaignActivity.int.spec.ts | 83 ++---- tests/int/campaignSuggestions.int.spec.ts | 2 +- tests/int/homeSearchActivities.int.spec.ts | 29 +- tests/int/municipalityDossierData.int.spec.ts | 4 +- tests/unit/activityUi.unit.spec.ts | 30 +- tests/unit/listOmniboxB128.unit.spec.ts | 12 +- 30 files changed, 790 insertions(+), 564 deletions(-) create mode 100644 docs/plans/remodelar-atividades-para-agenda-impl.md create mode 100644 src/migrations/20260806_120000_c14_remodel_activity_agenda.ts diff --git a/docs/plans/remodelar-atividades-para-agenda-impl.md b/docs/plans/remodelar-atividades-para-agenda-impl.md new file mode 100644 index 00000000..b525ed55 --- /dev/null +++ b/docs/plans/remodelar-atividades-para-agenda-impl.md @@ -0,0 +1,157 @@ +# Impl: Remodelar atividades para a agenda (calendário) + +Status: aprovado +Atualizado em: 2026-08-06 +Issue: #389 +Intenção: docs/plans/remodelar-atividades-para-agenda.md +Appetite restante: ~1,5–2 dias eng (herdado) + +## Leitura da intenção + +- **Outcome:** Remodelar o modelo/formulário de atividade para parecer compromisso de agenda — tempo + lugar + quem + tags livres — em ~1 minuto. Lista/detalhe/actions continuam usáveis; C15 traz o calendário por cima. +- **O que NÃO negociar:** remover `origin` e `deadline`; renomear `kind` → `tags` (livres); status enxuto `confirmado | realizado | cancelado`; default `confirmado` + `startAt` obrigatório; remarcar horário com `deputyPresent` só coordinator/candidate; leader lockdown intacto; nenhuma nova collection de pessoa (Contact). +- **O que reavaliar:** hipótese da intenção de "omnibox de associados" — o formulário atual já tem `RelationMultiSelect` para orgs e checkboxes de assessores; uma omnibox mista (assessor + liderança + organização) é caro e o produto não mostrou dor real — **rejeitada** nesta leva (ver Rabbit holes). + +## Abordagem recomendada + +```mermaid +flowchart TD + A[Migration: drop origin/deadline, kind→tags, status remap] --> B[schema activity.ts: tags string array, 3 statuses, startAt required] + B --> C[Activity.ts collection: tags field, remove origin/deadline/kind, status default confirmado] + C --> D[View models: tags/origin/deadline out, startAt always required] + D --> E[Form: tags input + autocomplete, no origin/deadline, status toggle só confirmado] + E --> F[Card/Omnibox/Dossier: tags badges, tag filter] + F --> G[E13 giros: tourStopRoleActivityTags, tags in stop draft] + G --> H[AI tools + notification hooks: adapt] + H --> I[deputyPresent write gate via beforeValidate] + I --> J[Gates: gate:fast + push] +``` + +**Opções consideradas:** A | B | C +**Recomendação:** **B** — `text` com `hasMany: true` (Payload 3.x nativo, armazena `text[]`, indexável, queryable com `contains`) — porque é o mais simples que atende "tags livres + filtro" sem nova collection. +**Rejeitadas:** + +- A — `array` com campo `text` aninhado: mais verboso, cada tag é uma row com id, desnecessário para tags simples. +- C — `relationship` para nova collection `ActivityTag`: super-engineering, a intenção diz "a mesa inventa" — não precisa catálogo administrado. + +### Decisões de engenharia + +**1. Tags: `text` com `hasMany: true`** +Opções: A) `text hasMany` | B) `array` com text field | C) relationship a nova collection +Recomendação: **A** — Payload 3.x suporta nativamente, armazena como `text[]`, filtro `contains` funciona sem join. +Rejeitadas: B — verbosidade desnecessária; C — nova collection sem necessidade, vai contra "tags livres". + +**2. Migração de `kind` → `tags`** +Opções: A) migrar valores usando label map (comício → Comício) | B) migrar valores raw (comício) | C) descartar histórico +Recomendação: **A** — preserva legibilidade; as labels já são pt-BR amigáveis e é o que a UI sempre mostrou. +Rejeitadas: B — valores raw com underscore são feios como tag; C — perde histórico útil. + +**3. Remap de status antigos** +Opções: A) `planejado` → `confirmado`, `rascunho` → `cancelado` | B) tudo → `confirmado` | C) tudo → `cancelado` +Recomendação: **A** — planejados tinham data, viram confirmados; rascunhos eram esboços sem data, cancelados é o destino honesto. +Rejeitadas: B — rascunhos sem data virariam "confirmados" sem data, quebrando a invariante; C — perde trabalho já planejado. + +**4. deputyPresent write gate** +Opções: A) `beforeValidate` hook com role check | B) field-level access control | C) server action check +Recomendação: **A** — `beforeValidate` tem acesso a `req.user`, `originalDoc`, e `data`; cobre todos os caminhos (admin + server action). +Rejeitadas: B — field access não lê o doc atual facilmente; C — não cobre Payload admin. + +**5. Omnibox tag filter** +Opções: A) filter por tag exata (`contains`) com autocomplete dos valores existentes | B) multi-tag filter | C) manter kind enum no filtro +Recomendação: **A** — atende o job "filtrar por tipo de compromisso" sem inflar; multi-tag é rabbit hole para C15. +Rejeitadas: B — scope creep; C — viola aceite de produto. + +**6. Formulário de tags (client)** +Opções: A) tag input custom com datalist de valores conhecidos | B) Combobox multi-select | C) text field livre +Recomendação: **A** — datalist HTML nativo dá autocomplete sem JS extra; o servidor passa a lista de tags conhecidas via props. Combobox multi é overkill para v1; text livre não dá autocomplete. +Rejeitadas: B — dependência pesada para v1; C — sem autocomplete, viola opção B da intenção. + +### Componentes / mudanças + +- **Migration** (`20260806_XXXXXX_c14_remodel_activity_agenda`): + - Add `tags` column (`text[]`) + - Data: `tags = ARRAY[activity_kind_labels[kind]]` where kind is not null + - Remap: `planejado` → `confirmado`, `rascunho` → `cancelado` + - Drop `kind`, `origin`, `deadline` columns + - Add NOT NULL default `confirmado` on status + - JSON side: espelhar no `.json` +- **`src/lib/schemas/activity.ts`**: remover `activityKinds`/`activityOrigins`/`activityKindLabels`/`activityOriginLabels`/`ActivityKind`/`ActivityOrigin`; `activityStatuses` → `['confirmado', 'realizado', 'cancelado']`; adicionar `MAX_ACTIVITY_TAGS`, `MAX_ACTIVITY_TAG_LENGTH`; schema `tags: z.array(z.string().trim().min(1).max(80)).max(20).optional()`; remover `kind`/`origin`/`deadline` do `activityFieldsSchema`; `activityCreateSchema` default status → `confirmado`; `tourStopDraftSchema` — `kind` → `tags`. +- **`src/collections/Activity.ts`**: `tags` field (`text`, `hasMany: true`, `index: true`, `label: 'Tags'`); remover `kind`/`origin`/`deadline` fields; `status` options → 3 valores, default `'confirmado'`; `startAt` required; `activityStaffFieldSnapshot` atualizado; `validateActivitySchedule` adaptada (startAt sempre required); novo `validateDeputyPresentTimeGate` hook; `afterChange` remover check de `rascunho`; `defaultColumns` — trocar `kind` por `tags`. +- **`src/utilities/activityViewModels.ts`**: remover `kind`/`origin`/`deadline` de todos os view models; adicionar `tags: string[]`; `activityListSelect`/`activityFormSelect`/`activityDetailContextSelect` trocar `kind`/`origin`/`deadline` por `tags`. +- **`src/utilities/activityUi.ts`**: `ActivityListState.kind` → `.tag`; `buildActivityListWhere` — `{ tags: { contains: state.tag } }`; `isActivityKind` → `isActivityTag` (string validation); `parseActivityListParams` — `tag` instead of `kind`; `activityTabs` remover `rascunhos`. +- **`src/utilities/activityOmnibox.ts`**: `kind` filter → `tag` filter; seeds from `activityKindLabels` → from existing tags (server-provided); chips `kind:` → `tag:`. +- **`src/utilities/activityFormData.ts`**: remover `kind`/`origin`/`deadline` parsing; adicionar `tags` parsing (from `tagsJson` bounded JSON field). +- **`src/components/campaign/activity/ActivityForm.tsx`**: remover `kind` select, `origin` select, `deadline` field; adicionar `ActivityTagsField` (tag input + datalist); status toggle: apenas `confirmado` para novo; `startAt` always required. +- **`src/components/campaign/activity/ActivityCard.tsx`**: `activityKindLabels[activity.kind]` → render tags as small badges. +- **`src/components/campaign/activity/ActivityStatusBadge.tsx`**: remover `rascunho`/`planejado` variants. +- **`src/components/campaign/activity/ActivityFilters.tsx`**: kind → tag no omnibox. +- **`src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx`**: remover rows de deadline/origin; adicionar tags display. +- **`src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx`**: kind badge → tags badges. +- **`src/components/campaign/municipality/MunicipalityDossier.tsx`**: `activityKindLabels[activity.kind]` → tags badges. +- **`src/utilities/visit/visitPlannerViews.ts`**: `tourStopRoleActivityKind` → `tourStopRoleActivityTags: Record` (`ancora: ['Comício']`, `satelite: ['Reunião de apoio']`, `semente: ['Reunião de apoio']`). +- **`src/components/campaign/tour/TourComposerForm.tsx`**: `kind` → `tags`; `ActivityKind` → `string[]`; `origin` → removed; `DEFAULT_STOP_ORIGIN` → removed. +- **`src/utilities/ai/tools/buildCampaignLinks.ts`**: `kind: z.enum(activityKinds)` → `tag: z.string().optional()`; `status: z.enum(activityStatuses)` atualizado. +- **`src/utilities/ai/campaignNavigationUrls.ts`**: `kind?: ActivityKind` → `tag?: string`; `activityKinds` reference removida; filter logic adapted. +- **`src/utilities/notification/notificationEvents.ts`**: `afterChange` hook `becameVisible` check — `rascunho` → `confirmado` (since rascunho doesn't exist anymore, `becameVisible` is always true for creates). +- **`src/utilities/access/activities.ts`**: adicionar `canUpdateDeputyPresentTime` para o gate do deputyPresent. +- **`tests/unit/activityUi.unit.spec.ts`**: atualizar para 3 statuses, tag filter. +- **`tests/unit/listOmniboxB128.unit.spec.ts`**: kind → tag. +- **`tests/unit/codebaseConventions.unit.spec.ts`**: vocabulary guard — `actionPlan`/`plano de ação` já existe; adicionar `activityKind` como forbidden? Não — `kind` vira `tags`, não há vocabulário proibido novo. + +### Dados → forma + +N/A — remodel de modelo, não apresentação de dados novos. + +## Fases verificáveis + +1. **Tracer / schema+server** (~40% do appetite) + - Migration (drop/add columns + data migration) + - `src/lib/schemas/activity.ts` — schema remodel + - `src/collections/Activity.ts` — collection remodel + hooks + - `src/utilities/activityViewModels.ts` — view models + - `src/utilities/activityUi.ts` — list state/where/params + - `src/utilities/activityFormData.ts` — form data parsing + - `src/app/(campaign)/campanha/actions/activity.ts` — actions + - `src/utilities/visit/visitPlannerViews.ts` — giro tags + - `src/utilities/notification/notificationEvents.ts` — hook + - Unit tests: `activityUi`, `listOmniboxB128` + - `pnpm exec tsc --noEmit` + `pnpm test` + +2. **UI** (~40% do appetite) + - `ActivityForm.tsx` — tags input, no origin/deadline/kind + - `ActivityCard.tsx` — tags badges + - `ActivityStatusBadge.tsx` — 3 variants + - `ActivityOverviewTab.tsx` — no deadline/origin + - `ActivityFilters.tsx` / `activityOmnibox.ts` — tag filter + - `TourComposerForm.tsx` — tags per stop + - `MunicipalityDossier.tsx` — tags display + - AI tools — tag/status + - `pnpm lint` + `pnpm build` against local DB + +3. **Gates** (~20% do appetite) + - `pnpm gate:fast` + - `pnpm push -u origin HEAD` + - PR + auto-merge + `gh pr checks --watch --required` + +## Rabbit holes / Não escopo (engenharia) + +- **Omnibox mista de associados** (assessor + liderança + organização num só campo): a intenção menciona, mas o formulário atual já funciona com campos separados e o aceite não depende disso. Fica para C15 se a dor aparecer. +- **Multi-tag filter**: o filtro de lista aceita UMA tag por vez. Multi-tag é scope creep para C15. +- **Recorrência / sync Google**: fora de escopo (C16). +- **FullCalendar / `/campanha/agenda`**: C15. +- **Redesign do compositor de giros**: "só não quebrar sem destino" — adaptar os tipos, não mudar o fluxo. +- **Catalog admin de tags**: a intenção rejeita explicitamente (opção C). Tags são livres. +- **`activityKind` vocabulary guard**: não adicionar — o termo some naturalmente com a migration. + +## Riscos e mitigação + +- **Migration destrutiva em prod**: a migration dropa colunas. Revisar SQL antes do deploy. Dados de `kind` são migrados para `tags`; `origin`/`deadline` são descartados sem backup (a intenção aceita explicitamente). +- **E13 giros quebrado**: `tourStopDraftSchema` muda de `kind` a `tags`. O giro passa a enviar `tags: ['Comício']` no lugar de `kind: 'comicio'`. Testar o fluxo end-to-end. +- **Omnibox sem tags existentes**: o autocomplete de tags precisa de uma lista de valores já usados. Na listagem, isso vem do Payload (query distinta de tags existentes) ou de um endpoint. **Mitigação**: v1 usa `datalist` populado pelo servidor via props da página (tags distintas do `payload.find` na listagem). C15 traz um endpoint dedicado se preciso. +- **deputyPresent gate hook**: o `beforeValidate` precisa ler `originalDoc.deputyPresent` e o role do `req.user`. Se o actor for advisor e tentar mudar `startAt`/`endAt` de atividade com `deputyPresent: true` → throw. Testar com fixtures de role. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (tags livres, 3 statuses, origin/deadline removidos, deputyPresent gate) +- [ ] Invariantes AGENTS/engineering-standards (transaction pattern, Contact convention, leader lockdown, naming) +- [ ] Testes de domínio previstos: `activityUi.unit.spec.ts`, `listOmniboxB128.unit.spec.ts`, `codebaseConventions.unit.spec.ts` diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index 2b53a939..9735f23f 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' -import { activityOriginLabels } from '@/lib/schemas/activity' import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand' import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData' @@ -72,17 +71,21 @@ export const ActivityOverviewTab = ({
Término
{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}
-
-
Prazo de conclusão
-
{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}
-
Responsável
{view.responsibleName ?? 'Não definido'}
-
-
Origem da atividade
-
{activityOriginLabels[view.origin]}
+
+
Tags
+
+ {view.tags.length > 0 + ? view.tags.map((tag) => ( + + {tag} + + )) + : '—'} +
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index f347fe97..40dbbe95 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -20,7 +20,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' -import { activityKindLabels } from '@/lib/schemas/activity' + import { getActivityDetailPageData } from '@/utilities/activityDetailPageData' import { getActivityDetailTabRedirect, @@ -109,7 +109,11 @@ export default async function ActivityDetailPage({
{view.deputyPresent ? Deputado presente : null} - {activityKindLabels[view.kind]} + {view.tags.map((tag) => ( + + {tag} + + ))} {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts index 7a8ba6f7..98278b73 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/formActions.ts @@ -32,7 +32,7 @@ export const createTourDraftsFormAction = async ( ): Promise => runCampaignRedirectFormAction({ execute: async () => createTourDraftActivities(parseTourDraftFormData(formData)), - redirectTo: () => buildActivityListHref({ page: 1, tab: 'rascunhos' }, 1), + redirectTo: () => buildActivityListHref({ page: 1, tab: 'todos' }, 1), safeMessages: TOUR_SAFE_MESSAGES, genericMessage: 'Não foi possível gerar os rascunhos do giro. Verifique se algum título já existe e tente novamente.', diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx index 92ba04f6..8e99d26b 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -24,7 +24,7 @@ import { buildTourComposerHref, parseTourComposerParams } from '@/utilities/visi import { composeTourSuggestion, resolveTourStopRole, - tourStopRoleActivityKind, + tourStopRoleActivityTags, tourSuggestionSlugs, type VisitCandidateViewModel, } from '@/utilities/visit/visitPlannerViews' @@ -52,7 +52,7 @@ const toStopOption = ( name: candidate.name, role, suggested, - kind: tourStopRoleActivityKind[role], + tags: tourStopRoleActivityTags[role], metCount: candidate.eligibility.metCount, conditionCount: candidate.eligibility.conditions.length, unmetConditionLabels: candidate.eligibility.conditions diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index 709cc53f..01710537 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -16,7 +16,7 @@ import { type ActivityDemandDraft, type ActivityUpdateInput, } from '@/lib/schemas/activity' -import type { CampaignUser } from '@/payload-types' +import type { Activity, CampaignUser } from '@/payload-types' import type { ParsedTourDraftFormData } from '@/utilities/activityFormData' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' @@ -40,7 +40,11 @@ export const createActivityRecord = async ( input: ActivityCreateInput, demandDrafts: ActivityDemandDraft[] = [], ) => { - const data = activityCreateSchema.parse(input) + const raw = activityCreateSchema.parse(input) + // Payload date fields accept string | undefined, not null. Strip nulls. + const data = Object.fromEntries( + Object.entries(raw).filter(([, value]) => value !== null), + ) as Record const demands = activityDemandDraftsSchema.parse(demandDrafts) return withPayloadTransaction( @@ -61,7 +65,7 @@ export const createActivityRecord = async ( collection: 'campaignDemand', data: hookFilledCreateData<'campaignDemand'>({ ...demand, - municipality: data.municipality, + municipality: raw.municipality, activity: activity.id, }), depth: 0, @@ -130,14 +134,18 @@ export const createTourDraftActivitiesRecord = async ( const created = [] for (const stop of stops) { - const data = activityCreateSchema.parse({ + // Tour stops don't carry a startAt — the coordination sets it later. + // We bypass activityCreateSchema (which requires startAt) and build + // the create data directly; the beforeValidate hook allows this via + // the isTourDraft context flag. + const data = { ...stop, title: `${tourName} — ${nameByID.get(stop.municipality)}`, - status: 'rascunho', - // The whole point of the planner: these drafts are the candidate's agenda. + status: 'confirmado' as const, deputyPresent: true, ...(note ? { description: note } : {}), - }) + tags: stop.tags ?? [], + } created.push( await payload.create({ @@ -146,6 +154,7 @@ export const createTourDraftActivitiesRecord = async ( depth: 0, user: currentActor, overrideAccess: false, + context: { isTourDraft: true }, req, }), ) @@ -163,7 +172,18 @@ const updateActivityRecord = async ( input: ActivityUpdateInput, demandDrafts: ActivityDemandDraft[] = [], ) => { - const { id, ...data } = activityUpdateSchema.parse(input) + const { id, ...rawData } = activityUpdateSchema.parse(input) + // Payload date fields accept string | undefined, not null. + // Strip nulls only for required-ish fields; keep null for optional + // fields (endAt, responsible, leadership) so clearing them works. + const data = { + ...Object.fromEntries( + Object.entries(rawData).filter(([key, value]) => { + if (value === null && (key === 'startAt' || key === 'status')) return false + return true + }), + ), + } as Record const demands = activityDemandDraftsSchema.parse(demandDrafts) return withPayloadTransaction( @@ -171,7 +191,7 @@ const updateActivityRecord = async ( async ({ req }) => { const currentActor = await reloadCampaignActor(payload, actor, req) - const activity = await payload.update({ + const activity = (await payload.update({ collection: 'activity', id, data, @@ -179,7 +199,7 @@ const updateActivityRecord = async ( user: currentActor, overrideAccess: false, req, - }) + })) as unknown as Activity const municipality = typeof activity.municipality === 'number' ? activity.municipality : activity.municipality.id diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index fdf13d2d..0de1a99f 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -6,24 +6,16 @@ import type { import { APIError } from 'payload' import { relationshipId } from '@/lib/relationship' -import { - activityKindLabels, - activityKinds, - activityOriginLabels, - activityOrigins, - activityStatusLabels, - activityStatuses, -} from '@/lib/schemas/activity' +import { activityStatusLabels, activityStatuses } from '@/lib/schemas/activity' import { slugify } from '@/lib/slug' import { trimmedText } from '@/lib/text' +import { isCampaignUnrestricted } from '@/utilities/access/shared' import { canCreateActivity, canCreateActivityAdvisors, canDeleteActivity, canManageActivityAdvisors, - canManageCampaignStaffField, canReadActivity, - canReadCampaignStaffField, canSetActivityStatus, canSetActivitySystemField, canUpdateActivity, @@ -34,33 +26,22 @@ import { systemStampedActorField } from '@/utilities/campaignAuditFields' const isActivityMutationShortcut = (context: Record | undefined) => context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate' -const ACTIVITY_KIND_OPTIONS = activityKinds.map((value) => ({ - value, - label: activityKindLabels[value], -})) - const ACTIVITY_STATUS_OPTIONS = activityStatuses.map((value) => ({ value, label: activityStatusLabels[value], })) -const ACTIVITY_ORIGIN_OPTIONS = activityOrigins.map((value) => ({ - value, - label: activityOriginLabels[value], -})) - const relationshipIds = (value: unknown): number[] => (Array.isArray(value) ? value : []).map(relationshipId).filter((id): id is number => id !== null) const activityStaffFieldSnapshot = (doc: Record) => ({ title: trimmedText(doc.title), slug: doc.slug ?? null, - kind: doc.kind ?? null, + tags: Array.isArray(doc.tags) ? [...doc.tags].sort() : [], status: doc.status ?? null, description: trimmedText(doc.description), startAt: doc.startAt ?? null, endAt: doc.endAt ?? null, - deadline: doc.deadline ?? null, municipality: relationshipId(doc.municipality), locality: trimmedText(doc.locality), deputyPresent: Boolean(doc.deputyPresent), @@ -70,7 +51,6 @@ const activityStaffFieldSnapshot = (doc: Record) => ({ leadership: relationshipId(doc.leadership), resultSummary: trimmedText(doc.resultSummary), resultMedia: relationshipIds(doc.resultMedia), - origin: doc.origin ?? null, }) const setCanonicalActivitySlug: CollectionBeforeValidateHook = ({ @@ -102,17 +82,16 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({ }) => { if (isActivityMutationShortcut(context)) return data if (!data) return data + // Tour drafts are created without startAt; the coordination sets it later. + if (context?.isTourDraft) return data const nextData = operation === 'update' ? { ...originalDoc, ...data } : data - const status = typeof nextData.status === 'string' ? nextData.status : 'rascunho' const startAt = nextData.startAt ?? null const endAt = nextData.endAt ?? null - if (status !== 'rascunho' && !startAt) { - throw new APIError( - 'Informe a data e horário de início ao planejar ou confirmar a atividade.', - 400, - ) + // C14: startAt is always required (no more rascunho without date). + if (!startAt) { + throw new APIError('Informe a data e horário de início do compromisso.', 400) } if (startAt && endAt) { @@ -156,6 +135,43 @@ const validateActivityAdvisors: CollectionBeforeValidateHook = async ({ data, re return data } +/** + * C14 — deputyPresent time gate: only coordinator and candidate can reschedule + * (change startAt/endAt) an activity where the deputy is present. Advisors can + * create/edit the rest of the activity but cannot move the time of a + * deputy-present commitment. + */ +const validateDeputyPresentTimeGate: CollectionBeforeValidateHook = async ({ + data, + operation, + originalDoc, + req, + context, +}) => { + if (isActivityMutationShortcut(context)) return data + if (!data) return data + if (operation === 'create') return data + if (!originalDoc?.deputyPresent) return data + + const timeChanged = + (data.startAt !== undefined && data.startAt !== (originalDoc.startAt ?? null)) || + (data.endAt !== undefined && data.endAt !== (originalDoc.endAt ?? null)) + + if (!timeChanged) return data + + const user = req.user + if (!user || user.collection !== 'campaignUser') return data + + if (!isCampaignUnrestricted(user)) { + throw new APIError( + 'Apenas o Coordenador Geral ou o Candidato podem remarcar compromisso com deputado presente.', + 403, + ) + } + + return data +} + const deriveActivityFields: CollectionBeforeChangeHook = ({ data, operation, @@ -238,26 +254,26 @@ const deriveActivityFields: CollectionBeforeChangeHook = ({ 403, ) } - } - if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) { - const previousTasks = originalDoc.tasks as Record[] - if (data.tasks.length !== previousTasks.length) { + if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) { + const previousTasks = originalDoc.tasks as Record[] + if (data.tasks.length !== previousTasks.length) { + throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403) + } + for (const [index, task] of data.tasks.entries()) { + const previousTask = previousTasks[index] + const taskRecord = task as Record + if ( + trimmedText(taskRecord.title) !== trimmedText(previousTask.title) || + relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) || + (taskRecord.due ?? null) !== (previousTask.due ?? null) + ) { + throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403) + } + } + } else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) { throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403) } - for (const [index, task] of data.tasks.entries()) { - const previousTask = previousTasks[index] - const taskRecord = task as Record - if ( - trimmedText(taskRecord.title) !== trimmedText(previousTask.title) || - relationshipId(taskRecord.responsible) !== relationshipId(previousTask.responsible) || - (taskRecord.due ?? null) !== (previousTask.due ?? null) - ) { - throw new APIError('Lideranças só podem marcar tarefas como concluídas.', 403) - } - } - } else if (Array.isArray(data.tasks) && !Array.isArray(originalDoc?.tasks)) { - throw new APIError('Lideranças não podem adicionar ou remover tarefas.', 403) } } @@ -273,7 +289,7 @@ export const Activity: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'title', - defaultColumns: ['title', 'kind', 'status', 'municipality', 'startAt', 'updatedAt'], + defaultColumns: ['title', 'tags', 'status', 'municipality', 'startAt', 'updatedAt'], }, access: { create: canCreateActivity, @@ -282,16 +298,16 @@ export const Activity: CollectionConfig = { delete: canDeleteActivity, }, hooks: { - beforeValidate: [setCanonicalActivitySlug, validateActivitySchedule, validateActivityAdvisors], + beforeValidate: [ + setCanonicalActivitySlug, + validateActivitySchedule, + validateActivityAdvisors, + validateDeputyPresentTimeGate, + ], beforeChange: [deriveActivityFields], afterChange: [ - async ({ doc, operation, previousDoc, req }) => { - const becameVisible = - operation === 'create' - ? doc.status !== 'rascunho' - : previousDoc?.status === 'rascunho' && doc.status !== 'rascunho' - if (!becameVisible) return doc - + async ({ doc, req }) => { + // C14: all activities are visible (no more rascunho). const { notifyActivityNeedsAttention } = await import('@/utilities/notification/notificationEvents') await notifyActivityNeedsAttention(req, doc) @@ -325,19 +341,21 @@ export const Activity: CollectionConfig = { }, }, { - name: 'kind', - type: 'select', - label: 'Tipo de atividade', - required: true, + name: 'tags', + type: 'text', + label: 'Tags', + hasMany: true, index: true, - options: [...ACTIVITY_KIND_OPTIONS], + admin: { + description: 'Classificação livre do compromisso (ex.: comício, imprensa, reunião).', + }, }, { name: 'status', type: 'select', label: 'Status', required: true, - defaultValue: 'rascunho', + defaultValue: 'confirmado', index: true, access: { create: canSetActivityStatus, @@ -351,18 +369,6 @@ export const Activity: CollectionConfig = { label: 'Descrição', maxLength: 4000, }, - { - name: 'origin', - type: 'select', - label: 'Origem da atividade', - defaultValue: 'dado', - options: ACTIVITY_ORIGIN_OPTIONS, - access: { - create: canManageCampaignStaffField, - read: canReadCampaignStaffField, - update: canManageCampaignStaffField, - }, - }, { name: 'deputyPresent', type: 'checkbox', @@ -394,16 +400,6 @@ export const Activity: CollectionConfig = { }, }, }, - { - name: 'deadline', - type: 'date', - label: 'Prazo de conclusão', - admin: { - date: { - pickerAppearance: 'dayAndTime', - }, - }, - }, { name: 'municipality', type: 'relationship', diff --git a/src/components/campaign/activity/ActivityCard.tsx b/src/components/campaign/activity/ActivityCard.tsx index e39cfa81..ad8bf0e9 100644 --- a/src/components/campaign/activity/ActivityCard.tsx +++ b/src/components/campaign/activity/ActivityCard.tsx @@ -4,14 +4,17 @@ import Link from 'next/link' import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge' import { Badge } from '@/components/ui/Badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { activityKindLabels } from '@/lib/schemas/activity' import { formatActivityWhenLabel, type ActivityListViewModel } from '@/utilities/activityViewModels' export const ActivityCard = ({ activity }: { activity: ActivityListViewModel }) => (
- {activityKindLabels[activity.kind]} + {activity.tags.map((tag) => ( + + {tag} + + ))} {activity.deputyPresent ? Deputado presente : null}
diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx index 204875a6..c7a4e840 100644 --- a/src/components/campaign/activity/ActivityForm.tsx +++ b/src/components/campaign/activity/ActivityForm.tsx @@ -31,14 +31,9 @@ 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 { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' import { formatIsoAsBahiaDateTimeInput } from '@/lib/campaignTime' import { isContactSearchQueryReady } from '@/lib/contactSearchQuery' -import { - activityKindLabels, - activityOriginLabels, - activityStatusLabels, -} from '@/lib/schemas/activity' +import { MAX_ACTIVITY_TAG_LENGTH, MAX_ACTIVITY_TAGS } from '@/lib/schemas/activity' import type { ActivityLeadershipOption } from '@/utilities/activityLeadershipOptions' import type { ActivityFormViewModel } from '@/utilities/activityViewModels' import { fieldError } from '@/utilities/campaignFormFields' @@ -66,9 +61,91 @@ export type ActivityFormFieldsProps = { submittedTitle?: string searchContacts: (query: string) => Promise searchLeaderships: (query: string) => Promise + /** Tags already used across activities, for autocomplete. */ + knownTags?: string[] } -const editableStatuses = ['rascunho', 'planejado'] as const +const TagInput = ({ + initialTags = [], + knownTags = [], + error, +}: { + initialTags?: string[] + knownTags?: string[] + error?: string +}) => { + const [tags, setTags] = useState(initialTags) + const [input, setInput] = useState('') + + const datalistId = 'activity-tags-datalist' + + const addTag = (raw: string) => { + const trimmed = raw.trim().slice(0, MAX_ACTIVITY_TAG_LENGTH) + if (!trimmed) return + if (tags.includes(trimmed)) return + if (tags.length >= MAX_ACTIVITY_TAGS) return + setTags((current) => [...current, trimmed]) + setInput('') + } + + const removeTag = (tag: string) => { + setTags((current) => current.filter((entry) => entry !== tag)) + } + + return ( +
+
+ {tags.map((tag) => ( + + {tag} + + + ))} + {tags.length < MAX_ACTIVITY_TAGS ? ( + setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault() + addTag(input) + } else if (event.key === 'Backspace' && !input && tags.length > 0) { + setTags((current) => current.slice(0, -1)) + } + }} + onBlur={() => addTag(input)} + placeholder={tags.length === 0 ? 'Ex.: comício, imprensa…' : 'Adicionar tag…'} + className="min-h-9 flex-1" + list={datalistId} + maxLength={MAX_ACTIVITY_TAG_LENGTH} + /> + ) : null} + + {knownTags + .filter((tag) => !tags.includes(tag)) + .map((tag) => ( + +
+ + + Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar. + + {error ? {error} : null} +
+ ) +} const ActivityFormFields = ({ municipalityOptions, @@ -80,15 +157,9 @@ const ActivityFormFields = ({ submittedTitle, searchContacts, searchLeaderships, + knownTags = [], }: ActivityFormFieldsProps) => { const errorFor = (name: string) => fieldError(fieldErrors, name) - const statusIsEditable = - !activity || editableStatuses.includes(activity.status as (typeof editableStatuses)[number]) - const [status, setStatus] = useState<(typeof editableStatuses)[number]>( - activity && editableStatuses.includes(activity.status as (typeof editableStatuses)[number]) - ? (activity.status as (typeof editableStatuses)[number]) - : 'rascunho', - ) const [responsible, setResponsible] = useState( activity?.responsible ? { @@ -104,11 +175,7 @@ const ActivityFormFields = ({ return ( - {statusIsEditable ? ( - - ) : ( - - )} + @@ -134,78 +201,16 @@ const ActivityFormFields = ({ {errorFor('title')} ) : null} -
- - Tipo de atividade * - - Selecione um tipo - {Object.entries(activityKindLabels).map(([value, label]) => ( - - {label} - - ))} - - {errorFor('kind') ? ( - {errorFor('kind')} - ) : null} - - - Status * - {statusIsEditable ? ( - <> - value && setStatus(value as typeof status)} - variant="outline" - className="flex w-full flex-wrap justify-start" - aria-label="Status da atividade" - > - Planejado - Rascunho - - - Rascunhos não exigem data. Atividades planejadas exigem data e horário de - início. - - - ) : ( - - Status atual: {activity ? activityStatusLabels[activity.status] : ''}. Use as - ações do detalhe para marcar como realizado ou cancelado. - - )} - -
- - Origem da atividade - - {Object.entries(activityOriginLabels).map(([value, label]) => ( - - {label} - - ))} - - - Registra se a atividade nasceu da análise de dados, de um pedido de articulação ou - de um compromisso político. - - {errorFor('origin') ? {errorFor('origin')} : null} + + + Tags + + Descrição