Merge branch 'cursor/602456d7': C7 partial scale/DRY for action plans

Deliver denormalized task counters, shared territory FormData parsing, advisory locks, contact-search guard, and document remaining simplify debts.

# Conflicts:
#	docs/roadmap.md
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 00:09:50 -03:00
commit 8b4ceae0ad
17 changed files with 9434 additions and 163 deletions

View file

@ -1,7 +1,7 @@
# Escala e DRY pós-C3 (planos de ação)
Status: rascunho
Atualizado em: 2026-07-18
Status: em andamento (parcial 2026-07-19)
Atualizado em: 2026-07-19
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C7)
Responsável: —
@ -19,6 +19,25 @@ O C3 ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) entregou a
Este plano é o registro canônico desses follow-ups. Sem ele, a agenda funciona em volume baixo, mas o DRY com núcleos e o custo por plano crescem com o feed de atualizações e a lista de "Próximos".
**Entregue parcialmente (2026-07-19):** primeira fatia do C7 + cleanup do `/simplify` do mesmo dia.
- **Fase 3 (parcial):** `src/utilities/campaignTerritoryFormData.ts` (`parseCampaignTerritoryFields` / `validateCampaignTerritoryFormData`); `nucleusFormData.ts` e `actionPlanFormData.ts` passam a consumir o helper compartilhado. `actionPlanUi.ts` já consome `campaignListUrl` (C6 F2 — não era débito aberto).
- **Fase 4 (parcial):** colunas derivadas `taskTotal` / `taskDoneCount` no `beforeChange` de `ActionPlan.ts`; migration `20260719_014906_action_plan_list_perf` (backfill + índice parcial `action_plan_upcoming_start_at_idx`); lista usa contadores em `actionPlanListSelect` / `toActionPlanListViewModel` (sem hidratar `tasks`).
- **Fase 4 (parcial):** busca de contato com mínimo de 2 caracteres/dígitos — `src/lib/contactSearchQuery.ts` (client + server); `ContactCombobox.tsx` + `contactSearchActions.ts`.
- **Fase 5 (parcial):** `toggleActionPlanTaskRecord` / `appendActionPlanUpdateRecord` adquirem `acquireTextAdvisoryLocks` (`action-plan:${planId}`) e `findByID` com `select` mínimo (`tasks` / `updates`).
- **Fase 1 (parcial):** export principal renomeado para `CampaignTerritoryFields`; `ActionPlanForm.tsx` atualizado (alias `NucleusTerritoryFields` removido no simplify — sem importadores restantes).
**Débitos importantes do `/simplify` 2026-07-19 que ficaram de fora** (registrados abaixo nas fases correspondentes):
1. **Short-circuit de hooks no toggle/append** — `setCanonicalActionPlanSlug` e `createCampaignTerritoryValidationHook` ainda rodam em todo `payload.update`, mesmo quando o payload só toca `tasks` ou `updates`; amplifica custo do hot path (Fase 5).
2. **Composição territorial** — `NucleusTerritoryAndZonesFields` ainda duplica ~300 linhas de `CampaignTerritoryFields` (Fase 1).
3. **Shell assíncrono de contato** — `ContactCombobox` vs `PrimaryContactCombobox` sem `AsyncContactCombobox` compartilhado (Fase 2).
4. **Leituras por aba + typeahead de liderança** — detalhe/form ainda carregam arrays completos; prefetch de lideranças não virou search lazy (Fase 4).
5. **`taskProgress` no detalhe** — overview do plano ainda filtra `tasks` inline em vez de reutilizar o padrão da lista (Fase 4).
6. **Validadores de território duplicados** — `validateCampaignTerritoryFormData` (FormData/`FormDataBoundaryError`) vs `createCampaignTerritoryValidationHook` (Payload/`APIError`); unificar exige refactor transversal (fora do escopo deste item — ver nota em "Não escopo").
7. **Feed de updates O(n)** — append continua read-modify-write do array inteiro; collection `actionPlanUpdate` só se medir volume (Fase 5, condicional).
8. **`pg_trgm` em busca de contato** — já registrado no C8 ([escala-dry-pos-c6.md](escala-dry-pos-c6.md) Fase 2); não duplicar aqui.
## Objetivos
- Território Bahia (sem ZE) é um único componente/core; o formulário de núcleo só adiciona `TseZoneInput` + chips de ZE.
@ -40,9 +59,9 @@ Este plano é o registro canônico desses follow-ups. Sem ele, a agenda funciona
## Questões em aberto
- **Renomear `NucleusTerritoryFields` → `CampaignTerritoryFields` neste item?** **Recomendação:** sim no PR da Fase 1 (nome deixa de mentir); manter re-export temporário `NucleusTerritoryFields` se algum import externo ainda existir.
- **Patch de array via drizzle vs Local API?** **Recomendação:** começar com update Local API que envia só `{ tasks: nextTasks }` (já é o caso) + select mínimo no `findByID` (`tasks` only / `updates` only); só cair para SQL direto se o `beforeChange` continuar varrendo arrays grandes.
- **Índice `(status, start_at)` na mesma migration dos contadores?** **Recomendação:** sim — uma `pnpm migrate:create action_plan_list_perf` com colunas derivadas + índice; evita duas deploys de schema.
- **Renomear `NucleusTerritoryFields` → `CampaignTerritoryFields` neste item?** **Decisão (2026-07-19):** feito; alias deprecated removido no simplify por ausência de importadores.
- **Patch de array via drizzle vs Local API?** **Recomendação:** locks + `select` mínimo já entregues; próximo passo é short-circuit de hooks (`context.mutationKind: 'taskToggle' | 'appendUpdate'`) antes de SQL direto.
- **Índice `(status, start_at)` na mesma migration dos contadores?** **Decisão (2026-07-19):** feito — `20260719_014906_action_plan_list_perf` com índice parcial `action_plan_upcoming_start_at_idx`.
- **Typeahead de liderança reusa `ContactCombobox` ou `NativeSelect` + search?** **Recomendação:** Server Action `searchActionPlanLeadershipOptions(query)` + combobox (mesmo shell da Fase 2), `depth: 0`, label a partir do contact id/nome já resolvido.
- **Migrar timestamps de núcleos para `formatBahiaDateTimeLabel`?** **Recomendação:** fill-in separado (não bloqueia C7); o helper date-only foi removido no simplify e só volta se a UI precisar.
@ -64,38 +83,42 @@ flowchart TD
C6["C6 F2 campaignListUrl"] -.suave.-> F3
```
### Fase 1 — Território UI composto
### Fase 1 — Território UI composto _(pendente)_
- Extrair o bloco regions/cities/neighborhoods/locality/notes (+ chips) para um core compartilhado (hoje duplicado em `NucleusTerritoryFields.tsx` ≈ `NucleusTerritoryAndZonesFields.tsx`).
- **`NucleusTerritoryAndZonesFields`**: compõe o core + `TseZoneInput` + sugestões de ZE (`buildTerritorySuggestions` / `outsideZones`).
- **`ActionPlanForm`**: continua importando só o core (sem ZE).
- **Entregue:** rename `CampaignTerritoryFields` + consumo no form de plano.
### Fase 2 — Contact picker único
### Fase 2 — Contact picker único _(pendente)_
- Shell assíncrono (debounce, `requestId`, CommandDialog, loading/erro) a partir de `ContactCombobox.tsx`.
- **`PrimaryContactCombobox`**: adapta `{ current, options }` / search de inteligência de núcleo para o shell (sem segundo Dialog).
- Estado controlado (`value` + `onChange`); eliminar espelho pai/filho que o simplify apontou.
- **Entregue:** helper `contactSearchQuery` (mín. 2 chars) — não substitui o shell compartilhado.
### Fase 3 — Helpers FormData / URL
### Fase 3 — Helpers FormData / URL _(parcialmente entregue)_
- **`validateCampaignTerritoryFormData` / `parseCampaignTerritoryFields`**: extrair de `nucleusFormData.ts` e `actionPlanFormData.ts` (entityLabel `núcleo` | `plano`).
- **URL de lista:** se C6 F2 já tiver `campaignListUrl.ts`, migrar `actionPlanUi.ts` para consumi-lo; senão, extrair o mínimo compartilhado **no módulo do C6** (não criar `actionPlanListUrl.ts` paralelo) ou adiar esta fatia até C6 F2.
- **`validateCampaignTerritoryFormData` / `parseCampaignTerritoryFields`**: ✅ extraído para `campaignTerritoryFormData.ts`; `nucleusFormData.ts` e `actionPlanFormData.ts` consomem.
- **URL de lista:** ✅ `actionPlanUi.ts` já consome `campaignListUrl.ts` (C6 F2).
- Opcional: `actionPlanDetailTabUi` parametrizado com o helper genérico de tabs do detalhe de núcleo (só se o PR já tocar URL).
### Fase 4 — Leituras
### Fase 4 — Leituras _(parcialmente entregue)_
- **`[slug]/page.tsx`**: selects por aba (overview sem arrays; tarefas sem `updates`; atualizações com `depth: 0` + batch de autores) — espelhar o padrão de loaders por aba do detalhe de núcleo.
- **Lista:** `taskDoneCount` / `taskTotal` (hook) + `depth: 0` para `responsible` com batch de nomes; dropar `tasks: { done: true }` de `actionPlanListSelect`.
- **`getActionPlanLeadershipOptions` → search:** lazy no open do campo; `limit` baixo; sem `depth: 1` em massa.
- **`ContactCombobox`:** não fetch com query vazia (mín. 2 chars ou dígitos), alinhado ao custo de N comboboxes no form de tarefas.
- **`[slug]/page.tsx`**: selects por aba (overview sem arrays; tarefas sem `updates`; atualizações com `depth: 0` + batch de autores) — espelhar o padrão de loaders por aba do detalhe de núcleo. _(pendente)_
- **Lista:** ✅ `taskDoneCount` / `taskTotal` + `actionPlanListSelect` sem `tasks`.
- **`getActionPlanLeadershipOptions` → search:** lazy no open do campo; `limit` baixo; sem `depth: 1` em massa. _(pendente)_
- **`ContactCombobox`:** ✅ não fetch com query < 2 chars (`contactSearchQuery.ts`).
- **Detalhe:** expor `taskProgress` no view model do detalhe e substituir filtro inline de `tasks` na overview. _(pendente — débito do simplify)_
### Fase 5 — Escritas + índice
### Fase 5 — Escritas + índice _(parcialmente entregue)_
- **`toggleActionPlanTaskRecord` / `appendActionPlanUpdateRecord`:** `findByID` com `select` só do array tocado; evitar rewalk desnecessário; avaliar patch de linha se ainda houver amplificação.
- **Migration** `action_plan_list_perf`: índice `(status, start_at)` (+ colunas de contador da Fase 4 se ainda não migradas).
- Só então considerar collection `actionPlanUpdate` append-only (fora do MVP C3; só com evidência de corrida/tamanho).
- **`toggleActionPlanTaskRecord` / `appendActionPlanUpdateRecord`:** ✅ locks advisory + `select` mínimo no `findByID`.
- **Short-circuit de hooks:** em `setCanonicalActionPlanSlug` e `validateActionPlanTerritory`, retornar cedo quando `data` só contém `tasks` ou `updates` (via `context` explícito nas actions). _(pendente — débito de performance do simplify)_
- **Migration** `action_plan_list_perf`: ✅ contadores + índice parcial `action_plan_upcoming_start_at_idx` (`20260719_014906_action_plan_list_perf`).
- **Feed O(n):** monitorar tamanho de `updates`; só então collection `actionPlanUpdate` append-only. _(condicional)_
**Migration:** Fases 1–3 sem schema. Fase 4/5: `pnpm migrate:create action_plan_list_perf` (contadores + índice). Sem Consent novo.
**Migration:** Fases 1–3 sem schema. Migration `20260719_014906_action_plan_list_perf` ✅ entregue (contadores + índice parcial). Sem Consent novo.
## Dependências
@ -111,6 +134,8 @@ flowchart TD
- Notificações de agenda — D2 / [notifications.md](notifications.md).
- Calendário visual / mapa de eventos / recorrência — fora do horizonte pré-16/08.
- Migrar todos os `Intl.DateTimeFormat` de núcleos para Bahia — fill-in.
- Unificar `validateCampaignTerritoryFormData` com `createCampaignTerritoryValidationHook` num core único — tipos de erro diferentes (FormData vs Payload); refactor transversal; reavaliar após C7 Fases 1–2.
- Índice `pg_trgm` em `contact` — C8 / [escala-dry-pos-c6.md](escala-dry-pos-c6.md).
## Referências
@ -118,6 +143,9 @@ flowchart TD
- [eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md) — C3 v1 entregue
- [escala-dry-pos-c2.md](escala-dry-pos-c2.md) — precedente C6 + `campaignListUrl`
- Review `/simplify` 2026-07-18 (quality / reuse / performance) — origem das fases
- Review `/simplify` 2026-07-19 — débitos registrados nas fases pendentes acima
- `src/lib/contactSearchQuery.ts` — guard compartilhado client/server (entregue)
- `src/migrations/20260719_014906_action_plan_list_perf.ts` — contadores + índice parcial (entregue)
- `src/components/campaign/NucleusTerritoryFields.tsx` / `NucleusTerritoryAndZonesFields.tsx`
- `src/components/campaign/ContactCombobox.tsx` / `PrimaryContactCombobox.tsx`
- `src/utilities/actionPlanFormData.ts` / `nucleusFormData.ts` / `actionPlanUi.ts`

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 registrado — escala/DRY pós-C3; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; C8 registrado — escala/DRY pós-C6 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -74,6 +74,7 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **C2 Cadastro nominal de apoiadores** — collection `supporter` (join `Contact`↔campanha, núcleo opcional), migration `20260718_222656_add_supporter` com `UNIQUE NULLS NOT DISTINCT (contact_id, nucleus_id)`, consent por chaves estáveis `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico (`getConsentByKey` / `requireConsentByKey`, falha fechada), actions (create / intenção de voto / import CSV só `geral` / `removeSupporterData`), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard de import, kit mínimo `wa.me`). Telefone obrigatório no v1; `lideranca` sem acesso à área. Engenharia pronta e mesclada — produção com dados reais espera deploy (build Vercel aplica a migration) + Consent keys + aprovação jurídica (Onda 0). [Plano](plans/cadastro-nominal-apoiadores.md).
- **C3 Eventos / agenda de mobilização** — collection `actionPlan` + vertical `/campanha/planos` (lista com tabs Próximos/Todos/Realizados/Rascunhos, detalhe com tabs Visão geral/Tarefas/Atualizações, forms novo/editar), blocos "Próximos eventos" no overview de núcleos e no dashboard; `startAt` opcional só em rascunho (obrigatório ao sair de `rascunho`); access por `coordinators`/`leadership` (escopo `lideranca` só toggle `tasks.done` + append `updates`); transações via `withPayloadTransaction`; migration `20260718_222832_add_action_plan`. Sem `Consent` (dado interno de staff). [Plano](plans/eventos-agenda-mobilizacao.md).
- **C7 Escala e DRY pós-C3 — parcial (2026-07-19)** — primeira fatia: `campaignTerritoryFormData`, contadores `taskTotal`/`taskDoneCount` + migration `20260719_014906_action_plan_list_perf`, locks advisory + `select` mínimo em toggle/append, `contactSearchQuery` (mín. 2 chars), rename `CampaignTerritoryFields`. Débitos do `/simplify` 2026-07-19 (composição territorial, shell de contato, leituras por aba, short-circuit de hooks, `taskProgress` no detalhe) registrados no plano — Fases 1–2 e restante de 4–5 pendentes. [Plano](plans/escala-dry-pos-c3.md).
### Referências de design (UX Pilot, 2026-07-18)
@ -134,7 +135,7 @@ flowchart TD
C2["C2 Cadastro nominal de apoiadores ✓"]
C6["C6 Escala e DRY pós-C2<br/>(import/listas/forms) ✓"]
C3["C3 Eventos / agenda de mobilização ✓"]
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras)"]
C7["C7 Escala e DRY pós-C3<br/>(parcial: contadores/locks/FormData)"]
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY)"]
C4["C4 Demandas"]
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
@ -199,7 +200,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–5 + simplify)** | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3, C7, C8 |
| 9 | A4 Baseline no produto + insight Gap vs 2022 **(implementado e mesclado em `main` 2026-07-18 — Fases 2–4 + simplify)** | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6, C7, C8 |
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) **(implementado e mesclado em `main` 2026-07-18)** | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6, C7, C8 |
| 11 | C7 Escala e DRY pós-C3 (território/contato compostos, leituras por aba, RMW/índice) | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 |
| 11 | C7 Escala e DRY pós-C3 _(parcial 2026-07-19: FormData território, contadores lista, locks, `contactSearchQuery`; pendente: composição territorial, shell de contato, leituras por aba, short-circuit de hooks)_ | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8 |
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7 |
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
@ -230,7 +231,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): fill-ins (vi
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 (leituras/RMW/índice — só se a agenda permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 3–4 (DRY de helpers/forms) e **A7 F1** (agregar federal no detalhe) se A5/B3 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C7 fases 4–5 restantes (leituras por aba / short-circuit de hooks / feed O(n) — só se a agenda permanecer pequena; contadores + locks já entregues), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 3–4 (DRY de helpers/forms) e **A7 F1** (agregar federal no detalhe) se A5/B3 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
### Itens consolidados/removidos nesta revisão (2026-07-17)

View file

@ -3,6 +3,10 @@
import config from '@payload-config'
import { getPayload } from 'payload'
import {
isContactSearchQueryReady,
normalizeContactSearchQuery,
} from '@/lib/contactSearchQuery'
import { getCampaignUser } from '@/utilities/campaignAuth'
export type ActionPlanContactOption = {
@ -19,16 +23,16 @@ export const searchActionPlanContactOptions = async (
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
if (!user) throw new Error('Autenticação necessária.')
const trimmed = typeof query === 'string' ? query.trim().slice(0, 120) : ''
const digits = trimmed.replace(/\D/g, '')
const normalizedQuery = typeof query === 'string' ? query : ''
if (!isContactSearchQueryReady(normalizedQuery)) return []
const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery)
const result = await payload.find({
collection: 'contact',
where: trimmed
? {
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
}
: {},
where: {
or: [{ name: { contains: trimmed } }, { phone: { contains: digits || trimmed } }],
},
depth: 0,
limit: CONTACT_OPTION_LIMIT,
page: 1,

View file

@ -11,6 +11,7 @@ import {
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
const MAX_ACTION_PLAN_UPDATE_BODY_LENGTH = 4000
@ -91,10 +92,12 @@ export const toggleActionPlanTaskRecord = async (
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`action-plan:${planId}`])
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
depth: 0,
select: { tasks: true },
user: currentActor,
overrideAccess: false,
req,
@ -135,10 +138,12 @@ export const appendActionPlanUpdateRecord = async (
payload,
async ({ req }) => {
const currentActor = await reloadCampaignActor(payload, actor, req)
await acquireTextAdvisoryLocks(payload, req, [`action-plan:${planId}`])
const plan = await payload.findByID({
collection: 'actionPlan',
id: planId,
depth: 0,
select: { updates: true },
user: currentActor,
overrideAccess: false,
req,

View file

@ -164,9 +164,11 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o
if (Array.isArray(data.tasks)) {
const previousTasks = Array.isArray(originalDoc?.tasks) ? originalDoc.tasks : []
let taskDoneCount = 0
data.tasks = data.tasks.map((task: Record<string, unknown>, index: number) => {
const previous = previousTasks[index] as Record<string, unknown> | undefined
const done = Boolean(task.done)
if (done) taskDoneCount += 1
const previousDone = Boolean(previous?.done)
let doneAt = task.doneAt ?? previous?.doneAt ?? null
if (done && !previousDone) {
@ -176,6 +178,8 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o
}
return { ...task, done, doneAt }
})
data.taskTotal = data.tasks.length
data.taskDoneCount = taskDoneCount
}
if (Array.isArray(data.updates)) {
@ -472,6 +476,34 @@ export const ActionPlan: CollectionConfig = {
},
],
},
{
name: 'taskTotal',
type: 'number',
label: 'Total de tarefas',
defaultValue: 0,
min: 0,
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'taskDoneCount',
type: 'number',
label: 'Tarefas concluídas',
defaultValue: 0,
min: 0,
admin: {
readOnly: true,
},
access: {
create: canSetActionPlanSystemField,
update: canSetActionPlanSystemField,
},
},
{
name: 'updates',
type: 'array',

View file

@ -5,7 +5,7 @@ import Link from 'next/link'
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -238,7 +238,7 @@ export const ActionPlanFormFields = ({
</CardHeader>
<CardContent>
<FieldGroup>
<NucleusTerritoryFields values={plan} fieldErrors={fieldErrors} />
<CampaignTerritoryFields values={plan} fieldErrors={fieldErrors} />
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Localidade</FieldLabel>

View file

@ -13,6 +13,7 @@ import {
CommandList,
} from '@/components/ui/Command'
import { Spinner } from '@/components/ui/Spinner'
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
export type ContactComboboxOption = {
id: number
@ -50,12 +51,18 @@ export const ContactCombobox = ({
useEffect(() => {
if (!open) return
if (!isContactSearchQueryReady(query)) {
setOptions([])
setLoading(false)
setFailed(false)
return
}
const currentRequestId = ++requestId.current
const timeout = window.setTimeout(() => {
setLoading(true)
setFailed(false)
void search(query)
void search(query.trim())
.then((nextOptions) => {
if (requestId.current !== currentRequestId) return
setOptions(nextOptions)
@ -150,7 +157,9 @@ export const ContactCombobox = ({
</CommandGroup>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Nenhum contato encontrado.
{!isContactSearchQueryReady(query)
? 'Digite ao menos dois caracteres para buscar.'
: 'Nenhum contato encontrado.'}
</p>
)}
</>

View file

@ -54,7 +54,7 @@ const ChipList = ({
* `NucleusTerritoryAndZonesFields` for the nucleus variant that also manages
* TSE zones.
*/
export const NucleusTerritoryFields = ({
export const CampaignTerritoryFields = ({
values,
fieldErrors = {},
}: {

View file

@ -0,0 +1,14 @@
export const CONTACT_SEARCH_MIN_LENGTH = 2
export const normalizeContactSearchQuery = (raw: string) => {
const trimmed = raw.trim().slice(0, 120)
const digits = trimmed.replace(/\D/g, '')
return { trimmed, digits }
}
export const isContactSearchQueryReady = (raw: string) => {
const { trimmed, digits } = normalizeContactSearchQuery(raw)
return (
trimmed.length >= CONTACT_SEARCH_MIN_LENGTH || digits.length >= CONTACT_SEARCH_MIN_LENGTH
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "action_plan" ADD COLUMN "task_total" numeric DEFAULT 0;
ALTER TABLE "action_plan" ADD COLUMN "task_done_count" numeric DEFAULT 0;
UPDATE "action_plan" AS plan
SET
"task_total" = COALESCE(counts.task_total, 0),
"task_done_count" = COALESCE(counts.task_done_count, 0)
FROM (
SELECT
"_parent_id",
COUNT(*)::numeric AS task_total,
COUNT(*) FILTER (WHERE "done" = true)::numeric AS task_done_count
FROM "action_plan_tasks"
GROUP BY "_parent_id"
) AS counts
WHERE counts."_parent_id" = plan."id";
CREATE INDEX "action_plan_upcoming_start_at_idx"
ON "action_plan" USING btree ("start_at")
WHERE "status" IN ('planejado', 'confirmado');`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "action_plan_upcoming_start_at_idx";
ALTER TABLE "action_plan" DROP COLUMN "task_total";
ALTER TABLE "action_plan" DROP COLUMN "task_done_count";`)
}

View file

@ -9,6 +9,7 @@ import * as migration_20260718_195854_add_election_results from './20260718_1958
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch';
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf';
export const migrations = [
{
@ -64,6 +65,11 @@ export const migrations = [
{
up: migration_20260719_011015_add_supporter_import_batch.up,
down: migration_20260719_011015_add_supporter_import_batch.down,
name: '20260719_011015_add_supporter_import_batch'
name: '20260719_011015_add_supporter_import_batch',
},
{
up: migration_20260719_014906_action_plan_list_perf.up,
down: migration_20260719_014906_action_plan_list_perf.down,
name: '20260719_014906_action_plan_list_perf'
},
];

View file

@ -561,6 +561,8 @@ export interface ActionPlan {
id?: string | null;
}[]
| null;
taskTotal?: number | null;
taskDoneCount?: number | null;
updates?:
| {
body: string;
@ -1273,6 +1275,8 @@ export interface ActionPlanSelect<T extends boolean = true> {
doneAt?: T;
id?: T;
};
taskTotal?: T;
taskDoneCount?: T;
updates?:
| T
| {

View file

@ -1,11 +1,5 @@
import 'server-only'
import {
bahiaIdentityTerritories,
type BahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
actionPlanKinds,
actionPlanStatuses,
@ -17,11 +11,14 @@ import {
FormDataBoundaryError,
nullableRelationshipFormValue,
optionalFormText,
repeatedFormTexts,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { parseBahiaDateTimeInput } from '@/utilities/campaignTime'
import {
parseCampaignTerritoryFields,
validateCampaignTerritoryFormData,
} from '@/utilities/campaignTerritoryFormData'
type ParsedActionPlanTask = {
title: string
@ -83,12 +80,7 @@ const parseTasksFormData = (formData: FormData): ParsedActionPlanTask[] => {
}
const parseSharedActionPlanFormData = (formData: FormData) => {
const cities = repeatedFormTexts(formData, 'cities')
const regions =
cities.length > 0
? territoriesForCities(cities)
: (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
const neighborhoods = cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : []
const territory = parseCampaignTerritoryFields(formData)
const responsible = nullableRelationshipFormValue(formData, 'responsible')
const leadership = nullableRelationshipFormValue(formData, 'leadership')
@ -100,60 +92,16 @@ const parseSharedActionPlanFormData = (formData: FormData) => {
startAt: parseDateTimeFormField(formData, 'startAt'),
endAt: parseDateTimeFormField(formData, 'endAt'),
deadline: parseDateTimeFormField(formData, 'deadline'),
regions,
cities,
neighborhoods,
locality: optionalFormText(formData, 'locality'),
territoryNotes: optionalFormText(formData, 'territoryNotes'),
...territory,
responsible,
leadership,
tasks: parseTasksFormData(formData),
}
}
const validateTerritoryFormData = (
territory: {
regions: string[]
cities: string[]
neighborhoods: string[]
locality?: string
},
{ requireGeography }: { requireGeography: boolean },
) => {
for (const region of territory.regions) {
if (!bahiaIdentityTerritories.includes(region as never)) {
throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
}
}
for (const city of territory.cities) {
if (!isBahiaMunicipality(city)) {
throw new FormDataBoundaryError('cities', 'Município inválido.')
}
}
if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
throw new FormDataBoundaryError(
'neighborhoods',
territory.cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
)
}
if (
requireGeography &&
territory.regions.length === 0 &&
territory.cities.length === 0 &&
!territory.locality
) {
throw new FormDataBoundaryError(
'cities',
'Informe o território de identidade, município ou localidade do plano.',
)
}
}
export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCreateInput => {
const shared = parseSharedActionPlanFormData(formData)
validateTerritoryFormData(shared, { requireGeography: false })
validateCampaignTerritoryFormData(shared, { entityLabel: 'plano', requireGeography: false })
const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
return {
@ -167,7 +115,7 @@ export const parseActionPlanCreateFormData = (formData: FormData): ActionPlanCre
export const parseActionPlanUpdateFormData = (formData: FormData): ActionPlanUpdateInput => {
const shared = parseSharedActionPlanFormData(formData)
validateTerritoryFormData(shared, { requireGeography: true })
validateCampaignTerritoryFormData(shared, { entityLabel: 'plano', requireGeography: true })
const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
// Checkboxes only submit when checked, so a hidden marker distinguishes
// "coordinators section rendered but all unchecked" from "not editable" (coordenador view).

View file

@ -36,7 +36,8 @@ export const actionPlanListSelect = {
neighborhoods: true,
locality: true,
responsible: true,
tasks: { done: true },
taskDoneCount: true,
taskTotal: true,
} as const
export type ActionPlanListViewModel = {
@ -60,8 +61,6 @@ const relationshipName = (
export const toActionPlanListViewModel = (plan: ActionPlan): ActionPlanListViewModel => {
const cities = asStringArray(plan.cities)
const tasks = plan.tasks ?? []
return {
id: plan.id,
title: plan.title,
@ -74,8 +73,8 @@ export const toActionPlanListViewModel = (plan: ActionPlan): ActionPlanListViewM
city: cities[0] ?? null,
responsibleName: relationshipName(plan.responsible),
taskProgress: {
done: tasks.filter((task) => Boolean(task.done)).length,
total: tasks.length,
done: plan.taskDoneCount ?? 0,
total: plan.taskTotal ?? 0,
},
}
}

View file

@ -0,0 +1,73 @@
import 'server-only'
import {
bahiaIdentityTerritories,
type BahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import { FormDataBoundaryError, optionalFormText, repeatedFormTexts } from '@/lib/formData'
type CampaignTerritoryEntity = 'núcleo' | 'plano'
export type CampaignTerritoryFormData = {
regions: BahiaIdentityTerritory[]
cities: string[]
neighborhoods: string[]
locality: string | undefined
territoryNotes: string | undefined
}
export const parseCampaignTerritoryFields = (formData: FormData): CampaignTerritoryFormData => {
const cities = repeatedFormTexts(formData, 'cities')
const regions =
cities.length > 0
? territoriesForCities(cities)
: (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
return {
regions,
cities,
neighborhoods: cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : [],
locality: optionalFormText(formData, 'locality'),
territoryNotes: optionalFormText(formData, 'territoryNotes'),
}
}
export const validateCampaignTerritoryFormData = (
territory: CampaignTerritoryFormData,
{
entityLabel,
requireGeography,
}: { entityLabel: CampaignTerritoryEntity; requireGeography: boolean },
): void => {
for (const region of territory.regions) {
if (!bahiaIdentityTerritories.includes(region)) {
throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
}
}
for (const city of territory.cities) {
if (!isBahiaMunicipality(city)) {
throw new FormDataBoundaryError('cities', 'Município inválido.')
}
}
if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
throw new FormDataBoundaryError(
'neighborhoods',
territory.cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
)
}
if (
requireGeography &&
territory.regions.length === 0 &&
territory.cities.length === 0 &&
!territory.locality
) {
throw new FormDataBoundaryError(
'cities',
`Informe o território de identidade, município ou localidade do ${entityLabel}.`,
)
}
}

View file

@ -1,11 +1,5 @@
import 'server-only'
import {
bahiaIdentityTerritories,
type BahiaIdentityTerritory,
isBahiaMunicipality,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
organizationKinds,
sectorKinds,
@ -14,12 +8,14 @@ import {
} from '@/lib/schemas/nucleus'
import {
checkboxFormValue,
FormDataBoundaryError,
optionalFormText,
repeatedFormTexts,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
parseCampaignTerritoryFields,
validateCampaignTerritoryFormData,
} from '@/utilities/campaignTerritoryFormData'
import { parseTseZoneNumbers } from '@/utilities/tseZone'
const parseZoneNumbers = (value: string | undefined): Array<{ zoneNumber: number }> | undefined =>
@ -28,21 +24,12 @@ const parseZoneNumbers = (value: string | undefined): Array<{ zoneNumber: number
const parseSharedNucleusFormData = (formData: FormData) => {
const organizationKind = optionalFormText(formData, 'organizationKind')
const sectorKind = optionalFormText(formData, 'sectorKind')
const cities = repeatedFormTexts(formData, 'cities')
const regions =
cities.length > 0
? territoriesForCities(cities)
: (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
const neighborhoods = cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : []
const territory = parseCampaignTerritoryFields(formData)
const partnerName = optionalFormText(formData, 'partnerName')
return {
name: optionalFormText(formData, 'name') ?? '',
regions,
cities,
neighborhoods,
locality: optionalFormText(formData, 'locality'),
territoryNotes: optionalFormText(formData, 'territoryNotes'),
...territory,
organizationKind: organizationKind as (typeof organizationKinds)[number],
organizationLabel: optionalFormText(formData, 'organizationLabel'),
sectorKind: sectorKind ? (sectorKind as (typeof sectorKinds)[number]) : undefined,
@ -58,49 +45,9 @@ const parseSharedNucleusFormData = (formData: FormData) => {
}
}
const validateTerritoryFormData = (
territory: {
regions: string[]
cities: string[]
neighborhoods: string[]
locality?: string
},
{ requireGeography }: { requireGeography: boolean },
) => {
for (const region of territory.regions) {
if (!bahiaIdentityTerritories.includes(region as never)) {
throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
}
}
for (const city of territory.cities) {
if (!isBahiaMunicipality(city)) {
throw new FormDataBoundaryError('cities', 'Município inválido.')
}
}
if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
throw new FormDataBoundaryError(
'neighborhoods',
territory.cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
)
}
if (
requireGeography &&
territory.regions.length === 0 &&
territory.cities.length === 0 &&
!territory.locality
) {
throw new FormDataBoundaryError(
'cities',
'Informe o território de identidade, município ou localidade do núcleo.',
)
}
}
export const parseNucleusCreateFormData = (formData: FormData): NucleusCreateInput => {
const shared = parseSharedNucleusFormData(formData)
validateTerritoryFormData(shared, { requireGeography: false })
validateCampaignTerritoryFormData(shared, { entityLabel: 'núcleo', requireGeography: false })
const coordinatorIds = repeatedRelationshipFormValues(formData, 'coordinators')
return {
@ -111,7 +58,7 @@ export const parseNucleusCreateFormData = (formData: FormData): NucleusCreateInp
export const parseNucleusUpdateFormData = (formData: FormData): NucleusUpdateInput => {
const shared = parseSharedNucleusFormData(formData)
validateTerritoryFormData(shared, { requireGeography: true })
validateCampaignTerritoryFormData(shared, { entityLabel: 'núcleo', requireGeography: true })
const partnerName = optionalFormText(formData, 'partnerName')
return {