mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Add missing C7 modules and register C11 simplify follow-ups.
This commit is contained in:
parent
15c756bb10
commit
9efcf2832f
14 changed files with 865 additions and 43 deletions
|
|
@ -15,7 +15,7 @@ alwaysApply: false
|
|||
- **C3 Planos de Ação implementado (2026-07-18):** 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; access por `coordinators`/`leadership`; migration `20260718_222832_add_action_plan`.
|
||||
- **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md).
|
||||
- **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável.
|
||||
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks.
|
||||
- **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)).
|
||||
- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md).
|
||||
- Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos.
|
||||
- Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md).
|
||||
|
|
@ -45,6 +45,7 @@ alwaysApply: false
|
|||
- **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena.
|
||||
- **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md).
|
||||
- **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md).
|
||||
- **Em 2026-07-19, C11 Escala e DRY pós-C7 registrado** (após `/simplify` do C7): feed O(n) condicional, loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths mutação/form, utilities de busca/label. Plano: [`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md).
|
||||
|
||||
## Decisões
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ flowchart TD
|
|||
- [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
|
||||
- Review `/simplify` 2026-07-19 (pós-entrega C7) — débitos maiores → **C11** / [escala-dry-pos-c7.md](escala-dry-pos-c7.md)
|
||||
- `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/AsyncSearchCombobox.tsx` — shell assíncrono compartilhado (entregue)
|
||||
|
|
@ -138,3 +139,7 @@ flowchart TD
|
|||
- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW
|
||||
- `src/migrations/20260718_222832_add_action_plan.ts` — índices atuais `status` / `start_at` separados
|
||||
- AGENTS.md — `overrideAccess: false`, transações, naming, território Bahia
|
||||
|
||||
## Simplify (2026-07-19)
|
||||
|
||||
Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados no item **C11** do roadmap — [escala-dry-pos-c7.md](escala-dry-pos-c7.md).
|
||||
|
|
|
|||
137
docs/plans/escala-dry-pos-c7.md
Normal file
137
docs/plans/escala-dry-pos-c7.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Escala e DRY pós-C7 (planos de ação / agenda)
|
||||
|
||||
Status: registrado no roadmap (fases pendentes)
|
||||
Atualizado em: 2026-07-19
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C11)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O C7 ([escala-dry-pos-c3.md](escala-dry-pos-c3.md)) entregou composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), shell `AsyncSearchCombobox` (+ `ContactCombobox` / `PrimaryContactCombobox` / `LeadershipCombobox`), leituras por aba no detalhe (`getActionPlanDetailSelect` + `taskProgress` + batch de autores na aba Atualizações), typeahead de liderança (`searchActionPlanLeadershipOptions`), short-circuit de hooks em toggle/append (`context.mutationKind`) e cleanup do `/simplify` (depth overview/tasks, `searchRef`, `fieldError`, remoção de exports mortos).
|
||||
|
||||
Duas passagens `/simplify` (pré- e pós-rebase com `main`/C8–C9) marcaram como **importantes e maiores que cleanup** os follow-ups abaixo. Sem registro, planos com feed longo degradam leitura/append; loaders do detalhe ficam fragmentados em relação ao padrão de núcleos; e hot paths de edição continuam O(n) no array embutido.
|
||||
|
||||
1. **Feed de atualizações O(n) (leitura + escrita).** A aba Atualizações carrega o array `updates` inteiro (corpos até 4 000 chars) sem limite/cursor; `appendActionPlanUpdateRecord` faz read-modify-write do array completo. Escala linear com o tamanho do feed — principal cliff de performance da vertical de agenda.
|
||||
2. **Toggle de tarefa O(n).** `toggleActionPlanTaskRecord` ainda faz `findByID` + `update` com o array `tasks` inteiro (locks + short-circuit de hooks ajudam, mas o RMW permanece).
|
||||
3. **Selects do detalhe over-fetch em tasks/updates.** `actionPlanDetailContextSelect` inclui `coordinators`, `responsible`, `createdBy`, `description`, etc. nas abas que só precisam de header + conteúdo da aba — hidratação extra em `depth: 1`.
|
||||
4. **Loader do detalhe fragmentado.** `planos/[slug]/page.tsx` encadeia `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`; núcleos já usam orquestrador único (`loadNucleusDetailPageData` / `loadNucleusActiveTabPageData`).
|
||||
5. **Batch de nomes de autor duplicado.** `loadActionPlanUpdateAuthorNames` em `actionPlanDetailPageData.ts` repete o padrão de `nucleusUpdatePageData` / overview de núcleos (`campaignUser` `id in` → `Map<id, name>`).
|
||||
6. **Form de edição hidrata todas as tarefas.** `getActionPlanEditPageData` usa `actionPlanFormSelect` + `depth: 1` sempre — latência cresce com contagem de tarefas/responsáveis.
|
||||
7. **`serializeTasks` no hidden input a cada keystroke.** `ActionPlanTaskFields` chama `JSON.stringify` do array completo em todo render enquanto o usuário edita tarefas.
|
||||
8. **Typeahead sem índice dedicado na busca de liderança.** `searchActionPlanLeadershipOptions` usa `contains` em `contact.*` aninhado; `pg_trgm` em `contact` já existe (C8) mas a query de liderança ainda não reutiliza o padrão otimizado de apoiadores.
|
||||
9. **Lista de coordenadores sem paginação no form.** `getEligibleNucleusCoordinatorOptions` com `pagination: false` em novo/editar — O(staff) por page view (aceitável no MVP, débito se o time crescer).
|
||||
10. **`mutationKind` sem tipo compartilhado.** Magic strings `'taskToggle' | 'appendUpdate'` em actions e `ActionPlan.ts` — risco de drift.
|
||||
11. **DRY menor:** `searchContactComboboxOptions` ainda inline em `contactSearchActions.ts` (liderança já em `utilities/`); label de liderança duplicada entre `actionPlanLeadershipOptions` e `toActionPlanFormViewModel`; `firstValue` local em `actionPlanDetailTabUi.ts` (já existe em `campaignListUrl.ts`); loader combinado da lista `/campanha/planos` (precedente: `loadSupportersPageData` no C9).
|
||||
|
||||
**Explicitamente fora (revisores pediram skip no simplify / já coberto em outro item):**
|
||||
|
||||
- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md) Fase 3 (inclui `ActionPlanForm`, `NucleusTerritoryFields`, `NucleusTerritoryAndZonesFields`).
|
||||
- **`pg_trgm` em `contact` (migration)** — C8 entregue; este plano só cobre adoção do padrão de busca na query de liderança se necessário.
|
||||
- **Unificar `validateCampaignTerritoryFormData` ↔ `createCampaignTerritoryValidationHook`** — tipos de erro diferentes; permanece em [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo.
|
||||
- **Renomear `NucleusTerritoryFields.tsx` → `CampaignTerritoryFields.tsx`** — fricção de descoberta; fill-in, não bloqueia escala.
|
||||
- **View-model dedup form ↔ detalhe** — refactor grande; só se surgir terceiro consumidor.
|
||||
- **Testes E2E da vertical** — fora do escopo deste item de escala/DRY.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Agenda (`/campanha/planos`) permanece responsiva com dezenas de atualizações e tarefas por plano — medir antes de migration; feed append-only só se o volume justificar.
|
||||
- Detalhe e edição seguem o mesmo padrão de loaders que núcleos (orquestrador + tab data).
|
||||
- Hot paths de mutação (toggle tarefa, append update) minimizam bytes lidos/escritos sem quebrar locks transacionais nem RBAC.
|
||||
- Guardrails: `overrideAccess: false` com `user`; sem novo `Consent`; migrations só na Fase 1 condicional (`actionPlanUpdate`).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um item C11, cinco fases ordenadas.** Mesmo racional de A7/B5/C8–C10: um ID de roadmap, PRs por fase. Ordem: feed condicional (maior cliff) → loaders/selects (I/O) → mutações/form hot path → utilities DRY → fill-ins.
|
||||
- **Dependência dura de C7** mergeado em `main` (código de abas, combobox, hooks). Não reabre Fases 1–5 do C7.
|
||||
- **Fase 1 (feed) é condicional.** Só migration + collection `actionPlanUpdate` após evidência (ex.: planos com >N updates ou latência medida em produção/staging). Até lá: documentar limite soft e monitorar.
|
||||
- **Fase 1 condicional respeita congelamento ~20/09** — se entrar perto da reta final, adiar para pós-eleição; Fases 2–4 não exigem migration.
|
||||
- **Cortável** se a agenda permanecer pequena (poucos planos, feeds curtos). Fase 1 é a mais cortável (e a única com migration). Fases 2–3 são as mais valiosas se houver uso real da agenda antes de 16/08.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadActionPlanDetailPageData`, `loadCampaignUserNamesById`, `ACTION_PLAN_MUTATION_KIND`), strings visíveis em pt-BR inalteradas.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Fase 1: threshold para extrair `actionPlanUpdate`?** **Recomendação:** 50 updates por plano ou p95 de append >500 ms em staging; até lá manter array embutido e só paginar leitura (limit + cursor) sem nova collection se bastar.
|
||||
- **Fase 1: paginação só na leitura vs collection append-only?** **Recomendação:** primeiro limitar/paginar leitura na aba Atualizações (sem migration); collection só se append RMW virar gargalo medido.
|
||||
- **Fase 3: SQL direto no toggle de tarefa?** **Recomendação:** não na primeira iteração; manter Local API + locks; reavaliar com drizzle na txn só se profiling mostrar necessidade (mesmo trade-off do C8).
|
||||
- **Fase 2: `loadActionPlanDetailPageData` retorna `{ context, view }` ou só `view`?** **Recomendação:** `{ context, view }` espelhando núcleos; página deixa de encadear dois awaits públicos.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
C7["C7 Fases 1–5 ✓"] --> F2
|
||||
F2["Fase 2 — Loaders + selects por aba<br/>+ loadCampaignUserNamesById"]
|
||||
F2 --> F3["Fase 3 — Hot path form/mutação<br/>(serializeTasks, mutationKind)"]
|
||||
F2 --> F4["Fase 4 — Utilities DRY<br/>(contact search, labels, firstValue)"]
|
||||
F1["Fase 1 — Feed escala<br/>(paginação / actionPlanUpdate)"]
|
||||
C7 --> F1
|
||||
F1 -.condicional volume.-> F5["Fase 5 — Loader lista planos"]
|
||||
F3 -.opcional.-> F5
|
||||
```
|
||||
|
||||
### Fase 1 — Escala do feed de atualizações _(condicional)_
|
||||
|
||||
- **1a (sem migration):** limitar leitura na aba Atualizações (`limit` + ordenação por `createdAt` desc); UI “carregar mais”; `getActionPlanDetailSelect` pode omitir corpos antigos até expandir.
|
||||
- **1b (com migration, se medido):** collection `actionPlanUpdate` append-only + relação `actionPlan` ↔ updates; migrar dados existentes; `appendActionPlanUpdateRecord` vira `create` na collection filha; manter snapshot/embed mínimo no pai se necessário para contadores.
|
||||
- Teste int: append concorrente + leitura paginada; não regressar locks advisory.
|
||||
|
||||
### Fase 2 — Loaders, batching e selects por aba
|
||||
|
||||
- `loadCampaignUserNamesById(payload, user, ids)` em `src/utilities/` — reutilizar em `actionPlanDetailPageData.ts`, `nucleusUpdatePageData.ts` e overview de núcleos.
|
||||
- `loadActionPlanDetailPageData(payload, user, slug, activeTab)` → `{ context, view }` unificando `resolveAccessibleActionPlanContext` + `getActionPlanDetailPageData`.
|
||||
- Refinar `getActionPlanDetailSelect`: header mínimo compartilhado + campos por aba (tasks: `tasks` + responsáveis das tarefas; updates: `updates` apenas; overview: relações + `taskDoneCount`/`taskTotal`).
|
||||
- Opcional: `getActionPlanEditPageData` com `select`/`depth` por seção (tarefas lazy) — só se edição ficar lenta em staging.
|
||||
|
||||
### Fase 3 — Hot paths de mutação e form de tarefas
|
||||
|
||||
- Constante tipada `ACTION_PLAN_MUTATION_KIND` compartilhada entre `actionPlan.ts` actions e `ActionPlan.ts` hooks.
|
||||
- `ActionPlanTaskFields`: evitar `JSON.stringify` em todo render — atualizar hidden `tasksJson` só no submit ou com debounce/`useDeferredValue`.
|
||||
- Documentar limite soft de tarefas por plano se toggle continuar RMW.
|
||||
|
||||
### Fase 4 — Utilities e DRY menor
|
||||
|
||||
- `searchContactComboboxOptions(payload, user, query)` em `utilities/`; `contactSearchActions.ts` fica auth wrapper (paridade com liderança).
|
||||
- `actionPlanLeadershipLabel(leadership)` único para search + `toActionPlanFormViewModel`.
|
||||
- `actionPlanDetailTabUi.ts` importa `firstValue` de `campaignListUrl.ts`.
|
||||
- Avaliar índice/query de liderança alinhada ao padrão C8/C9 (`contactSearchQuery` + `pg_trgm` onde aplicável).
|
||||
|
||||
### Fase 5 (opcional) — Loader da lista de planos
|
||||
|
||||
- `loadActionPlansPageData` combinando canonical URL + list (precedente `loadSupportersPageData`).
|
||||
- Entra só se a lista ganhar filtros/overview pesados; cortável.
|
||||
|
||||
**Migration:** somente Fase 1b (condicional). Demais fases sem schema.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** C7 Escala e DRY pós-C3 — entregue (merge em `main` pendente do PR atual).
|
||||
- **Suave:** C8 `pg_trgm` em `contact` — já em `main`; acelera typeahead se Fase 4 adotar o padrão.
|
||||
- **Suave:** C10 `errorProps` — paralelo; não bloqueia C11 (escopos distintos).
|
||||
- Reuso: `src/utilities/actionPlanPageData.ts`, `actionPlanDetailPageData.ts`, `actionPlanViewModels.ts`, `src/app/(campaign)/campanha/actions/actionPlan.ts`, `src/collections/ActionPlan.ts`, padrão `loadNucleusDetailPageData`, `campaignListUrl.ts`, `contactSearchQuery.ts`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- **`errorProps` nos forms** — C10 / [escala-dry-pos-c9.md](escala-dry-pos-c9.md).
|
||||
- **Demandas / relação `actionPlan` opcional** — C4.
|
||||
- **Notificações de agenda** — D2.
|
||||
- **Calendário visual / mapa de eventos** — fora do horizonte pré-16/08.
|
||||
- **Unificação FormData ↔ hook de território Payload** — [escala-dry-pos-c3.md](escala-dry-pos-c3.md) §Não escopo.
|
||||
- **Coordinator list paginada** — só entra se staff > ~50; até lá fill-in.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` — Trilha C, item C11; sequência Janela 2
|
||||
- [escala-dry-pos-c3.md](escala-dry-pos-c3.md) — C7 entregue; feed O(n) registrado aqui
|
||||
- [escala-dry-pos-c6.md](escala-dry-pos-c6.md) — precedente C8 (`pg_trgm`)
|
||||
- [escala-dry-pos-c8.md](escala-dry-pos-c8.md) — precedente C9/C10
|
||||
- `src/utilities/actionPlanDetailPageData.ts` — batch autores + view por aba
|
||||
- `src/utilities/actionPlanPageData.ts` — depth/select por aba
|
||||
- `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW
|
||||
- `src/components/campaign/ActionPlanTaskFields.tsx` — `serializeTasks`
|
||||
- `src/utilities/actionPlanLeadershipOptions.ts` — typeahead
|
||||
- `src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts` — server actions de busca
|
||||
- AGENTS.md — transações, `overrideAccess: false`, naming
|
||||
|
||||
## Simplify (2026-07-19)
|
||||
|
||||
Passagens `/simplify` sobre o diff do C7 (incl. pós-rebase com C8–C9/Onda 0) aplicaram limpezas pontuais (`fieldError`, `LeadershipCombobox`, `searchRef`, depth overview, exports mortos, skip `buildTerritorySuggestions` com override). Débitos maiores registrados neste plano como item **C11** do roadmap.
|
||||
|
|
@ -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 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
|
||||
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 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md))
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -76,11 +76,12 @@ 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 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Feed O(n) condicional fora de escopo. [Plano](plans/escala-dry-pos-c3.md).
|
||||
- **C7 Escala e DRY pós-C3 — entregue (2026-07-19)** — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), `AsyncSearchCombobox`, leituras por aba + `taskProgress` + typeahead de liderança, short-circuit de hooks em toggle/append; primeira fatia anterior: FormData território, contadores lista, locks, `contactSearchQuery`. Débitos pós-`/simplify` → C11. [Plano](plans/escala-dry-pos-c3.md).
|
||||
- **C6 Escala e DRY pós-C2 — implementado e mesclado em `main` (2026-07-19)** — import bulk drizzle, token HMAC + `supporterImportBatch`, KPI aggregate, shells compartilhados (`campaignListUrl`, `CampaignListPagination`, `campaignFormFields`, `mapCampaignFormActionError`). Duas passagens `/simplify`; débitos maiores → C8. [Plano](plans/escala-dry-pos-c2.md).
|
||||
- **C8 Escala e DRY pós-C6 — implementado e mesclado em `main` (2026-07-19)** — locks advisory em 1 RT, bulk com `.returning()` + leituras drizzle na txn, overview sem `COUNT(*)` redundante, migration `pg_trgm` em `contact`, `drizzleBulk.ts`, `getCoordinatorNucleusIds`, migração da maioria dos `formActions` para `mapCampaignFormActionError`. Duas passagens `/simplify` (pré- e pós-rebase com C7); débitos maiores → C9. [Plano](plans/escala-dry-pos-c6.md).
|
||||
- **C9 Escala e DRY pós-C8 — implementado (2026-07-19)** — filtro unificado Payload↔SQL (`supporterListFilters`), `contactSearchQuery` no aggregate/lista, `loadSupportersPageData` (1× núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos componentes de form restantes. Passagem `/simplify`; débitos maiores → C10. [Plano](plans/escala-dry-pos-c8.md).
|
||||
- **C10 Escala e DRY pós-C9 registrado (2026-07-19)** — dedup de access na lista de apoiadores (`req.context`), query única de núcleos do coordenador (`loadCoordinatorNucleusScope`), `errorProps` nos forms grandes, prefetch opcional na criação. [Plano](plans/escala-dry-pos-c9.md).
|
||||
- **C11 Escala e DRY pós-C7 registrado (2026-07-19)** — feed de atualizações O(n) (paginação / `actionPlanUpdate` condicional), loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths de mutação/form (`serializeTasks`, `mutationKind` tipado), utilities de busca/label. [Plano](plans/escala-dry-pos-c7.md).
|
||||
|
||||
### Ciclo 3 — Trilha E: mapa de projeção por município (registrado 2026-07-19)
|
||||
|
||||
|
|
@ -112,6 +113,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
|
|||
| C8 Escala e DRY pós-C6 | — (sem design novo; reusa os de C2/C6) | [escala-dry-pos-c6.md](plans/escala-dry-pos-c6.md) |
|
||||
| C9 Escala e DRY pós-C8 | — (sem design novo; reusa os de C2/C6/C8) | [escala-dry-pos-c8.md](plans/escala-dry-pos-c8.md) |
|
||||
| C10 Escala e DRY pós-C9 | — (sem design novo; reusa os de C2/C6/C8/C9) | [escala-dry-pos-c9.md](plans/escala-dry-pos-c9.md) |
|
||||
| C11 Escala e DRY pós-C7 | — (sem design novo; reusa os de C3 + padrões de núcleos/C6/C9) | [escala-dry-pos-c7.md](plans/escala-dry-pos-c7.md) |
|
||||
| O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) |
|
||||
| B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) |
|
||||
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
|
||||
|
|
@ -159,6 +161,7 @@ flowchart TD
|
|||
C8["C8 Escala e DRY pós-C6<br/>(locks bulk / leituras / forms DRY) ✓"]
|
||||
C9["C9 Escala e DRY pós-C8<br/>(filtros unificados / loader / forms) ✓"]
|
||||
C10["C10 Escala e DRY pós-C9<br/>(access dedup / scope / errorProps)"]
|
||||
C11["C11 Escala e DRY pós-C7<br/>(feed escala / loaders / hot paths)"]
|
||||
C4["C4 Demandas"]
|
||||
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
|
||||
end
|
||||
|
|
@ -205,6 +208,8 @@ flowchart TD
|
|||
C6 --> C8
|
||||
C8 --> C9
|
||||
C9 --> C10
|
||||
C7 --> C11
|
||||
C10 -.forms DRY.-> C11
|
||||
D1 --> D2
|
||||
JUR -.chave de push.-> D2
|
||||
DEPLOY -.engenharia entregue.-> O0DRY
|
||||
|
|
@ -218,7 +223,7 @@ flowchart TD
|
|||
A7 -.mesmo loader do baseline.-> E2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓) e fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
|
|
@ -244,31 +249,32 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
|
|||
| 11 | C7 Escala e DRY pós-C3 **(entregue 2026-07-19 — Fases 1–5; feed O(n) condicional fora de escopo)** | [detalhes](plans/escala-dry-pos-c3.md) | C3 (suave: C6 F2) | A4, C6, C8, E1+E3, E2 |
|
||||
| 12 | C8 Escala e DRY pós-C6 (locks bulk em 1 RT, leituras/`pg_trgm`, helpers drizzle, forms DRY) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c6.md) | C6 (merge) | A4, C3, C7, E1+E3, E2, C9 |
|
||||
| 13 | C9 Escala e DRY pós-C8 (filtro unificado Payload↔SQL, `contactSearchQuery` no aggregate, loader único `/apoiadores`, forms restantes) **(implementado e mesclado em `main` 2026-07-19 — Fases 1–4 + simplify)** _(cortável se a base nominal permanecer pequena)_ | [detalhes](plans/escala-dry-pos-c8.md) | C8 (merge) | A4, C3, C7, E1+E3, E2, C10 |
|
||||
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2 |
|
||||
| 15 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
|
||||
| 16 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10 |
|
||||
| 14 | C10 Escala e DRY pós-C9 (dedup access lista, scope único de núcleos do coordenador, `errorProps` nos forms) _(cortável se a base nominal permanecer pequena; Fase 3 DRY pode entrar isolada)_ | [detalhes](plans/escala-dry-pos-c9.md) | C9 (merge) | A4, C3, C7, E1+E3, E2, C11 |
|
||||
| 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 |
|
||||
| 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela |
|
||||
| 17 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, C7, C8, C9, C10, C11 |
|
||||
|
||||
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------- |
|
||||
| 17 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
|
||||
| 18 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 |
|
||||
| 19 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
|
||||
| 20 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
|
||||
| 21 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
|
||||
| 22 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
|
||||
| 23 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 24 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 |
|
||||
| 18 | E4 Import único da planilha (`pnpm db:seed:mapa`; sem criar `Contact`/`leadership`) | [detalhes](plans/mapa-projecao-municipios.md) | E1 + E3 (suave: E2 — tendência visível na revisão) | A5, A7, B3, B5, C4, D2 |
|
||||
| 19 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | A7, B3, B5, C4, E4, D1 |
|
||||
| 20 | A7 Escala e DRY pós-A4 (agregar federal no detalhe, `cityCode` TSE, Alert `confirmed` + Progress) | [detalhes](plans/escala-dry-pos-a4.md) | A4 ✓ (suave: A5/B3/E2 amplificam o custo) | A5, B3, B5, C4, E4, D1 |
|
||||
| 21 | B2 Mapa Fase 1 (geometrias) **(entregue 2026-07-18)** + B3 Leaflet nas superfícies | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1 ✓ + B2 ✓ (suave: A4 coroplético; E1/E2 métricas; B5 F1 lazy geometrias) | A5, A7, B5, C4, E4, D1 |
|
||||
| 22 | B5 Escala e DRY pós-B2 (lazy geometrias + cache CLI) | [detalhes](plans/escala-dry-pos-b2.md) | B2 ✓ (F1 preferencialmente com B3) | A5, A7, B3, C4, E4, D1 |
|
||||
| 23 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, A7, B3, B5, E4, D1 |
|
||||
| 24 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 25 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, A7, B3, B5, C4, E4 |
|
||||
|
||||
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 25 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 26 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 27 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
|
||||
| 28 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
|
||||
| 26 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 27 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 28 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
|
||||
| 29 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. E5 (Salvador por bairro) fica registrado para depois da eleição, condicionado a decisão de produto. | — | — |
|
||||
|
||||
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
|
||||
|
||||
|
|
@ -278,7 +284,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0
|
|||
- **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): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 fases 1–2, C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 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, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), 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), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 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, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import {
|
|||
normalizeContactSearchQuery,
|
||||
} from '@/lib/contactSearchQuery'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { searchActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions'
|
||||
import {
|
||||
searchActionPlanLeadershipOptions,
|
||||
type ActionPlanLeadershipOption,
|
||||
} from '@/utilities/actionPlanLeadershipOptions'
|
||||
|
||||
const CONTACT_OPTION_LIMIT = 20
|
||||
|
||||
|
|
@ -43,7 +46,7 @@ export const searchActionPlanContactOptions = async (
|
|||
|
||||
export const searchActionPlanLeadershipOptionsAction = async (
|
||||
query: string,
|
||||
): Promise<Array<{ id: number; label: string }>> => {
|
||||
): Promise<ActionPlanLeadershipOption[]> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) throw new Error('Autenticação necessária.')
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useActionState, useState } from 'react'
|
|||
import Link from 'next/link'
|
||||
|
||||
import { ContactCombobox, type ContactComboboxOption } from '@/components/campaign/ContactCombobox'
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
|
||||
import { LeadershipCombobox } from '@/components/campaign/LeadershipCombobox'
|
||||
import { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields'
|
||||
import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
||||
|
|
@ -26,7 +26,6 @@ import { Textarea } from '@/components/ui/textarea'
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { actionPlanKindLabels, actionPlanStatusLabels } from '@/lib/schemas/actionPlan'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
|
||||
import type { ActionPlanFormViewModel } from '@/utilities/actionPlanViewModels'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
|
@ -79,7 +78,7 @@ export const ActionPlanFormFields = ({
|
|||
? { id: plan.responsible.id, name: plan.responsible.name, phone: plan.responsible.phone }
|
||||
: null,
|
||||
)
|
||||
const [leadership, setLeadership] = useState<{ id: number; label: string } | null>(
|
||||
const [leadership, setLeadership] = useState<ActionPlanLeadershipOption | null>(
|
||||
plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null,
|
||||
)
|
||||
|
||||
|
|
@ -304,19 +303,12 @@ export const ActionPlanFormFields = ({
|
|||
|
||||
<Field data-invalid={Boolean(errorFor('leadership'))}>
|
||||
<FieldLabel>Liderança vinculada</FieldLabel>
|
||||
<AsyncSearchCombobox
|
||||
<LeadershipCombobox
|
||||
name="leadership"
|
||||
label="Liderança vinculada"
|
||||
value={leadership}
|
||||
onChange={setLeadership}
|
||||
search={async (query) => {
|
||||
const results = await searchLeaderships(query)
|
||||
return results.map((option) => ({ id: option.id, label: option.label }))
|
||||
}}
|
||||
emptyOptionLabel="Nenhuma"
|
||||
dialogDescription="Busque lideranças engajadas por nome ou celular."
|
||||
isQueryReady={isContactSearchQueryReady}
|
||||
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
|
||||
search={searchLeaderships}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar
|
||||
|
|
|
|||
198
src/components/campaign/AsyncSearchCombobox.tsx
Normal file
198
src/components/campaign/AsyncSearchCombobox.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronsUpDownIcon } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/Command'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
export type AsyncSearchOption = {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
type AsyncSearchComboboxProps = {
|
||||
name?: string
|
||||
label: string
|
||||
value: AsyncSearchOption | null
|
||||
onChange?: (option: AsyncSearchOption | null) => void
|
||||
search: (query: string) => Promise<AsyncSearchOption[]>
|
||||
emptyOptionLabel?: string
|
||||
dialogDescription?: string
|
||||
isQueryReady?: (query: string) => boolean
|
||||
queryTooShortMessage?: string
|
||||
pinnedOptions?: AsyncSearchOption[]
|
||||
pinnedGroupHeading?: string
|
||||
}
|
||||
|
||||
export const AsyncSearchCombobox = ({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
emptyOptionLabel = 'Nenhum',
|
||||
dialogDescription = 'Digite para buscar.',
|
||||
isQueryReady = () => true,
|
||||
queryTooShortMessage = 'Digite para buscar.',
|
||||
pinnedOptions = [],
|
||||
pinnedGroupHeading = 'Sugestões',
|
||||
}: AsyncSearchComboboxProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState<AsyncSearchOption | null>(value)
|
||||
const [options, setOptions] = useState<AsyncSearchOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const requestId = useRef(0)
|
||||
const searchRef = useRef(search)
|
||||
const isQueryReadyRef = useRef(isQueryReady)
|
||||
|
||||
searchRef.current = search
|
||||
isQueryReadyRef.current = isQueryReady
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!isQueryReadyRef.current(query)) {
|
||||
setOptions([])
|
||||
setLoading(false)
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
|
||||
const currentRequestId = ++requestId.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
setFailed(false)
|
||||
void searchRef.current(query.trim())
|
||||
.then((nextOptions) => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setOptions(nextOptions)
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId.current !== currentRequestId) return
|
||||
setFailed(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId.current === currentRequestId) setLoading(false)
|
||||
})
|
||||
}, 250)
|
||||
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [open, query])
|
||||
|
||||
const choose = (option: AsyncSearchOption | null) => {
|
||||
setSelected(option)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onChange?.(option)
|
||||
}
|
||||
|
||||
const visibleOptions = options.filter((option) => option.id !== selected?.id)
|
||||
const visiblePinned = pinnedOptions.filter((option) => option.id !== selected?.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
{name ? <input type="hidden" name={name} value={selected?.id ?? ''} /> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full justify-between font-normal"
|
||||
aria-label={`${label}: ${selected?.label ?? emptyOptionLabel}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? emptyOptionLabel}</span>
|
||||
<ChevronsUpDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={label}
|
||||
description={dialogDescription}
|
||||
showCloseButton
|
||||
>
|
||||
<Command shouldFilter={false} label={label}>
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar"
|
||||
aria-label={label}
|
||||
/>
|
||||
<CommandList aria-busy={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Spinner aria-hidden="true" />
|
||||
Buscando…
|
||||
</div>
|
||||
) : failed ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">
|
||||
Não foi possível concluir a busca.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<CommandGroup heading="Seleção">
|
||||
<CommandItem value="none" onSelect={() => choose(null)}>
|
||||
{emptyOptionLabel}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{selected ? (
|
||||
<CommandGroup heading="Selecionado">
|
||||
<CommandItem
|
||||
value={`current-${selected.id}`}
|
||||
data-checked
|
||||
onSelect={() => choose(selected)}
|
||||
>
|
||||
<span className="truncate">{selected.label}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{visiblePinned.length ? (
|
||||
<CommandGroup heading={pinnedGroupHeading}>
|
||||
{visiblePinned.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`pinned-${option.id}`}
|
||||
onSelect={() => choose(option)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{visibleOptions.length ? (
|
||||
<CommandGroup heading="Resultados">
|
||||
{visibleOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`option-${option.id}`}
|
||||
onSelect={() => choose(option)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{!isQueryReady(query) ? queryTooShortMessage : 'Nenhum resultado encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
244
src/components/campaign/CampaignTerritoryCoreFields.tsx
Normal file
244
src/components/campaign/CampaignTerritoryCoreFields.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
|
||||
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
|
||||
import type { CampaignTerritoryFieldsState } from '@/components/campaign/useCampaignTerritoryFieldsState'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
|
||||
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
||||
const ChipList = ({
|
||||
values,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: {
|
||||
values: string[]
|
||||
onRemove: (value: string) => void
|
||||
removeLabel: (value: string) => string
|
||||
}) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((value) => (
|
||||
<Badge key={value} asChild variant="secondary" className="h-8 gap-1 rounded-sm px-2.5 py-1">
|
||||
<button type="button" onClick={() => onRemove(value)} aria-label={removeLabel(value)}>
|
||||
<span>{value}</span>
|
||||
<XIcon aria-hidden="true" className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CampaignTerritoryCoreFields = ({
|
||||
state,
|
||||
idPrefix,
|
||||
fieldErrors = {},
|
||||
citySuggestions: citySuggestionsOverride,
|
||||
}: {
|
||||
state: CampaignTerritoryFieldsState
|
||||
idPrefix: string
|
||||
fieldErrors?: Record<string, string[]>
|
||||
citySuggestions?: ReturnType<typeof buildTerritorySuggestions>['citySuggestions']
|
||||
}) => {
|
||||
const errorFor = (name: string) => fieldError(fieldErrors, name)
|
||||
const {
|
||||
regions,
|
||||
cities,
|
||||
neighborhoods,
|
||||
cityDraft,
|
||||
regionDraft,
|
||||
neighborhoodDraft,
|
||||
regionError,
|
||||
cityError,
|
||||
neighborhoodError,
|
||||
setCityDraft,
|
||||
setRegionDraft,
|
||||
setNeighborhoodDraft,
|
||||
setRegionError,
|
||||
setCityError,
|
||||
setNeighborhoodError,
|
||||
displayRegions,
|
||||
regionsAreDerived,
|
||||
neighborhoodsEnabled,
|
||||
availableCityOptions,
|
||||
availableRegionOptions,
|
||||
addCity,
|
||||
removeCity,
|
||||
addRegion,
|
||||
removeRegion,
|
||||
commitNeighborhood,
|
||||
removeNeighborhood,
|
||||
} = state
|
||||
|
||||
const citySuggestions = useMemo(() => {
|
||||
if (citySuggestionsOverride) return citySuggestionsOverride
|
||||
return buildTerritorySuggestions({
|
||||
cities,
|
||||
regions: displayRegions,
|
||||
tseZones: [],
|
||||
}).citySuggestions
|
||||
}, [citySuggestionsOverride, cities, displayRegions])
|
||||
|
||||
const visibleRegionError = regionError ?? errorFor('regions')
|
||||
const visibleCityError = cityError ?? errorFor('cities')
|
||||
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
|
||||
|
||||
const regionFieldId = `${idPrefix}-lookup-a`
|
||||
const cityFieldId = `${idPrefix}-lookup-b`
|
||||
const neighborhoodFieldId = `${idPrefix}-neighborhoodDraft`
|
||||
|
||||
return (
|
||||
<>
|
||||
{cities.map((city) => (
|
||||
<input key={`city-${city}`} type="hidden" name="cities" value={city} />
|
||||
))}
|
||||
{!regionsAreDerived
|
||||
? regions.map((region) => (
|
||||
<input key={`region-${region}`} type="hidden" name="regions" value={region} />
|
||||
))
|
||||
: null}
|
||||
{neighborhoodsEnabled
|
||||
? neighborhoods.map((neighborhood) => (
|
||||
<input
|
||||
key={`neighborhood-${neighborhood}`}
|
||||
type="hidden"
|
||||
name="neighborhoods"
|
||||
value={neighborhood}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
|
||||
<Field data-invalid={Boolean(visibleRegionError)} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={regionFieldId}>Territórios de identidade</FieldLabel>
|
||||
{displayRegions.length ? (
|
||||
<ChipList
|
||||
values={displayRegions}
|
||||
onRemove={removeRegion}
|
||||
removeLabel={(region) => `Remover território ${region}`}
|
||||
/>
|
||||
) : null}
|
||||
{regionsAreDerived ? (
|
||||
<FieldDescription>
|
||||
Derivados automaticamente dos municípios selecionados. Remova municípios para editar
|
||||
territórios manualmente.
|
||||
</FieldDescription>
|
||||
) : (
|
||||
<StrictCombobox
|
||||
id={regionFieldId}
|
||||
options={availableRegionOptions}
|
||||
value={regionDraft}
|
||||
onValueChange={(nextRegion) => {
|
||||
if (nextRegion) addRegion(nextRegion)
|
||||
else setRegionDraft('')
|
||||
}}
|
||||
onInvalid={() => {
|
||||
setRegionError('Selecione um território de identidade válido da Bahia.')
|
||||
}}
|
||||
error={visibleRegionError}
|
||||
/>
|
||||
)}
|
||||
{visibleRegionError ? (
|
||||
<FieldError id={`${regionFieldId}-error`}>{visibleRegionError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={cityFieldId}>Municípios</FieldLabel>
|
||||
<TerritorySuggestionChips
|
||||
kind="city"
|
||||
suggestions={citySuggestions}
|
||||
onAccept={(suggestion) => addCity(suggestion.city)}
|
||||
/>
|
||||
{cities.length ? (
|
||||
<ChipList
|
||||
values={cities}
|
||||
onRemove={removeCity}
|
||||
removeLabel={(city) => `Remover município ${city}`}
|
||||
/>
|
||||
) : null}
|
||||
<StrictCombobox
|
||||
id={cityFieldId}
|
||||
options={availableCityOptions}
|
||||
value={cityDraft}
|
||||
onValueChange={(nextCity) => {
|
||||
if (nextCity) addCity(nextCity)
|
||||
else setCityDraft('')
|
||||
}}
|
||||
onInvalid={() => {
|
||||
setCityError('Selecione um município válido da Bahia.')
|
||||
}}
|
||||
error={visibleCityError}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só
|
||||
ficam disponíveis com exatamente um município.
|
||||
</FieldDescription>
|
||||
{visibleCityError ? (
|
||||
<FieldError id={`${cityFieldId}-error`}>{visibleCityError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-disabled={!neighborhoodsEnabled}
|
||||
data-invalid={Boolean(visibleNeighborhoodError)}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<FieldLabel htmlFor={neighborhoodFieldId}>Bairros</FieldLabel>
|
||||
{neighborhoodsEnabled && neighborhoods.length ? (
|
||||
<ChipList
|
||||
values={neighborhoods}
|
||||
onRemove={removeNeighborhood}
|
||||
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
|
||||
/>
|
||||
) : null}
|
||||
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
|
||||
<InputGroupInput
|
||||
id={neighborhoodFieldId}
|
||||
value={neighborhoodDraft}
|
||||
disabled={!neighborhoodsEnabled}
|
||||
placeholder={
|
||||
neighborhoodsEnabled
|
||||
? 'Digite um bairro e pressione Enter'
|
||||
: 'Selecione exatamente um município para informar bairros'
|
||||
}
|
||||
autoComplete="address-level3"
|
||||
aria-invalid={Boolean(visibleNeighborhoodError)}
|
||||
aria-describedby={
|
||||
visibleNeighborhoodError
|
||||
? `${idPrefix}-neighborhoods-error`
|
||||
: `${idPrefix}-neighborhoods-description`
|
||||
}
|
||||
className="min-w-48"
|
||||
onChange={(event) => {
|
||||
setNeighborhoodDraft(event.target.value)
|
||||
setNeighborhoodError(undefined)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitNeighborhood(neighborhoodDraft)
|
||||
} else if (
|
||||
event.key === 'Backspace' &&
|
||||
!neighborhoodDraft &&
|
||||
neighborhoods.length > 0
|
||||
) {
|
||||
removeNeighborhood(neighborhoods[neighborhoods.length - 1]!)
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitNeighborhood(neighborhoodDraft)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FieldDescription id={`${idPrefix}-neighborhoods-description`}>
|
||||
Use Enter para adicionar. Disponível somente quando há exatamente um município.
|
||||
</FieldDescription>
|
||||
{visibleNeighborhoodError ? (
|
||||
<FieldError id={`${idPrefix}-neighborhoods-error`}>{visibleNeighborhoodError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</>
|
||||
)
|
||||
}
|
||||
40
src/components/campaign/LeadershipCombobox.tsx
Normal file
40
src/components/campaign/LeadershipCombobox.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client'
|
||||
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
|
||||
import { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
import type { ActionPlanLeadershipOption } from '@/utilities/actionPlanLeadershipOptions'
|
||||
|
||||
type LeadershipComboboxProps = {
|
||||
name?: string
|
||||
label: string
|
||||
value: ActionPlanLeadershipOption | null
|
||||
search: (query: string) => Promise<ActionPlanLeadershipOption[]>
|
||||
emptyOptionLabel?: string
|
||||
onChange?: (option: ActionPlanLeadershipOption | null) => void
|
||||
}
|
||||
|
||||
export const LeadershipCombobox = ({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
search,
|
||||
emptyOptionLabel = 'Nenhuma',
|
||||
onChange,
|
||||
}: LeadershipComboboxProps) => (
|
||||
<AsyncSearchCombobox
|
||||
name={name}
|
||||
label={label}
|
||||
value={value ? { id: value.id, label: value.label } : null}
|
||||
emptyOptionLabel={emptyOptionLabel}
|
||||
dialogDescription="Busque lideranças engajadas por nome ou celular."
|
||||
isQueryReady={isContactSearchQueryReady}
|
||||
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
|
||||
search={async (query) => {
|
||||
const results = await search(query)
|
||||
return results.map((option) => ({ id: option.id, label: option.label }))
|
||||
}}
|
||||
onChange={(option) =>
|
||||
onChange?.(option ? { id: option.id, label: option.label } : null)
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox'
|
||||
import type {
|
||||
NucleusPrimaryContactPageData,
|
||||
|
|
@ -22,9 +20,6 @@ export const PrimaryContactCombobox = ({
|
|||
current,
|
||||
search,
|
||||
}: PrimaryContactComboboxProps) => {
|
||||
const contactsById = useRef(new Map<number, PrimaryContactOption>())
|
||||
if (current) contactsById.current.set(current.id, current)
|
||||
|
||||
return (
|
||||
<AsyncSearchCombobox
|
||||
name={name}
|
||||
|
|
@ -34,8 +29,6 @@ export const PrimaryContactCombobox = ({
|
|||
dialogDescription="Busque entre as lideranças engajadas deste núcleo."
|
||||
search={async (query) => {
|
||||
const result = await search(query)
|
||||
if (result.current) contactsById.current.set(result.current.id, result.current)
|
||||
for (const contact of result.options) contactsById.current.set(contact.id, contact)
|
||||
const limit = result.current ? 99 : 100
|
||||
return result.options.slice(0, limit).map((contact) => ({
|
||||
id: contact.id,
|
||||
|
|
|
|||
148
src/components/campaign/useCampaignTerritoryFieldsState.ts
Normal file
148
src/components/campaign/useCampaignTerritoryFieldsState.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { territoriesForCities } from '@/lib/bahiaTerritories'
|
||||
import {
|
||||
dedupeTrimmedStrings,
|
||||
MAX_NUCLEUS_CITIES,
|
||||
MAX_NUCLEUS_NEIGHBORHOODS,
|
||||
MAX_NUCLEUS_REGIONS,
|
||||
} from '@/lib/schemas/nucleus'
|
||||
import {
|
||||
allMunicipalityComboboxOptions,
|
||||
territoryComboboxOptions,
|
||||
} from '@/utilities/territoryComboboxOptions'
|
||||
|
||||
export type CampaignTerritoryValues = {
|
||||
regions?: string[] | null
|
||||
cities?: string[] | null
|
||||
neighborhoods?: string[] | null
|
||||
}
|
||||
|
||||
export const useCampaignTerritoryFieldsState = (values?: CampaignTerritoryValues) => {
|
||||
const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
|
||||
const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
|
||||
const [neighborhoods, setNeighborhoods] = useState(() =>
|
||||
dedupeTrimmedStrings(values?.neighborhoods ?? []),
|
||||
)
|
||||
const [cityDraft, setCityDraft] = useState('')
|
||||
const [regionDraft, setRegionDraft] = useState('')
|
||||
const [neighborhoodDraft, setNeighborhoodDraft] = useState('')
|
||||
const [regionError, setRegionError] = useState<string>()
|
||||
const [cityError, setCityError] = useState<string>()
|
||||
const [neighborhoodError, setNeighborhoodError] = useState<string>()
|
||||
|
||||
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
|
||||
const displayRegions = cities.length > 0 ? derivedRegions : regions
|
||||
const regionsAreDerived = cities.length > 0
|
||||
const neighborhoodsEnabled = cities.length === 1
|
||||
|
||||
const availableCityOptions = useMemo(() => {
|
||||
const selected = new Set(cities)
|
||||
return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value))
|
||||
}, [cities])
|
||||
const availableRegionOptions = useMemo(() => {
|
||||
const selected = new Set(regions)
|
||||
return territoryComboboxOptions.filter((option) => !selected.has(option.value))
|
||||
}, [regions])
|
||||
|
||||
const addCity = (nextCity: string) => {
|
||||
if (!nextCity) return
|
||||
if (cities.length >= MAX_NUCLEUS_CITIES) {
|
||||
setCityError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`)
|
||||
setCityDraft('')
|
||||
return
|
||||
}
|
||||
setCities((current) => {
|
||||
const next = dedupeTrimmedStrings([...current, nextCity])
|
||||
if (next.length !== 1) setNeighborhoods([])
|
||||
return next
|
||||
})
|
||||
setCityDraft('')
|
||||
setCityError(undefined)
|
||||
setRegionError(undefined)
|
||||
}
|
||||
|
||||
const removeCity = (city: string) => {
|
||||
setCities((current) => {
|
||||
const next = current.filter((item) => item !== city)
|
||||
if (next.length !== 1) setNeighborhoods([])
|
||||
return next
|
||||
})
|
||||
setCityError(undefined)
|
||||
}
|
||||
|
||||
const addRegion = (nextRegion: string) => {
|
||||
if (!nextRegion || regionsAreDerived) return
|
||||
if (regions.length >= MAX_NUCLEUS_REGIONS) {
|
||||
setRegionError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`)
|
||||
setRegionDraft('')
|
||||
return
|
||||
}
|
||||
setRegions((current) => dedupeTrimmedStrings([...current, nextRegion]))
|
||||
setRegionDraft('')
|
||||
setRegionError(undefined)
|
||||
}
|
||||
|
||||
const removeRegion = (region: string) => {
|
||||
if (regionsAreDerived) return
|
||||
setRegions((current) => current.filter((item) => item !== region))
|
||||
setRegionError(undefined)
|
||||
}
|
||||
|
||||
const commitNeighborhood = (value: string) => {
|
||||
if (!neighborhoodsEnabled) {
|
||||
setNeighborhoodDraft('')
|
||||
return
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
setNeighborhoodDraft('')
|
||||
return
|
||||
}
|
||||
if (neighborhoods.length >= MAX_NUCLEUS_NEIGHBORHOODS) {
|
||||
setNeighborhoodError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`)
|
||||
setNeighborhoodDraft('')
|
||||
return
|
||||
}
|
||||
setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed]))
|
||||
setNeighborhoodDraft('')
|
||||
setNeighborhoodError(undefined)
|
||||
}
|
||||
|
||||
const removeNeighborhood = (neighborhood: string) => {
|
||||
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
|
||||
}
|
||||
|
||||
return {
|
||||
regions,
|
||||
cities,
|
||||
neighborhoods,
|
||||
cityDraft,
|
||||
regionDraft,
|
||||
neighborhoodDraft,
|
||||
regionError,
|
||||
cityError,
|
||||
neighborhoodError,
|
||||
setCityDraft,
|
||||
setRegionDraft,
|
||||
setNeighborhoodDraft,
|
||||
setRegionError,
|
||||
setCityError,
|
||||
setNeighborhoodError,
|
||||
displayRegions,
|
||||
regionsAreDerived,
|
||||
neighborhoodsEnabled,
|
||||
availableCityOptions,
|
||||
availableRegionOptions,
|
||||
addCity,
|
||||
removeCity,
|
||||
addRegion,
|
||||
removeRegion,
|
||||
commitNeighborhood,
|
||||
removeNeighborhood,
|
||||
}
|
||||
}
|
||||
|
||||
export type CampaignTerritoryFieldsState = ReturnType<typeof useCampaignTerritoryFieldsState>
|
||||
56
src/utilities/actionPlanDetailPageData.ts
Normal file
56
src/utilities/actionPlanDetailPageData.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Payload } from 'payload'
|
||||
|
||||
import type { ActionPlan, CampaignUser } from '@/payload-types'
|
||||
import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi'
|
||||
import {
|
||||
toActionPlanDetailViewModel,
|
||||
type ActionPlanDetailViewModel,
|
||||
} from '@/utilities/actionPlanViewModels'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import type { AccessibleActionPlanContext } from '@/utilities/actionPlanPageData'
|
||||
|
||||
const loadActionPlanUpdateAuthorNames = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
updates: NonNullable<ActionPlan['updates']>,
|
||||
): Promise<Map<number, string>> => {
|
||||
const authorIds = [
|
||||
...new Set(
|
||||
updates
|
||||
.map((update) => relationshipId(update.author))
|
||||
.filter((id): id is number => id !== null),
|
||||
),
|
||||
]
|
||||
|
||||
if (authorIds.length === 0) return new Map()
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: authorIds } },
|
||||
depth: 0,
|
||||
pagination: false,
|
||||
select: { name: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return new Map(result.docs.map((author) => [author.id, author.name]))
|
||||
}
|
||||
|
||||
export const getActionPlanDetailPageData = async (
|
||||
payload: Pick<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
context: AccessibleActionPlanContext,
|
||||
activeTab: ActionPlanDetailTab,
|
||||
): Promise<ActionPlanDetailViewModel> => {
|
||||
if (activeTab === 'updates' && context.document.updates?.length) {
|
||||
const authorNames = await loadActionPlanUpdateAuthorNames(
|
||||
payload,
|
||||
user,
|
||||
context.document.updates,
|
||||
)
|
||||
return toActionPlanDetailViewModel(context.document, activeTab, authorNames)
|
||||
}
|
||||
|
||||
return toActionPlanDetailViewModel(context.document, activeTab)
|
||||
}
|
||||
|
|
@ -52,7 +52,6 @@ export const searchActionPlanLeadershipOptions = async (
|
|||
},
|
||||
depth: 1,
|
||||
limit: LEADERSHIP_SEARCH_LIMIT,
|
||||
sort: 'id',
|
||||
select: {
|
||||
contact: true,
|
||||
supportStatus: true,
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] =>
|
|||
doneAt: task.doneAt ?? null,
|
||||
}))
|
||||
|
||||
export const mapActionPlanUpdates = (
|
||||
const mapActionPlanUpdates = (
|
||||
plan: ActionPlan,
|
||||
authorNamesById: ReadonlyMap<number, string> = new Map(),
|
||||
): ActionPlanUpdateViewModel[] =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue