mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
commit
8b4ceae0ad
17 changed files with 9434 additions and 163 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const ChipList = ({
|
|||
* `NucleusTerritoryAndZonesFields` for the nucleus variant that also manages
|
||||
* TSE zones.
|
||||
*/
|
||||
export const NucleusTerritoryFields = ({
|
||||
export const CampaignTerritoryFields = ({
|
||||
values,
|
||||
fieldErrors = {},
|
||||
}: {
|
||||
|
|
|
|||
14
src/lib/contactSearchQuery.ts
Normal file
14
src/lib/contactSearchQuery.ts
Normal 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
|
||||
)
|
||||
}
|
||||
9171
src/migrations/20260719_014906_action_plan_list_perf.json
Normal file
9171
src/migrations/20260719_014906_action_plan_list_perf.json
Normal file
File diff suppressed because it is too large
Load diff
30
src/migrations/20260719_014906_action_plan_list_perf.ts
Normal file
30
src/migrations/20260719_014906_action_plan_list_perf.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/utilities/campaignTerritoryFormData.ts
Normal file
73
src/utilities/campaignTerritoryFormData.ts
Normal 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}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue