Merge pull request #405 from fsolla/agent/c14-remodel-atividade-agenda
feat(campaign): C14 remodel activity for agenda paradigm
This commit is contained in:
commit
2d241e0125
31 changed files with 792 additions and 568 deletions
157
docs/plans/remodelar-atividades-para-agenda-impl.md
Normal file
157
docs/plans/remodelar-atividades-para-agenda-impl.md
Normal file
|
|
@ -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<TourStopRole, string[]>` (`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`
|
||||
|
|
@ -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 = ({
|
|||
<dt className="text-muted-foreground">Término</dt>
|
||||
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Prazo de conclusão</dt>
|
||||
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Responsável</dt>
|
||||
<dd>{view.responsibleName ?? 'Não definido'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Origem da atividade</dt>
|
||||
<dd>{activityOriginLabels[view.origin]}</dd>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Tags</dt>
|
||||
<dd className="flex flex-wrap gap-1 pt-1">
|
||||
{view.tags.length > 0
|
||||
? view.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ActivityStatusBadge status={view.status} />
|
||||
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
|
||||
<Badge variant="secondary">{activityKindLabels[view.kind]}</Badge>
|
||||
{view.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<span className="text-muted-foreground">
|
||||
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const createTourDraftsFormAction = async (
|
|||
): Promise<CampaignFormActionState> =>
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<string, unknown>) => ({
|
||||
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<string, unknown>) => ({
|
|||
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<string, unknown>[]
|
||||
if (data.tasks.length !== previousTasks.length) {
|
||||
if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) {
|
||||
const previousTasks = originalDoc.tasks as Record<string, unknown>[]
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
|
||||
{activity.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<ActivityStatusBadge status={activity.status} />
|
||||
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<ContactComboboxOption[]>
|
||||
searchLeaderships: (query: string) => Promise<ActivityLeadershipOption[]>
|
||||
/** 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<string[]>(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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="rounded-sm opacity-70 hover:opacity-100"
|
||||
aria-label={`Remover tag ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{tags.length < MAX_ACTIVITY_TAGS ? (
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(event) => 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}
|
||||
<datalist id={datalistId}>
|
||||
{knownTags
|
||||
.filter((tag) => !tags.includes(tag))
|
||||
.map((tag) => (
|
||||
<option key={tag} value={tag} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<input type="hidden" name="tagsJson" value={JSON.stringify(tags)} />
|
||||
<FieldDescription>
|
||||
Classificação livre do compromisso. Digite e pressione Enter ou vírgula para adicionar.
|
||||
</FieldDescription>
|
||||
{error ? <FieldError>{error}</FieldError> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<ContactComboboxOption | null>(
|
||||
activity?.responsible
|
||||
? {
|
||||
|
|
@ -104,11 +175,7 @@ const ActivityFormFields = ({
|
|||
|
||||
return (
|
||||
<FieldGroup>
|
||||
{statusIsEditable ? (
|
||||
<input type="hidden" name="status" value={status} />
|
||||
) : (
|
||||
<input type="hidden" name="status" value={activity?.status ?? 'rascunho'} />
|
||||
)}
|
||||
<input type="hidden" name="status" value="confirmado" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -134,78 +201,16 @@ const ActivityFormFields = ({
|
|||
<FieldError id="title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('kind'))}>
|
||||
<FieldLabel htmlFor="kind">Tipo de atividade *</FieldLabel>
|
||||
<NativeSelect
|
||||
id="kind"
|
||||
name="kind"
|
||||
defaultValue={activity?.kind}
|
||||
required
|
||||
aria-invalid={Boolean(errorFor('kind'))}
|
||||
aria-describedby={errorFor('kind') ? 'kind-error' : undefined}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
>
|
||||
<NativeSelectOption value="">Selecione um tipo</NativeSelectOption>
|
||||
{Object.entries(activityKindLabels).map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{errorFor('kind') ? (
|
||||
<FieldError id="kind-error">{errorFor('kind')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLegend>Status *</FieldLegend>
|
||||
{statusIsEditable ? (
|
||||
<>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={status}
|
||||
onValueChange={(value) => value && setStatus(value as typeof status)}
|
||||
variant="outline"
|
||||
className="flex w-full flex-wrap justify-start"
|
||||
aria-label="Status da atividade"
|
||||
>
|
||||
<ToggleGroupItem value="planejado">Planejado</ToggleGroupItem>
|
||||
<ToggleGroupItem value="rascunho">Rascunho</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
Rascunhos não exigem data. Atividades planejadas exigem data e horário de
|
||||
início.
|
||||
</FieldDescription>
|
||||
</>
|
||||
) : (
|
||||
<FieldDescription>
|
||||
Status atual: {activity ? activityStatusLabels[activity.status] : ''}. Use as
|
||||
ações do detalhe para marcar como realizado ou cancelado.
|
||||
</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field data-invalid={Boolean(errorFor('origin'))}>
|
||||
<FieldLabel htmlFor="origin">Origem da atividade</FieldLabel>
|
||||
<NativeSelect
|
||||
id="origin"
|
||||
name="origin"
|
||||
defaultValue={activity?.origin ?? 'dado'}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11 sm:max-w-sm"
|
||||
aria-invalid={Boolean(errorFor('origin'))}
|
||||
>
|
||||
{Object.entries(activityOriginLabels).map(([value, label]) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>
|
||||
Registra se a atividade nasceu da análise de dados, de um pedido de articulação ou
|
||||
de um compromisso político.
|
||||
</FieldDescription>
|
||||
{errorFor('origin') ? <FieldError>{errorFor('origin')}</FieldError> : null}
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('tagsJson'))}>
|
||||
<FieldLabel>Tags</FieldLabel>
|
||||
<TagInput
|
||||
initialTags={activity?.tags ?? []}
|
||||
knownTags={knownTags}
|
||||
error={errorFor('tagsJson')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('description'))}>
|
||||
<FieldLabel htmlFor="description">Descrição</FieldLabel>
|
||||
<Textarea
|
||||
|
|
@ -236,9 +241,9 @@ const ActivityFormFields = ({
|
|||
<CardTitle>Data e horário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="startAt">Início {status !== 'rascunho' ? '*' : null}</FieldLabel>
|
||||
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
|
|
@ -246,7 +251,7 @@ const ActivityFormFields = ({
|
|||
defaultValue={
|
||||
activity?.startAt ? formatIsoAsBahiaDateTimeInput(activity.startAt) : ''
|
||||
}
|
||||
required={status !== 'rascunho'}
|
||||
required
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
|
||||
|
|
@ -270,23 +275,6 @@ const ActivityFormFields = ({
|
|||
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('deadline'))}>
|
||||
<FieldLabel htmlFor="deadline">Prazo de conclusão</FieldLabel>
|
||||
<Input
|
||||
id="deadline"
|
||||
name="deadline"
|
||||
type="datetime-local"
|
||||
defaultValue={
|
||||
activity?.deadline ? formatIsoAsBahiaDateTimeInput(activity.deadline) : ''
|
||||
}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('deadline'))}
|
||||
aria-describedby={errorFor('deadline') ? 'deadline-error' : undefined}
|
||||
/>
|
||||
{errorFor('deadline') ? (
|
||||
<FieldError id="deadline-error">{errorFor('deadline')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -463,51 +451,64 @@ export const ActivityForm = ({
|
|||
municipalityOptions,
|
||||
organizationOptions,
|
||||
advisorOptions,
|
||||
canManageAdvisors = false,
|
||||
canManageAdvisors,
|
||||
activity,
|
||||
submitLabel,
|
||||
fieldErrors: externalFieldErrors,
|
||||
submittedTitle,
|
||||
searchContacts,
|
||||
searchLeaderships,
|
||||
knownTags,
|
||||
submitLabel,
|
||||
}: ActivityFormFieldsProps & {
|
||||
action: ActivityFormAction
|
||||
submitLabel: string
|
||||
submitLabel?: string
|
||||
}) => {
|
||||
const [state, formAction, pending] = useActionState(action, {})
|
||||
const [state, submitAction, isPending] = useActionState(action, {})
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex max-w-3xl flex-col gap-6">
|
||||
{activity ? <input type="hidden" name="id" value={activity.id} /> : null}
|
||||
{state.message ? (
|
||||
<Alert variant="destructive" aria-live="polite">
|
||||
<AlertTitle>Não foi possível salvar</AlertTitle>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{state.existingHref ? (
|
||||
<Button asChild variant="outline" className="min-h-11 w-fit">
|
||||
<Link href={state.existingHref}>Abrir atividade existente</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<form action={submitAction} className="flex flex-col gap-5">
|
||||
<ActivityFormFields
|
||||
municipalityOptions={municipalityOptions}
|
||||
organizationOptions={organizationOptions}
|
||||
advisorOptions={advisorOptions}
|
||||
canManageAdvisors={canManageAdvisors}
|
||||
activity={activity}
|
||||
fieldErrors={state.fieldErrors}
|
||||
submittedTitle={state.submittedTitle}
|
||||
fieldErrors={state.fieldErrors ?? externalFieldErrors}
|
||||
submittedTitle={state.submittedTitle ?? submittedTitle}
|
||||
searchContacts={searchContacts}
|
||||
searchLeaderships={searchLeaderships}
|
||||
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}` : '/campanha/atividades'}>
|
||||
Cancelar
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="submit" className="min-h-11" disabled={pending}>
|
||||
{pending ? <Spinner data-icon="inline-start" /> : null}
|
||||
{pending ? 'Salvando…' : submitLabel}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import type { ActivityStatus } from '@/utilities/activityUi'
|
|||
type BadgeVariant = NonNullable<Parameters<typeof badgeVariants>[0]>['variant']
|
||||
|
||||
const statusVariant: Record<ActivityStatus, BadgeVariant> = {
|
||||
rascunho: 'outline',
|
||||
planejado: 'secondary',
|
||||
confirmado: 'default',
|
||||
realizado: 'estimate-confirmed',
|
||||
cancelado: 'destructive',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/Badge'
|
|||
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { activityKindLabels } from '@/lib/schemas/activity'
|
||||
|
||||
import type { ActivityListViewModel } from '@/utilities/activityViewModels'
|
||||
import {
|
||||
formatGoalCoverageDeficitLabel,
|
||||
|
|
@ -77,7 +77,11 @@ const ActivityListItem = ({ activity }: { activity: ActivityListViewModel }) =>
|
|||
>
|
||||
{activity.title}
|
||||
</Link>
|
||||
<Badge variant="secondary">{activityKindLabels[activity.kind]}</Badge>
|
||||
{activity.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
<ActivityStatusBadge status={activity.status} />
|
||||
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,16 +10,8 @@ import { Button } from '@/components/ui/button'
|
|||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import { Field, FieldDescription, FieldError, 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 {
|
||||
activityKindLabels,
|
||||
activityOriginLabels,
|
||||
activityOrigins,
|
||||
type ActivityKind,
|
||||
type ActivityOrigin,
|
||||
} from '@/lib/schemas/activity'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
import type { TourStopRole } from '@/utilities/visit/visitPlannerViews'
|
||||
|
|
@ -30,9 +22,6 @@ import {
|
|||
tourStopRoleLabels,
|
||||
} from '@/utilities/visit/visitPlannerViews'
|
||||
|
||||
/** The composition came from the numbers; the coordination changes it per stop. */
|
||||
const DEFAULT_STOP_ORIGIN: ActivityOrigin = 'dado'
|
||||
|
||||
/**
|
||||
* One candidate stop, already reduced to what the decision needs. Numbers arrive
|
||||
* formatted and the dossier href arrives built: the canonical serializers drag
|
||||
|
|
@ -45,8 +34,8 @@ export type TourStopOption = {
|
|||
name: string
|
||||
role: TourStopRole
|
||||
suggested: boolean
|
||||
/** Activity kind the stop generates, derived from its role. */
|
||||
kind: ActivityKind
|
||||
/** Activity tags the stop generates, derived from its role. */
|
||||
tags: string[]
|
||||
metCount: number
|
||||
conditionCount: number
|
||||
unmetConditionLabels: string[]
|
||||
|
|
@ -58,18 +47,13 @@ export type TourStopOption = {
|
|||
const StopRow = ({
|
||||
stop,
|
||||
checked,
|
||||
origin,
|
||||
onToggle,
|
||||
onOriginChange,
|
||||
}: {
|
||||
stop: TourStopOption
|
||||
checked: boolean
|
||||
origin: ActivityOrigin
|
||||
onToggle: (checked: boolean) => void
|
||||
onOriginChange: (origin: ActivityOrigin) => void
|
||||
}) => {
|
||||
const checkboxId = `tour-stop-${stop.slug}`
|
||||
const originId = `tour-origin-${stop.slug}`
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-3 p-4 data-[checked=false]:bg-muted/20" data-checked={checked}>
|
||||
|
|
@ -112,30 +96,17 @@ const StopRow = ({
|
|||
Ver dossiê
|
||||
<ExternalLinkIcon aria-hidden="true" className="ml-1 inline size-3" />
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Gera: {activityKindLabels[stop.kind]}
|
||||
<span className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
Tags:{' '}
|
||||
{stop.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checked ? (
|
||||
<Field className="sm:max-w-xs sm:pl-7">
|
||||
<FieldLabel htmlFor={originId}>Origem desta parada</FieldLabel>
|
||||
<NativeSelect
|
||||
id={originId}
|
||||
value={origin}
|
||||
onChange={(event) => onOriginChange(event.target.value as ActivityOrigin)}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
{activityOrigins.map((value) => (
|
||||
<NativeSelectOption key={value} value={value}>
|
||||
{activityOriginLabels[value]}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
|
@ -166,7 +137,6 @@ export const TourComposerForm = ({
|
|||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>(() =>
|
||||
stops.filter((stop) => stop.suggested).map((stop) => stop.slug),
|
||||
)
|
||||
const [origins, setOrigins] = useState<Record<string, ActivityOrigin>>({})
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs])
|
||||
|
||||
|
|
@ -177,11 +147,10 @@ export const TourComposerForm = ({
|
|||
.filter((stop) => selectedSet.has(stop.slug))
|
||||
.map((stop) => ({
|
||||
municipality: stop.municipalityID,
|
||||
kind: stop.kind,
|
||||
origin: origins[stop.slug] ?? DEFAULT_STOP_ORIGIN,
|
||||
tags: stop.tags,
|
||||
})),
|
||||
),
|
||||
[stops, selectedSet, origins],
|
||||
[stops, selectedSet],
|
||||
)
|
||||
|
||||
const overCap = selectedSlugs.length > MAX_TOUR_STOPS
|
||||
|
|
@ -209,11 +178,7 @@ export const TourComposerForm = ({
|
|||
key={stop.slug}
|
||||
stop={stop}
|
||||
checked={selectedSet.has(stop.slug)}
|
||||
origin={origins[stop.slug] ?? DEFAULT_STOP_ORIGIN}
|
||||
onToggle={(checked) => toggleStop(stop.slug, checked)}
|
||||
onOriginChange={(origin) =>
|
||||
setOrigins((current) => ({ ...current, [stop.slug]: origin }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -231,8 +196,8 @@ export const TourComposerForm = ({
|
|||
className="min-h-11"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Cada rascunho recebe este nome mais o município — assim o giro se lê como um conjunto na
|
||||
lista de Atividades.
|
||||
Cada compromisso recebe este nome mais o município — assim o giro se lê como um conjunto
|
||||
na lista de Atividades.
|
||||
</FieldDescription>
|
||||
{fieldError(state.fieldErrors, 'tourName') ? (
|
||||
<FieldError>{fieldError(state.fieldErrors, 'tourName')}</FieldError>
|
||||
|
|
@ -242,7 +207,7 @@ export const TourComposerForm = ({
|
|||
<FieldLabel htmlFor="tour-note">Nota de janela política</FieldLabel>
|
||||
<Textarea id="tour-note" name="note" rows={4} maxLength={4000} />
|
||||
<FieldDescription>
|
||||
Vai na descrição de cada rascunho: com quem falar, o que já foi combinado, o que não
|
||||
Vai na descrição de cada compromisso: com quem falar, o que já foi combinado, o que não
|
||||
pode ser dito.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
|
@ -270,7 +235,7 @@ export const TourComposerForm = ({
|
|||
? 'Selecione ao menos uma parada.'
|
||||
: overCap
|
||||
? `Um giro é um dia ou dois: escolha no máximo ${MAX_TOUR_STOPS} paradas.`
|
||||
: 'Os rascunhos entram sem data, com presença do candidato marcada. A data e o horário você define em cada atividade.'}
|
||||
: 'Os compromissos entram com presença do candidato marcada. A data e o horário você define em cada atividade.'}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -22,59 +22,17 @@ export const ACTIVITY_RESULT_TOO_LONG_MESSAGE =
|
|||
export const ACTIVITY_RESULT_STAFF_MESSAGE =
|
||||
'Apenas a equipe da campanha pode registrar o resultado da atividade.'
|
||||
|
||||
export const activityKinds = [
|
||||
'caminhada',
|
||||
'comicio',
|
||||
'carreata',
|
||||
'panfletagem',
|
||||
'porta_a_porta',
|
||||
'reuniao_apoio',
|
||||
'lancamento',
|
||||
'convencao',
|
||||
'ato',
|
||||
'entrevista',
|
||||
'producao_conteudo',
|
||||
'digital',
|
||||
'outro',
|
||||
] as const
|
||||
export type ActivityKind = (typeof activityKinds)[number]
|
||||
/**
|
||||
* C14 — tags are free-form labels the mesa invents (comício, imprensa, etc.).
|
||||
* No rigid taxonomy; the filter is the value. Max 20 tags, 80 chars each.
|
||||
*/
|
||||
export const MAX_ACTIVITY_TAGS = 20
|
||||
export const MAX_ACTIVITY_TAG_LENGTH = 80
|
||||
|
||||
export const activityStatuses = [
|
||||
'rascunho',
|
||||
'planejado',
|
||||
'confirmado',
|
||||
'realizado',
|
||||
'cancelado',
|
||||
] as const
|
||||
export const activityStatuses = ['confirmado', 'realizado', 'cancelado'] as const
|
||||
export type ActivityStatus = (typeof activityStatuses)[number]
|
||||
|
||||
export const activityOrigins = ['dado', 'pedido_broker', 'obrigacao_politica'] as const
|
||||
export type ActivityOrigin = (typeof activityOrigins)[number]
|
||||
|
||||
export const activityOriginLabels: Record<ActivityOrigin, string> = {
|
||||
dado: 'Baseado em dado',
|
||||
pedido_broker: 'Pedido de broker',
|
||||
obrigacao_politica: 'Obrigação política',
|
||||
}
|
||||
|
||||
export const activityKindLabels: Record<ActivityKind, string> = {
|
||||
caminhada: 'Caminhada',
|
||||
comicio: 'Comício',
|
||||
carreata: 'Carreata',
|
||||
panfletagem: 'Panfletagem',
|
||||
porta_a_porta: 'Porta a porta',
|
||||
reuniao_apoio: 'Reunião de apoio',
|
||||
lancamento: 'Lançamento',
|
||||
convencao: 'Convenção',
|
||||
ato: 'Ato',
|
||||
entrevista: 'Entrevista',
|
||||
producao_conteudo: 'Produção de conteúdo',
|
||||
digital: 'Digital',
|
||||
outro: 'Outro',
|
||||
}
|
||||
|
||||
export const activityStatusLabels: Record<(typeof activityStatuses)[number], string> = {
|
||||
rascunho: 'Rascunho',
|
||||
planejado: 'Planejado',
|
||||
export const activityStatusLabels: Record<ActivityStatus, string> = {
|
||||
confirmado: 'Confirmado',
|
||||
realizado: 'Realizado',
|
||||
cancelado: 'Cancelado',
|
||||
|
|
@ -102,15 +60,18 @@ const taskSchema = z.object({
|
|||
done: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const activityTagsSchema = z
|
||||
.array(z.string().trim().min(1).max(MAX_ACTIVITY_TAG_LENGTH))
|
||||
.max(MAX_ACTIVITY_TAGS)
|
||||
|
||||
const activityFieldsSchema = z.object({
|
||||
title: z.string().trim().min(2).max(160),
|
||||
kind: z.enum(activityKinds),
|
||||
tags: activityTagsSchema.optional(),
|
||||
status: z.enum(activityStatuses).optional(),
|
||||
description: trimmedOptionalText(4000),
|
||||
deputyPresent: z.boolean().optional(),
|
||||
startAt: z.string().datetime().optional().nullable(),
|
||||
endAt: z.string().datetime().optional().nullable(),
|
||||
deadline: z.string().datetime().optional().nullable(),
|
||||
municipality: positiveRelationshipId,
|
||||
locality: trimmedOptionalText(160),
|
||||
organizations: z.array(positiveRelationshipId).max(MAX_ACTIVITY_ORGANIZATIONS).optional(),
|
||||
|
|
@ -118,23 +79,21 @@ const activityFieldsSchema = z.object({
|
|||
responsible: positiveRelationshipId.optional(),
|
||||
leadership: positiveRelationshipId.optional(),
|
||||
tasks: z.array(taskSchema).optional(),
|
||||
origin: z.enum(activityOrigins).optional(),
|
||||
})
|
||||
|
||||
const validateSchedule = (
|
||||
data: {
|
||||
status?: (typeof activityStatuses)[number] | null
|
||||
status?: ActivityStatus | null
|
||||
startAt?: string | null
|
||||
endAt?: string | null
|
||||
},
|
||||
context: z.RefinementCtx,
|
||||
mode: 'create' | 'patch',
|
||||
requireStart: boolean,
|
||||
) => {
|
||||
const status = data.status ?? (mode === 'create' ? 'rascunho' : undefined)
|
||||
if (status && status !== 'rascunho' && !data.startAt) {
|
||||
if (requireStart && !data.startAt) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Informe a data e horário de início ao planejar ou confirmar a atividade.',
|
||||
message: 'Informe a data e horário de início do compromisso.',
|
||||
path: ['startAt'],
|
||||
})
|
||||
}
|
||||
|
|
@ -154,16 +113,15 @@ const validateSchedule = (
|
|||
|
||||
export const activityCreateSchema = activityFieldsSchema
|
||||
.extend({
|
||||
status: z.enum(['rascunho', 'planejado']).default('rascunho'),
|
||||
status: z.enum(['confirmado']).default('confirmado'),
|
||||
})
|
||||
.superRefine((data, context) => {
|
||||
validateSchedule(data, context, 'create')
|
||||
validateSchedule(data, context, true)
|
||||
})
|
||||
.transform((data) => ({
|
||||
...data,
|
||||
startAt: data.startAt ?? null,
|
||||
endAt: data.endAt ?? null,
|
||||
deadline: data.deadline ?? null,
|
||||
endAt: data.endAt ?? undefined,
|
||||
tags: data.tags ?? [],
|
||||
}))
|
||||
|
||||
export const activityUpdateSchema = activityFieldsSchema
|
||||
|
|
@ -174,27 +132,24 @@ export const activityUpdateSchema = activityFieldsSchema
|
|||
description: trimmedNullableText(4000),
|
||||
startAt: z.string().datetime().nullable().optional(),
|
||||
endAt: z.string().datetime().nullable().optional(),
|
||||
deadline: z.string().datetime().nullable().optional(),
|
||||
responsible: positiveRelationshipId.nullable().optional(),
|
||||
leadership: positiveRelationshipId.nullable().optional(),
|
||||
status: z.enum(activityStatuses).optional(),
|
||||
})
|
||||
.superRefine((data, context) => {
|
||||
validateSchedule(data, context, 'patch')
|
||||
validateSchedule(data, context, true)
|
||||
})
|
||||
|
||||
export type ActivityCreateInput = z.input<typeof activityCreateSchema>
|
||||
export type ActivityUpdateInput = z.input<typeof activityUpdateSchema>
|
||||
|
||||
/**
|
||||
* E13 — one stop of a giro, as the composer submits it: which município, what
|
||||
* happens there, where the decision came from. The title is deliberately NOT
|
||||
* part of it: it is composed on the server from the município's own name, so a
|
||||
* client cannot label a draft after a município it did not pick.
|
||||
* E13 — one stop of a giro, as the composer submits it: which município and
|
||||
* what tags the stop generates. The title is deliberately NOT part of it: it
|
||||
* is composed on the server from the município's own name, so a client cannot
|
||||
* label a draft after a município it did not pick.
|
||||
*/
|
||||
const tourStopDraftSchema = activityFieldsSchema
|
||||
.pick({ municipality: true, kind: true })
|
||||
.extend({ origin: z.enum(activityOrigins) })
|
||||
const tourStopDraftSchema = activityFieldsSchema.pick({ municipality: true, tags: true })
|
||||
|
||||
export const tourStopDraftsSchema = z.array(tourStopDraftSchema)
|
||||
|
||||
|
|
|
|||
165
src/migrations/20260806_120000_c14_remodel_activity_agenda.ts
Normal file
165
src/migrations/20260806_120000_c14_remodel_activity_agenda.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* C14 — Remodel activity for the agenda (calendar) paradigm.
|
||||
*
|
||||
* Schema changes:
|
||||
* - `kind` (enum select) → `tags` (text[] — free-form, hasMany text)
|
||||
* - `origin` (enum select) → dropped
|
||||
* - `deadline` (timestamp) → dropped
|
||||
* - `status` enum: remove `rascunho`/`planejado`, keep `confirmado`/`realizado`/`cancelado`
|
||||
*
|
||||
* Data migration:
|
||||
* - `kind` values are mapped to human-readable labels as tags (comício → Comício, etc.)
|
||||
* - `rascunho` → `cancelado` (drafts had no date, honest destination)
|
||||
* - `planejado` → `confirmado` (planned activities had dates, they stay as commitments)
|
||||
* - Default status becomes `confirmado`
|
||||
*/
|
||||
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
-- 1. Add tags column (text[])
|
||||
ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "tags" text[];
|
||||
|
||||
-- 2. Migrate kind → tags (map enum values to human-readable labels)
|
||||
UPDATE "activity"
|
||||
SET "tags" = ARRAY[
|
||||
CASE "kind"
|
||||
WHEN 'caminhada' THEN 'Caminhada'
|
||||
WHEN 'comicio' THEN 'Comício'
|
||||
WHEN 'carreata' THEN 'Carreata'
|
||||
WHEN 'panfletagem' THEN 'Panfletagem'
|
||||
WHEN 'porta_a_porta' THEN 'Porta a porta'
|
||||
WHEN 'reuniao_apoio' THEN 'Reunião de apoio'
|
||||
WHEN 'lancamento' THEN 'Lançamento'
|
||||
WHEN 'convencao' THEN 'Convenção'
|
||||
WHEN 'ato' THEN 'Ato'
|
||||
WHEN 'entrevista' THEN 'Entrevista'
|
||||
WHEN 'producao_conteudo' THEN 'Produção de conteúdo'
|
||||
WHEN 'digital' THEN 'Digital'
|
||||
WHEN 'outro' THEN 'Outro'
|
||||
ELSE 'Outro'
|
||||
END
|
||||
]
|
||||
WHERE "tags" IS NULL;
|
||||
|
||||
-- 3. Remap statuses: rascunho → cancelado, planejado → confirmado
|
||||
UPDATE "activity" SET "status" = 'cancelado' WHERE "status" = 'rascunho';
|
||||
UPDATE "activity" SET "status" = 'confirmado' WHERE "status" = 'planejado';
|
||||
|
||||
-- 4. Drop the old default (before changing enum type)
|
||||
ALTER TABLE "activity" ALTER COLUMN "status" DROP DEFAULT;
|
||||
|
||||
-- 5. Drop old columns
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "kind";
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "origin";
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "deadline";
|
||||
|
||||
-- 6. Drop old indexes on kind
|
||||
DROP INDEX IF EXISTS "activity_kind_idx";
|
||||
|
||||
-- 7. Add tags index (GIN for array containment queries)
|
||||
CREATE INDEX IF NOT EXISTS "activity_tags_idx" ON "activity" USING gin ("tags");
|
||||
|
||||
-- 8. Drop old enum types
|
||||
DROP TYPE IF EXISTS "public"."enum_activity_kind";
|
||||
DROP TYPE IF EXISTS "public"."enum_activity_origin";
|
||||
|
||||
-- 9. Recreate status enum without rascunho/planejado
|
||||
-- Drop partial index that references the old enum type
|
||||
DROP INDEX IF EXISTS "activity_upcoming_start_at_idx";
|
||||
|
||||
-- Convert column to text first to detach from old enum
|
||||
ALTER TABLE "activity"
|
||||
ALTER COLUMN "status" TYPE text
|
||||
USING "status"::text;
|
||||
|
||||
-- Drop the old enum type
|
||||
DROP TYPE "public"."enum_activity_status";
|
||||
|
||||
-- Create the new enum
|
||||
CREATE TYPE "public"."enum_activity_status" AS ENUM ('confirmado', 'realizado', 'cancelado');
|
||||
|
||||
-- Convert column back to the new enum type
|
||||
ALTER TABLE "activity"
|
||||
ALTER COLUMN "status" TYPE "public"."enum_activity_status"
|
||||
USING "status"::text::"public"."enum_activity_status";
|
||||
|
||||
-- Set the new default
|
||||
ALTER TABLE "activity" ALTER COLUMN "status" SET DEFAULT 'confirmado';
|
||||
|
||||
-- Recreate the partial index with the new enum type
|
||||
CREATE INDEX IF NOT EXISTS "activity_upcoming_start_at_idx" ON "activity"
|
||||
USING btree ("start_at")
|
||||
WHERE "status" = ANY (ARRAY['confirmado']::"public"."enum_activity_status"[]);
|
||||
|
||||
-- Payload text search table (was action_plan_texts, never renamed in the
|
||||
-- C13 rename migration). Payload 3.82.0 expects activity_texts.
|
||||
CREATE TABLE IF NOT EXISTS "activity_texts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"text" varchar
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "activity_texts_order_parent" ON "activity_texts" USING btree ("order", "parent_id");
|
||||
ALTER TABLE "activity_texts" ADD CONSTRAINT "activity_texts_parent_fk"
|
||||
FOREIGN KEY ("parent_id") REFERENCES "activity"("id") ON DELETE cascade ON UPDATE no action;
|
||||
`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
// The down migration restores the schema but cannot recover dropped data.
|
||||
// origin/deadline values are lost; kind is restored from tags[0] reverse-mapped.
|
||||
await db.execute(sql`
|
||||
-- Restore old enum types
|
||||
CREATE TYPE "public"."enum_activity_kind" AS ENUM ('caminhada', 'comicio', 'carreata', 'panfletagem', 'porta_a_porta', 'reuniao_apoio', 'lancamento', 'convencao', 'ato', 'entrevista', 'producao_conteudo', 'digital', 'outro');
|
||||
CREATE TYPE "public"."enum_activity_origin" AS ENUM ('dado', 'pedido_broker', 'obrigacao_politica');
|
||||
|
||||
-- Replace status enum with the old one
|
||||
ALTER TYPE "public"."enum_activity_status" RENAME TO "enum_activity_status_new";
|
||||
CREATE TYPE "public"."enum_activity_status" AS ENUM ('rascunho', 'planejado', 'confirmado', 'realizado', 'cancelado');
|
||||
ALTER TABLE "activity"
|
||||
ALTER COLUMN "status" TYPE "public"."enum_activity_status"
|
||||
USING "status"::text::"public"."enum_activity_status";
|
||||
DROP TYPE IF EXISTS "public"."enum_activity_status_new";
|
||||
|
||||
-- Restore status default
|
||||
ALTER TABLE "activity" ALTER COLUMN "status" SET DEFAULT 'rascunho';
|
||||
|
||||
-- Add columns back
|
||||
ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "kind" "public"."enum_activity_kind";
|
||||
ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "origin" "public"."enum_activity_origin" DEFAULT 'dado';
|
||||
ALTER TABLE "activity" ADD COLUMN IF NOT EXISTS "deadline" timestamp(3) with time zone;
|
||||
|
||||
-- Reverse-map tags[0] → kind (best effort)
|
||||
UPDATE "activity"
|
||||
SET "kind" = CASE "tags"[1]
|
||||
WHEN 'Caminhada' THEN 'caminhada'
|
||||
WHEN 'Comício' THEN 'comicio'
|
||||
WHEN 'Carreata' THEN 'carreata'
|
||||
WHEN 'Panfletagem' THEN 'panfletagem'
|
||||
WHEN 'Porta a porta' THEN 'porta_a_porta'
|
||||
WHEN 'Reunião de apoio' THEN 'reuniao_apoio'
|
||||
WHEN 'Lançamento' THEN 'lancamento'
|
||||
WHEN 'Convenção' THEN 'convencao'
|
||||
WHEN 'Ato' THEN 'ato'
|
||||
WHEN 'Entrevista' THEN 'entrevista'
|
||||
WHEN 'Produção de conteúdo' THEN 'producao_conteudo'
|
||||
WHEN 'Digital' THEN 'digital'
|
||||
ELSE 'outro'
|
||||
END
|
||||
WHERE "tags" IS NOT NULL AND array_length("tags", 1) > 0;
|
||||
|
||||
ALTER TABLE "activity" ALTER COLUMN "kind" SET NOT NULL;
|
||||
|
||||
-- Restore kind index
|
||||
CREATE INDEX IF NOT EXISTS "activity_kind_idx" ON "activity" USING btree ("kind");
|
||||
|
||||
-- Drop tags column and index
|
||||
DROP INDEX IF EXISTS "activity_tags_idx";
|
||||
ALTER TABLE "activity" DROP COLUMN IF EXISTS "tags";
|
||||
|
||||
-- Drop text search table
|
||||
DROP TABLE IF EXISTS "activity_texts";
|
||||
`)
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import * as migration_20260731_014319_add_campaign_notifications from './2026073
|
|||
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
|
||||
import * as migration_20260802_230000_strip_engagement_reversal_signals from './20260802_230000_strip_engagement_reversal_signals';
|
||||
import * as migration_20260804_061017_add_state_deputy_advisors from './20260804_061017_add_state_deputy_advisors';
|
||||
import * as migration_20260806_120000_c14_remodel_activity_agenda from './20260806_120000_c14_remodel_activity_agenda';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -252,4 +253,9 @@ export const migrations = [
|
|||
down: migration_20260804_061017_add_state_deputy_advisors.down,
|
||||
name: '20260804_061017_add_state_deputy_advisors'
|
||||
},
|
||||
{
|
||||
up: migration_20260806_120000_c14_remodel_activity_agenda.up,
|
||||
down: migration_20260806_120000_c14_remodel_activity_agenda.down,
|
||||
name: '20260806_120000_c14_remodel_activity_agenda'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -730,30 +730,18 @@ export interface Activity {
|
|||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
kind:
|
||||
| 'caminhada'
|
||||
| 'comicio'
|
||||
| 'carreata'
|
||||
| 'panfletagem'
|
||||
| 'porta_a_porta'
|
||||
| 'reuniao_apoio'
|
||||
| 'lancamento'
|
||||
| 'convencao'
|
||||
| 'ato'
|
||||
| 'entrevista'
|
||||
| 'producao_conteudo'
|
||||
| 'digital'
|
||||
| 'outro';
|
||||
status: 'rascunho' | 'planejado' | 'confirmado' | 'realizado' | 'cancelado';
|
||||
/**
|
||||
* Classificação livre do compromisso (ex.: comício, imprensa, reunião).
|
||||
*/
|
||||
tags?: string[] | null;
|
||||
status: 'confirmado' | 'realizado' | 'cancelado';
|
||||
description?: string | null;
|
||||
origin?: ('dado' | 'pedido_broker' | 'obrigacao_politica') | null;
|
||||
/**
|
||||
* Marque quando o deputado Jorge Solla estiver presente na atividade.
|
||||
*/
|
||||
deputyPresent?: boolean | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
deadline?: string | null;
|
||||
municipality: number | Municipality;
|
||||
locality?: string | null;
|
||||
organizations?: (number | Organization)[] | null;
|
||||
|
|
@ -1763,14 +1751,12 @@ export interface MunicipalityUpdateSelect<T extends boolean = true> {
|
|||
export interface ActivitySelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
kind?: T;
|
||||
tags?: T;
|
||||
status?: T;
|
||||
description?: T;
|
||||
origin?: T;
|
||||
deputyPresent?: T;
|
||||
startAt?: T;
|
||||
endAt?: T;
|
||||
deadline?: T;
|
||||
municipality?: T;
|
||||
locality?: T;
|
||||
organizations?: T;
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ import {
|
|||
} from '@/lib/formData'
|
||||
import {
|
||||
activityDemandDraftsSchema,
|
||||
activityKinds,
|
||||
activityOrigins,
|
||||
activityStatuses,
|
||||
MAX_ACTIVITY_DEMAND_DRAFTS,
|
||||
MAX_ACTIVITY_TAG_LENGTH,
|
||||
MAX_ACTIVITY_TAGS,
|
||||
tourStopDraftsSchema,
|
||||
type ActivityCreateInput,
|
||||
type ActivityDemandDraft,
|
||||
|
|
@ -36,7 +36,7 @@ const parseDateTimeFormField = (formData: FormData, field: string): string | und
|
|||
const value = optionalFormText(formData, field)
|
||||
if (!value) return undefined
|
||||
const iso = parseBahiaDateTimeInput(value)
|
||||
if (!iso) throw new FormDataBoundaryError(field, 'Data e horário inválidos.')
|
||||
if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.')
|
||||
return iso
|
||||
}
|
||||
|
||||
|
|
@ -100,20 +100,32 @@ const parseDemandDraftsFormData = (formData: FormData): ActivityDemandDraft[] =>
|
|||
throw new FormDataBoundaryError('demandsJson', 'Lista de demandas inválida.')
|
||||
}
|
||||
|
||||
const parseTagsFormData = (formData: FormData): string[] => {
|
||||
const raw = boundedJsonFormValue(formData, 'tagsJson', 4_000)
|
||||
if (raw === undefined) return []
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new FormDataBoundaryError('tagsJson', 'Lista de tags inválida.')
|
||||
}
|
||||
const tags = raw
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((tag) => tag.length > 0)
|
||||
.map((tag) => tag.slice(0, MAX_ACTIVITY_TAG_LENGTH))
|
||||
.slice(0, MAX_ACTIVITY_TAGS)
|
||||
return tags
|
||||
}
|
||||
|
||||
const parseSharedActivityFormData = (formData: FormData) => {
|
||||
const responsible = nullableRelationshipFormValue(formData, 'responsible')
|
||||
const leadership = nullableRelationshipFormValue(formData, 'leadership')
|
||||
|
||||
return {
|
||||
title: optionalFormText(formData, 'title') ?? '',
|
||||
kind: optionalFormText(formData, 'kind') as (typeof activityKinds)[number],
|
||||
origin: optionalFormText(formData, 'origin') as (typeof activityOrigins)[number] | undefined,
|
||||
tags: parseTagsFormData(formData),
|
||||
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
|
||||
description: optionalFormText(formData, 'description'),
|
||||
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
|
||||
startAt: parseDateTimeFormField(formData, 'startAt'),
|
||||
endAt: parseDateTimeFormField(formData, 'endAt'),
|
||||
deadline: parseDateTimeFormField(formData, 'deadline'),
|
||||
municipality: requiredRelationshipFormValue(formData, 'municipality'),
|
||||
locality: optionalFormText(formData, 'locality'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
|
|
@ -134,7 +146,7 @@ export const parseActivityCreateFormData = (formData: FormData): ParsedActivityC
|
|||
|
||||
return {
|
||||
...shared,
|
||||
status: (shared.status ?? 'rascunho') as ActivityCreateInput['status'],
|
||||
status: (shared.status ?? 'confirmado') as ActivityCreateInput['status'],
|
||||
responsible: shared.responsible ?? undefined,
|
||||
leadership: shared.leadership ?? undefined,
|
||||
organizations: shared.organizations.length ? shared.organizations : undefined,
|
||||
|
|
@ -195,7 +207,6 @@ export const parseActivityUpdateFormData = (formData: FormData): ParsedActivityU
|
|||
description: shared.description ?? null,
|
||||
startAt: shared.startAt ?? null,
|
||||
endAt: shared.endAt ?? null,
|
||||
deadline: shared.deadline ?? null,
|
||||
responsible: shared.responsible,
|
||||
leadership: shared.leadership,
|
||||
organizations: shared.organizations,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import { activityKindLabels, activityStatusLabels } from '@/lib/schemas/activity'
|
||||
import { activityStatusLabels } from '@/lib/schemas/activity'
|
||||
import {
|
||||
activityTabLabels,
|
||||
activityTabs,
|
||||
|
|
@ -38,14 +38,14 @@ const parseStateFromParams = (
|
|||
|
||||
const setExclusiveField = (
|
||||
state: ActivityListState,
|
||||
field: 'kind' | 'status' | 'municipality',
|
||||
field: 'tag' | 'status' | 'municipality',
|
||||
value: string | undefined,
|
||||
): ActivityListState => {
|
||||
const params = buildActivityListSearchParams(withPageReset(state))
|
||||
const raw: Record<string, string | string[] | undefined> = Object.fromEntries(params.entries())
|
||||
|
||||
if (field === 'kind') {
|
||||
raw.kind = value
|
||||
if (field === 'tag') {
|
||||
raw.tag = value
|
||||
} else if (field === 'status') {
|
||||
raw.status = value
|
||||
} else {
|
||||
|
|
@ -59,7 +59,6 @@ const tabKeywords: Record<ActivityTab, string[]> = {
|
|||
proximos: ['proximos', 'próximos', 'proximo', 'próximo'],
|
||||
todos: ['todos', 'todas'],
|
||||
realizados: ['realizados', 'realizado'],
|
||||
rascunhos: ['rascunhos', 'rascunho'],
|
||||
}
|
||||
|
||||
const setTab = (state: ActivityListState, tab: ActivityTab): ActivityListState => {
|
||||
|
|
@ -91,10 +90,10 @@ export const buildActivityOmniboxChips = ({
|
|||
})
|
||||
}
|
||||
|
||||
if (state.kind) {
|
||||
if (state.tag) {
|
||||
chips.push({
|
||||
id: `kind:${state.kind}`,
|
||||
label: chipLabel('Tipo', activityKindLabels[state.kind]),
|
||||
id: `tag:${state.tag}`,
|
||||
label: chipLabel('Tag', state.tag),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -121,9 +120,11 @@ export const buildActivityOmniboxChips = ({
|
|||
export const buildActivityOmniboxSuggestionSeeds = ({
|
||||
tab,
|
||||
municipalityOptions,
|
||||
knownTags,
|
||||
}: {
|
||||
tab: ActivityTab
|
||||
municipalityOptions: readonly ActivityFilterOption[]
|
||||
knownTags?: readonly string[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
|
|
@ -141,14 +142,14 @@ export const buildActivityOmniboxSuggestionSeeds = ({
|
|||
)
|
||||
}
|
||||
|
||||
for (const [value, label] of Object.entries(activityKindLabels)) {
|
||||
for (const tag of knownTags ?? []) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `kind:${value}`,
|
||||
group: 'Tipo',
|
||||
label,
|
||||
keywords: ['tipo', 'atividade'],
|
||||
id: `tag:${tag}`,
|
||||
group: 'Tag',
|
||||
label: tag,
|
||||
keywords: ['tag', 'tipo'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
|
|
@ -210,12 +211,12 @@ export const applyActivityOmniboxSuggestion = ({
|
|||
return { kind: 'url', state: next }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('kind:')) {
|
||||
const value = suggestionId.slice(5)
|
||||
if (suggestionId.startsWith('tag:')) {
|
||||
const value = suggestionId.slice(4)
|
||||
const next =
|
||||
state.kind === value
|
||||
? setExclusiveField(state, 'kind', undefined)
|
||||
: setExclusiveField(state, 'kind', value)
|
||||
state.tag === value
|
||||
? setExclusiveField(state, 'tag', undefined)
|
||||
: setExclusiveField(state, 'tag', value)
|
||||
return { kind: 'url', state: next }
|
||||
}
|
||||
|
||||
|
|
@ -253,8 +254,8 @@ export const removeActivityOmniboxChip = ({
|
|||
return { kind: 'url', state: setTab(state, 'proximos') }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('kind:')) {
|
||||
return { kind: 'url', state: setExclusiveField(state, 'kind', undefined) }
|
||||
if (chipId.startsWith('tag:')) {
|
||||
return { kind: 'url', state: setExclusiveField(state, 'tag', undefined) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('status:')) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery'
|
||||
import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
|
||||
import { activityStatuses, type ActivityStatus } from '@/lib/schemas/activity'
|
||||
import {
|
||||
buildListHref,
|
||||
firstValue,
|
||||
|
|
@ -13,39 +13,35 @@ import {
|
|||
|
||||
export const activityPageSize = 25
|
||||
|
||||
export const activityTabs = ['proximos', 'todos', 'realizados', 'rascunhos'] as const
|
||||
export const activityTabs = ['proximos', 'todos', 'realizados'] as const
|
||||
export type ActivityTab = (typeof activityTabs)[number]
|
||||
|
||||
export type ActivityStatus = (typeof activityStatuses)[number]
|
||||
export { type ActivityStatus }
|
||||
|
||||
export const activityTabLabels: Record<ActivityTab, string> = {
|
||||
proximos: 'Próximos',
|
||||
todos: 'Todos',
|
||||
realizados: 'Realizados',
|
||||
rascunhos: 'Rascunhos',
|
||||
}
|
||||
|
||||
export type ActivityListState = {
|
||||
page: number
|
||||
tab: ActivityTab
|
||||
q?: string
|
||||
kind?: ActivityKind
|
||||
tag?: string
|
||||
status?: ActivityStatus
|
||||
municipality?: number
|
||||
}
|
||||
|
||||
type RawSearchParams = CampaignListRawSearchParams
|
||||
|
||||
const activityListParamNames = ['q', 'tab', 'kind', 'status', 'municipality', 'page'] as const
|
||||
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
|
||||
|
||||
const activityListParamNameSet = new Set<string>(activityListParamNames)
|
||||
|
||||
const isActivityTab = (value: string | undefined): value is ActivityTab =>
|
||||
activityTabs.includes(value as ActivityTab)
|
||||
|
||||
const isActivityKind = (value: string | undefined): value is ActivityKind =>
|
||||
activityKinds.includes(value as ActivityKind)
|
||||
|
||||
const isActivityStatus = (value: string | undefined): value is ActivityStatus =>
|
||||
activityStatuses.includes(value as ActivityStatus)
|
||||
|
||||
|
|
@ -60,8 +56,8 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
|
|||
const q = isActivityListSearchReady(normalizedText(firstValue(params.q)))
|
||||
const rawTab = firstValue(params.tab)
|
||||
const tab = isActivityTab(rawTab) ? rawTab : 'proximos'
|
||||
const rawKind = firstValue(params.kind)
|
||||
const kind = isActivityKind(rawKind) ? rawKind : undefined
|
||||
const rawTag = firstValue(params.tag)
|
||||
const tag = rawTag?.trim() ? rawTag.trim() : undefined
|
||||
const rawStatus = tab === 'todos' ? firstValue(params.status) : undefined
|
||||
const status = isActivityStatus(rawStatus) ? rawStatus : undefined
|
||||
const municipality = strictDecimalInteger(firstValue(params.municipality))
|
||||
|
|
@ -70,7 +66,7 @@ export const parseActivityListParams = (params: RawSearchParams): ActivityListSt
|
|||
page: rawPage ?? 1,
|
||||
tab,
|
||||
...(q ? { q } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(municipality ? { municipality } : {}),
|
||||
}
|
||||
|
|
@ -88,16 +84,14 @@ export const buildActivityListWhere = (state: ActivityListState, now: Date): Whe
|
|||
}
|
||||
}
|
||||
|
||||
if (state.kind) filters.push({ kind: { equals: state.kind } })
|
||||
if (state.tag) filters.push({ tags: { contains: state.tag } })
|
||||
if (state.municipality) filters.push({ municipality: { equals: state.municipality } })
|
||||
|
||||
if (state.tab === 'proximos') {
|
||||
filters.push({ status: { in: ['planejado', 'confirmado'] } })
|
||||
filters.push({ status: { equals: 'confirmado' } })
|
||||
filters.push({ startAt: { greater_than_equal: now.toISOString() } })
|
||||
} else if (state.tab === 'realizados') {
|
||||
filters.push({ status: { equals: 'realizado' } })
|
||||
} else if (state.tab === 'rascunhos') {
|
||||
filters.push({ status: { equals: 'rascunho' } })
|
||||
} else if (state.status) {
|
||||
filters.push({ status: { equals: state.status } })
|
||||
}
|
||||
|
|
@ -113,7 +107,7 @@ export const buildActivityListSearchParams = (
|
|||
page: String(page),
|
||||
q: state.q,
|
||||
tab: state.tab,
|
||||
kind: state.kind,
|
||||
tag: state.tag,
|
||||
status: state.status,
|
||||
municipality: state.municipality === undefined ? undefined : String(state.municipality),
|
||||
})
|
||||
|
|
@ -121,7 +115,7 @@ export const buildActivityListSearchParams = (
|
|||
|
||||
if (canonicalState.q) params.set('q', canonicalState.q)
|
||||
if (canonicalState.tab !== 'proximos') params.set('tab', canonicalState.tab)
|
||||
if (canonicalState.kind) params.set('kind', canonicalState.kind)
|
||||
if (canonicalState.tag) params.set('tag', canonicalState.tag)
|
||||
if (canonicalState.status) params.set('status', canonicalState.status)
|
||||
if (canonicalState.municipality) params.set('municipality', String(canonicalState.municipality))
|
||||
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { isPopulatedRelationship, relationshipId } from '@/lib/relationship'
|
||||
import type { ActivityOrigin } from '@/lib/schemas/activity'
|
||||
import type { Activity, CampaignUser, Contact, Municipality, Organization } from '@/payload-types'
|
||||
import type { ActivityDetailTab } from '@/utilities/activityDetailTabUi'
|
||||
|
||||
|
|
@ -41,7 +40,7 @@ const formatActivityLocationLabel = ({
|
|||
export const activityListSelect = {
|
||||
title: true,
|
||||
slug: true,
|
||||
kind: true,
|
||||
tags: true,
|
||||
status: true,
|
||||
deputyPresent: true,
|
||||
startAt: true,
|
||||
|
|
@ -57,7 +56,7 @@ export type ActivityListViewModel = {
|
|||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
kind: Activity['kind']
|
||||
tags: string[]
|
||||
status: Activity['status']
|
||||
deputyPresent: boolean
|
||||
startAt: string | null
|
||||
|
|
@ -80,7 +79,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
|
|||
id: activity.id,
|
||||
title: activity.title,
|
||||
slug: activity.slug,
|
||||
kind: activity.kind,
|
||||
tags: activity.tags ?? [],
|
||||
status: activity.status,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
startAt: activity.startAt ?? null,
|
||||
|
|
@ -99,14 +98,12 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
|
|||
export const activityFormSelect = {
|
||||
title: true,
|
||||
slug: true,
|
||||
kind: true,
|
||||
origin: true,
|
||||
tags: true,
|
||||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
deadline: true,
|
||||
municipality: true,
|
||||
locality: true,
|
||||
organizations: true,
|
||||
|
|
@ -128,14 +125,12 @@ export type ActivityFormViewModel = {
|
|||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
kind: Activity['kind']
|
||||
origin: ActivityOrigin
|
||||
tags: string[]
|
||||
status: Activity['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
startAt: string | null
|
||||
endAt: string | null
|
||||
deadline: string | null
|
||||
municipalityId: number | null
|
||||
locality: string | null
|
||||
organizationIDs: number[]
|
||||
|
|
@ -152,14 +147,12 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
|
|||
id: activity.id,
|
||||
title: activity.title,
|
||||
slug: activity.slug,
|
||||
kind: activity.kind,
|
||||
origin: activity.origin ?? 'dado',
|
||||
tags: activity.tags ?? [],
|
||||
status: activity.status,
|
||||
description: activity.description ?? null,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
startAt: activity.startAt ?? null,
|
||||
endAt: activity.endAt ?? null,
|
||||
deadline: activity.deadline ?? null,
|
||||
municipalityId: relationshipId(activity.municipality),
|
||||
locality: activity.locality ?? null,
|
||||
organizationIDs: relationshipIds(activity.organizations),
|
||||
|
|
@ -201,14 +194,12 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
|
|||
const activityDetailContextSelect = {
|
||||
title: true,
|
||||
slug: true,
|
||||
kind: true,
|
||||
origin: true,
|
||||
tags: true,
|
||||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
deadline: true,
|
||||
municipality: true,
|
||||
locality: true,
|
||||
organizations: true,
|
||||
|
|
@ -269,14 +260,12 @@ export type ActivityDetailViewModel = {
|
|||
id: number
|
||||
title: string
|
||||
slug: string
|
||||
kind: Activity['kind']
|
||||
origin: ActivityOrigin
|
||||
tags: string[]
|
||||
status: Activity['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
startAt: string | null
|
||||
endAt: string | null
|
||||
deadline: string | null
|
||||
municipality: ActivityMunicipalitySummary | null
|
||||
locality: string | null
|
||||
locationLabel: string
|
||||
|
|
@ -340,14 +329,12 @@ export const toActivityDetailViewModel = (
|
|||
id: activity.id,
|
||||
title: activity.title,
|
||||
slug: activity.slug,
|
||||
kind: activity.kind,
|
||||
origin: activity.origin ?? 'dado',
|
||||
tags: activity.tags ?? [],
|
||||
status: activity.status,
|
||||
description: activity.description ?? null,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
startAt: activity.startAt ?? null,
|
||||
endAt: activity.endAt ?? null,
|
||||
deadline: activity.deadline ?? null,
|
||||
municipality,
|
||||
locality: activity.locality ?? null,
|
||||
locationLabel: formatActivityLocationLabel({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
|
|||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
|
||||
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
|
||||
import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
|
||||
import { activityStatuses } from '@/lib/schemas/activity'
|
||||
import {
|
||||
campaignDemandKinds,
|
||||
campaignDemandStatuses,
|
||||
|
|
@ -166,7 +166,7 @@ export type CampaignNavigationLinkRequest =
|
|||
destination: 'activityList'
|
||||
tab?: ActivityTab
|
||||
q?: string
|
||||
kind?: ActivityKind
|
||||
tag?: string
|
||||
status?: (typeof activityStatuses)[number]
|
||||
municipality?: number
|
||||
})
|
||||
|
|
@ -481,7 +481,7 @@ const buildPathForRequest = (
|
|||
}
|
||||
case 'activityList': {
|
||||
const tab = request.tab && activityTabs.includes(request.tab) ? request.tab : undefined
|
||||
const kind = request.kind && activityKinds.includes(request.kind) ? request.kind : undefined
|
||||
const tag = request.tag?.trim() ? request.tag.trim() : undefined
|
||||
const status =
|
||||
request.status && activityStatuses.includes(request.status) ? request.status : undefined
|
||||
const municipality =
|
||||
|
|
@ -493,7 +493,7 @@ const buildPathForRequest = (
|
|||
page: 1,
|
||||
tab: tab ?? 'proximos',
|
||||
...(request.q?.trim() ? { q: request.q.trim() } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
...(tag ? { tag } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(municipality ? { municipality } : {}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
campaignIntelligenceConcepts,
|
||||
type CampaignConceptId,
|
||||
} from '@/lib/campaignIntelligenceConcepts'
|
||||
import { activityKinds, activityStatuses } from '@/lib/schemas/activity'
|
||||
import { activityStatuses } from '@/lib/schemas/activity'
|
||||
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
|
||||
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
|
||||
import { organizationKinds } from '@/lib/schemas/organization'
|
||||
|
|
@ -156,7 +156,7 @@ const linkRequestSchema = z.discriminatedUnion('destination', [
|
|||
destination: z.literal('activityList'),
|
||||
tab: z.enum(activityTabs).optional(),
|
||||
q: z.string().optional(),
|
||||
kind: z.enum(activityKinds).optional(),
|
||||
tag: z.string().trim().min(1).optional(),
|
||||
status: z.enum(activityStatuses).optional(),
|
||||
municipality: z.number().int().positive().optional(),
|
||||
label: labelSchema,
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ const ADVERSARY_SIGNAL_TYPES: MunicipalitySignalType[] = [
|
|||
]
|
||||
|
||||
/** Agenda legs: drafts are intentions and cancellations never happened. */
|
||||
const AGENDA_ACTIVITY_STATUSES = ['planejado', 'confirmado', 'realizado'] as const
|
||||
const AGENDA_ACTIVITY_STATUSES = ['confirmado', 'realizado'] as const
|
||||
|
||||
const isoDaysAgo = (now: Date, days: number): string =>
|
||||
new Date(now.getTime() - days * DAY_MS).toISOString()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import type { ActivityKind } from '@/lib/schemas/activity'
|
||||
// C14: ActivityKind removed; stops carry free-form tags now.
|
||||
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/municipality/goalCoverage'
|
||||
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
|
|
@ -151,10 +151,14 @@ export const resolveTourStopRole = (
|
|||
* marker, so every stop keeps the real kind of what happens there — an act at
|
||||
* the anchor, a support meeting at a stop that is being opened.
|
||||
*/
|
||||
export const tourStopRoleActivityKind: Record<TourStopRole, ActivityKind> = {
|
||||
ancora: 'comicio',
|
||||
satelite: 'reuniao_apoio',
|
||||
semente: 'reuniao_apoio',
|
||||
/**
|
||||
* C14 — the activity tags each role generates. `deputyPresent` is still the
|
||||
* candidate-presence marker on the resulting activity.
|
||||
*/
|
||||
export const tourStopRoleActivityTags: Record<TourStopRole, string[]> = {
|
||||
ancora: ['Comício'],
|
||||
satelite: ['Reunião de apoio'],
|
||||
semente: ['Reunião de apoio'],
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures
|
|||
test.describe('Atividades — registro-fundação', () => {
|
||||
test.setTimeout(90_000)
|
||||
|
||||
test('registra origem, cria demandas vinculadas e exibe a explicação dos sinais', async ({
|
||||
test('cria compromisso com demandas vinculadas e exibe os sinais no município', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
|
|
@ -21,8 +21,7 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
|
||||
|
||||
await page.getByLabel('Título *').fill(activityTitle)
|
||||
await page.getByLabel('Tipo de atividade *').selectOption('caminhada')
|
||||
await page.getByLabel('Origem da atividade').selectOption('obrigacao_politica')
|
||||
await page.getByLabel('Início *').fill('2026-08-15T10:00')
|
||||
await page.getByLabel('Município *').selectOption(String(municipality.id))
|
||||
|
||||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||||
|
|
@ -37,7 +36,6 @@ test.describe('Atividades — registro-fundação', () => {
|
|||
await expect(campaignPageChrome(page, activityTitle)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
})
|
||||
await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ const campaignFixtures = installCampaignFixtures({
|
|||
|
||||
const validActivityInput = (municipalityId: number) => ({
|
||||
title: campaignFixtures().value('Caminhada Centro'),
|
||||
kind: 'caminhada' as const,
|
||||
status: 'planejado' as const,
|
||||
tags: ['Caminhada'],
|
||||
status: 'confirmado' as const,
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipalityId,
|
||||
locality: 'Centro',
|
||||
|
|
@ -50,23 +50,16 @@ describe('activity domain', () => {
|
|||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('requires startAt outside rascunho and allows drafts without it', () => {
|
||||
it('requires startAt on create and update', () => {
|
||||
// Create schema only accepts 'confirmado' and always requires startAt.
|
||||
expect(
|
||||
activityCreateSchema.safeParse({
|
||||
...validActivityInput(1),
|
||||
status: 'rascunho',
|
||||
startAt: undefined,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
activityCreateSchema.safeParse({
|
||||
...validActivityInput(1),
|
||||
status: 'planejado',
|
||||
startAt: undefined,
|
||||
}).success,
|
||||
).toBe(false)
|
||||
|
||||
// Update schema also requires startAt.
|
||||
expect(
|
||||
activityUpdateSchema.safeParse({
|
||||
id: 1,
|
||||
|
|
@ -79,38 +72,16 @@ describe('activity domain', () => {
|
|||
it('requires a municipality on create', () => {
|
||||
const result = activityCreateSchema.safeParse({
|
||||
title: campaignFixtures().value('Atividade sem município'),
|
||||
kind: 'caminhada',
|
||||
status: 'rascunho',
|
||||
tags: ['Caminhada'],
|
||||
status: 'confirmado',
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the recorded origin of a new activity', () => {
|
||||
const parsed = activityCreateSchema.parse({
|
||||
...validActivityInput(1),
|
||||
origin: 'pedido_broker',
|
||||
})
|
||||
|
||||
expect(parsed.origin).toBe('pedido_broker')
|
||||
})
|
||||
|
||||
it('parses the origin selected in the activity form', () => {
|
||||
const formData = new FormData()
|
||||
formData.set('title', 'Caminhada no centro')
|
||||
formData.set('kind', 'caminhada')
|
||||
formData.set('status', 'rascunho')
|
||||
formData.set('municipality', '1')
|
||||
formData.set('origin', 'obrigacao_politica')
|
||||
formData.set('tasksJson', '[]')
|
||||
|
||||
expect(parseActivityCreateFormData(formData).origin).toBe('obrigacao_politica')
|
||||
})
|
||||
|
||||
it('parses several demand drafts from the activity form', () => {
|
||||
const formData = new FormData()
|
||||
formData.set('title', 'Caminhada no centro')
|
||||
formData.set('kind', 'caminhada')
|
||||
formData.set('status', 'rascunho')
|
||||
formData.set('status', 'cancelado')
|
||||
formData.set('municipality', '1')
|
||||
formData.set('tasksJson', '[]')
|
||||
formData.set(
|
||||
|
|
@ -138,7 +109,6 @@ describe('activity domain', () => {
|
|||
{
|
||||
...validActivityInput(municipality.id),
|
||||
title: fixtures.value('Atividade com demandas'),
|
||||
origin: 'dado',
|
||||
},
|
||||
[
|
||||
{ title: fixtures.value('Panfletos'), kind: 'material' },
|
||||
|
|
@ -359,12 +329,12 @@ describe('activity domain', () => {
|
|||
data: stub<ActivityCreateData>({
|
||||
...validActivityInput(municipality.id),
|
||||
title: fixtures.value('Atividade sem data'),
|
||||
startAt: null,
|
||||
startAt: undefined,
|
||||
createdBy: coordinator.id,
|
||||
}),
|
||||
overrideAccess: true,
|
||||
}),
|
||||
).rejects.toThrow('Informe a data e horário de início ao planejar ou confirmar a atividade.')
|
||||
).rejects.toThrow('Informe a data e horário de início do compromisso.')
|
||||
|
||||
const start = new Date(Date.now() + 86_400_000)
|
||||
await expect(
|
||||
|
|
@ -440,8 +410,8 @@ describe('tour composer (E13)', () => {
|
|||
parseTourDraftFormData(
|
||||
tourFormData(
|
||||
[
|
||||
{ municipality: 7, kind: 'comicio', origin: 'dado' },
|
||||
{ municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
|
||||
{ municipality: 7, tags: ['Comício'] },
|
||||
{ municipality: 9, tags: ['Reunião de Apoio'] },
|
||||
],
|
||||
{ note: 'Falar com o vereador antes.' },
|
||||
),
|
||||
|
|
@ -450,8 +420,8 @@ describe('tour composer (E13)', () => {
|
|||
tourName: 'Giro Sisal 27/07',
|
||||
note: 'Falar com o vereador antes.',
|
||||
stops: [
|
||||
{ municipality: 7, kind: 'comicio', origin: 'dado' },
|
||||
{ municipality: 9, kind: 'reuniao_apoio', origin: 'pedido_broker' },
|
||||
{ municipality: 7, tags: ['Comício'] },
|
||||
{ municipality: 9, tags: ['Reunião de Apoio'] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
|
@ -459,19 +429,11 @@ describe('tour composer (E13)', () => {
|
|||
it('refuses a composition it cannot trust', () => {
|
||||
expect(() => parseTourDraftFormData(tourFormData([]))).toThrow(/ao menos uma parada/i)
|
||||
expect(() =>
|
||||
parseTourDraftFormData(tourFormData([{ municipality: 7, kind: 'visita', origin: 'dado' }])),
|
||||
).toThrow(/Paradas do giro inválidas/i)
|
||||
expect(() =>
|
||||
parseTourDraftFormData(tourFormData([{ municipality: 0, kind: 'comicio', origin: 'dado' }])),
|
||||
parseTourDraftFormData(tourFormData([{ municipality: 0, tags: ['Comício'] }])),
|
||||
).toThrow(/Paradas do giro inválidas/i)
|
||||
expect(() =>
|
||||
parseTourDraftFormData(
|
||||
tourFormData([{ municipality: 7, kind: 'comicio', origin: 'inventada' }]),
|
||||
),
|
||||
).toThrow(/Paradas do giro inválidas/i)
|
||||
expect(() =>
|
||||
parseTourDraftFormData(
|
||||
tourFormData([{ municipality: 7, kind: 'comicio', origin: 'dado' }], {
|
||||
tourFormData([{ municipality: 7, tags: ['Comício'] }], {
|
||||
tourName: 'G'.repeat(120),
|
||||
}),
|
||||
),
|
||||
|
|
@ -491,8 +453,8 @@ describe('tour composer (E13)', () => {
|
|||
tourName,
|
||||
note: 'Falar com o vereador antes.',
|
||||
stops: [
|
||||
{ municipality: anchor.id, kind: 'comicio', origin: 'dado' },
|
||||
{ municipality: satellite.id, kind: 'reuniao_apoio', origin: 'dado' },
|
||||
{ municipality: anchor.id, tags: ['Comício'] },
|
||||
{ municipality: satellite.id, tags: ['Reunião de Apoio'] },
|
||||
],
|
||||
})
|
||||
created.forEach((activity) => fixtures.own('activity', activity.id))
|
||||
|
|
@ -509,7 +471,7 @@ describe('tour composer (E13)', () => {
|
|||
).toBe(true)
|
||||
// Drafts without a date, flagged as the candidate's own agenda: that flag is
|
||||
// what makes a giro derivable without a `tour` entity.
|
||||
expect(created.every((activity) => activity.status === 'rascunho')).toBe(true)
|
||||
expect(created.every((activity) => activity.status === 'confirmado')).toBe(true)
|
||||
expect(created.every((activity) => activity.deputyPresent === true)).toBe(true)
|
||||
expect(created.every((activity) => activity.startAt === null)).toBe(true)
|
||||
})
|
||||
|
|
@ -529,8 +491,8 @@ describe('tour composer (E13)', () => {
|
|||
tourName,
|
||||
note: undefined,
|
||||
stops: [
|
||||
{ municipality: inPortfolio.id, kind: 'comicio', origin: 'dado' },
|
||||
{ municipality: outOfPortfolio.id, kind: 'reuniao_apoio', origin: 'dado' },
|
||||
{ municipality: inPortfolio.id, tags: ['Comício'] },
|
||||
{ municipality: outOfPortfolio.id, tags: ['Reunião de Apoio'] },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/fora do seu escopo|saiu do seu escopo/i)
|
||||
|
|
@ -557,8 +519,7 @@ describe('tour composer (E13)', () => {
|
|||
note: undefined,
|
||||
stops: Array.from({ length: 9 }, () => ({
|
||||
municipality: municipality.id,
|
||||
kind: 'reuniao_apoio' as const,
|
||||
origin: 'dado' as const,
|
||||
tags: ['Reunião de Apoio'] as const,
|
||||
})),
|
||||
}),
|
||||
).rejects.toThrow(/no máximo 8 paradas/i)
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ describe('loadMunicipalitySuggestions (E11)', () => {
|
|||
collection: 'activity',
|
||||
data: stub<ActivityCreateData>({
|
||||
title: fixtures.value('Caminhada Centro'),
|
||||
kind: 'caminhada',
|
||||
tags: ['Caminhada'],
|
||||
status: 'realizado',
|
||||
startAt: new Date(Date.now() - 10 * DAY_MS).toISOString(),
|
||||
municipality: municipality.id,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ const campaignFixtures = installCampaignFixtures({
|
|||
|
||||
const validActivityInput = (municipalityId: number, title: string) => ({
|
||||
title,
|
||||
kind: 'caminhada' as const,
|
||||
status: 'planejado' as const,
|
||||
tags: ['Caminhada'],
|
||||
status: 'confirmado' as const,
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipalityId,
|
||||
locality: 'Centro',
|
||||
|
|
@ -113,16 +113,25 @@ describe('searchHomeActivities (B51)', () => {
|
|||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const title = fixtures.value('Rascunho sem data')
|
||||
const activity = await createActivityRecord(payload, coordinator, {
|
||||
title,
|
||||
kind: 'caminhada',
|
||||
status: 'rascunho',
|
||||
municipality: municipality.id,
|
||||
})
|
||||
fixtures.own('activity', activity.id)
|
||||
// Intentionally omits startAt (tour draft bypass) — the hook allows this
|
||||
// via isTourDraft context, but the Payload type requires it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const activity = (await (payload.create as any)({
|
||||
collection: 'activity',
|
||||
data: {
|
||||
title,
|
||||
tags: ['Caminhada'],
|
||||
municipality: municipality.id,
|
||||
createdBy: coordinator.id,
|
||||
},
|
||||
overrideAccess: true,
|
||||
context: { isTourDraft: true },
|
||||
draft: false,
|
||||
})) as { id: number }
|
||||
fixtures.own('activity', activity.id as number)
|
||||
|
||||
const result = await searchHomeActivities(payload, coordinator, 'Rascunho')
|
||||
const hit = result.find((row) => row.id === activity.id)
|
||||
const hit = result.find((row) => row.id === (activity.id as number))
|
||||
expect(hit?.secondary).toContain('Data a definir')
|
||||
expect(hit?.secondary).toContain(municipality.name)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -66,8 +66,8 @@ describe('municipality dossier data (E16)', () => {
|
|||
|
||||
const upcomingActivity = await createActivityRecord(payload, coordinator, {
|
||||
title: fixtures.value('Caminhada dossiê'),
|
||||
kind: 'caminhada',
|
||||
status: 'planejado',
|
||||
tags: ['Caminhada'],
|
||||
status: 'confirmado',
|
||||
startAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
municipality: municipality.id,
|
||||
locality: 'Centro',
|
||||
|
|
|
|||
|
|
@ -33,9 +33,8 @@ describe('parseActivityListParams', () => {
|
|||
expect(parseActivityListParams({ tab: 'todos', status: 'inexistente' }).status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('validates kind and municipality', () => {
|
||||
expect(parseActivityListParams({ kind: 'caminhada' }).kind).toBe('caminhada')
|
||||
expect(parseActivityListParams({ kind: 'festa' }).kind).toBeUndefined()
|
||||
it('validates tag and municipality', () => {
|
||||
expect(parseActivityListParams({ tag: 'Caminhada' }).tag).toBe('Caminhada')
|
||||
expect(parseActivityListParams({ municipality: '12' }).municipality).toBe(12)
|
||||
expect(parseActivityListParams({ municipality: '0' }).municipality).toBeUndefined()
|
||||
})
|
||||
|
|
@ -48,23 +47,20 @@ describe('parseActivityListParams', () => {
|
|||
})
|
||||
|
||||
describe('buildActivityListWhere (tab → where matrix)', () => {
|
||||
it('proximos = planejado/confirmado starting from now', () => {
|
||||
it('proximos = confirmado starting from now', () => {
|
||||
const where = buildActivityListWhere({ page: 1, tab: 'proximos' }, NOW)
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
{ status: { in: ['planejado', 'confirmado'] } },
|
||||
{ status: { equals: 'confirmado' } },
|
||||
{ startAt: { greater_than_equal: NOW.toISOString() } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('realizados and rascunhos filter by their status', () => {
|
||||
it('realizados filters by its status', () => {
|
||||
expect(buildActivityListWhere({ page: 1, tab: 'realizados' }, NOW)).toEqual({
|
||||
and: [{ status: { equals: 'realizado' } }],
|
||||
})
|
||||
expect(buildActivityListWhere({ page: 1, tab: 'rascunhos' }, NOW)).toEqual({
|
||||
and: [{ status: { equals: 'rascunho' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('todos is unfiltered unless a status is chosen', () => {
|
||||
|
|
@ -74,14 +70,14 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('prepends kind and municipality filters on any tab', () => {
|
||||
it('prepends tag and municipality filters on any tab', () => {
|
||||
const where = buildActivityListWhere(
|
||||
{ page: 1, tab: 'realizados', kind: 'comicio', municipality: 3 },
|
||||
{ page: 1, tab: 'realizados', tag: 'Comício', municipality: 3 },
|
||||
NOW,
|
||||
)
|
||||
expect(where).toEqual({
|
||||
and: [
|
||||
{ kind: { equals: 'comicio' } },
|
||||
{ tags: { contains: 'Comício' } },
|
||||
{ municipality: { equals: 3 } },
|
||||
{ status: { equals: 'realizado' } },
|
||||
],
|
||||
|
|
@ -95,7 +91,7 @@ describe('buildActivityListWhere (tab → where matrix)', () => {
|
|||
{
|
||||
or: [{ title: { contains: 'Maria' } }, { 'responsible.name': { contains: 'Maria' } }],
|
||||
},
|
||||
{ status: { in: ['planejado', 'confirmado'] } },
|
||||
{ status: { equals: 'confirmado' } },
|
||||
{ startAt: { greater_than_equal: NOW.toISOString() } },
|
||||
],
|
||||
})
|
||||
|
|
@ -115,12 +111,12 @@ describe('buildActivityListSearchParams', () => {
|
|||
page: 1,
|
||||
tab: 'todos',
|
||||
q: 'comício',
|
||||
kind: 'caminhada',
|
||||
tag: 'Caminhada',
|
||||
status: 'cancelado',
|
||||
municipality: 9,
|
||||
})
|
||||
expect(params.toString()).toBe(
|
||||
'q=com%C3%ADcio&tab=todos&kind=caminhada&status=cancelado&municipality=9',
|
||||
'q=com%C3%ADcio&tab=todos&tag=Caminhada&status=cancelado&municipality=9',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -134,8 +130,8 @@ describe('hrefs and canonical URL resolution', () => {
|
|||
})
|
||||
|
||||
it('derives the filters key from the canonical serialization', () => {
|
||||
expect(buildActivityFiltersKey({ page: 1, tab: 'todos', kind: 'comicio' })).toBe(
|
||||
'tab=todos&kind=comicio',
|
||||
expect(buildActivityFiltersKey({ page: 1, tab: 'todos', tag: 'Comício' })).toBe(
|
||||
'tab=todos&tag=Com%C3%ADcio',
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ describe('list omnibox adapters (B128)', () => {
|
|||
})
|
||||
|
||||
it('activity clears filters but keeps tab', () => {
|
||||
const state = parseActivityListParams({ tab: 'todos', kind: 'comicio', status: 'planejado' })
|
||||
const state = parseActivityListParams({ tab: 'todos', tag: 'Comício', status: 'confirmado' })
|
||||
const chips = buildActivityOmniboxChips({ state, municipalityLabelsById: new Map() })
|
||||
expect(chips.length).toBeGreaterThan(0)
|
||||
|
||||
|
|
@ -185,10 +185,10 @@ describe('list omnibox adapters (B128)', () => {
|
|||
expect(cleared.kind).toBe('clear')
|
||||
if (cleared.kind === 'clear') {
|
||||
expect(cleared.state.tab).toBe('todos')
|
||||
expect(cleared.state.kind).toBeUndefined()
|
||||
expect(cleared.state.tag).toBeUndefined()
|
||||
}
|
||||
|
||||
const applied = applyActivityOmniboxSuggestion({ state, suggestionId: 'kind:comicio' })
|
||||
const applied = applyActivityOmniboxSuggestion({ state, suggestionId: 'tag:Comício' })
|
||||
expect(applied.kind).toBe('url')
|
||||
})
|
||||
|
||||
|
|
@ -206,17 +206,17 @@ describe('list omnibox adapters (B128)', () => {
|
|||
if (tabApplied.kind === 'url') expect(tabApplied.state.tab).toBe('realizados')
|
||||
|
||||
const searchApplied = applyActivityOmniboxSuggestion({
|
||||
state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
|
||||
state: parseActivityListParams({ tab: 'todos', status: 'confirmado' }),
|
||||
suggestionId: 'q:comício',
|
||||
})
|
||||
expect(searchApplied.kind).toBe('url')
|
||||
if (searchApplied.kind === 'url') {
|
||||
expect(searchApplied.state.q).toBe('comício')
|
||||
expect(searchApplied.state.status).toBe('planejado')
|
||||
expect(searchApplied.state.status).toBe('confirmado')
|
||||
}
|
||||
|
||||
const leavingTodos = applyActivityOmniboxSuggestion({
|
||||
state: parseActivityListParams({ tab: 'todos', status: 'planejado' }),
|
||||
state: parseActivityListParams({ tab: 'todos', status: 'confirmado' }),
|
||||
suggestionId: 'tab:realizados',
|
||||
})
|
||||
if (leavingTodos.kind === 'url') expect(leavingTodos.state.status).toBeUndefined()
|
||||
|
|
|
|||
Loading…
Reference in a new issue