diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 0627fbc6..6f9a4ac1 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -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):** débitos de escala/DRY pós-C3 do `/simplify` (território/contato compostos, helpers FormData/URL, leituras por aba, RMW/índice) — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); não bloqueia o MVP da agenda. +- **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. - **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). diff --git a/docs/plans/escala-dry-pos-c3.md b/docs/plans/escala-dry-pos-c3.md index 426d9bf9..b306816e 100644 --- a/docs/plans/escala-dry-pos-c3.md +++ b/docs/plans/escala-dry-pos-c3.md @@ -1,6 +1,6 @@ # Escala e DRY pós-C3 (planos de ação) -Status: em andamento (parcial 2026-07-19) +Status: entregue (2026-07-19) Atualizado em: 2026-07-19 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C7) Responsável: — @@ -19,24 +19,9 @@ 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. +**Entregue (2026-07-19):** Fases 1–5 completas — composição territorial (`useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`), shell `AsyncSearchCombobox` (Contact + Primary + liderança), helpers FormData/URL, leituras por aba + `taskProgress` no detalhe + typeahead de liderança, short-circuit de hooks em toggle/append. Feed O(n) / collection `actionPlanUpdate` permanece condicional (fora de escopo). -- **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. +**Primeira fatia (2026-07-19):** FormData território, contadores lista + migration `20260719_014906_action_plan_list_perf`, locks advisory, `contactSearchQuery`, rename `CampaignTerritoryFields`. ## Objetivos @@ -83,40 +68,40 @@ flowchart TD C6["C6 F2 campaignListUrl"] -.suave.-> F3 ``` -### Fase 1 — Território UI composto _(pendente)_ +### Fase 1 — Território UI composto _(entregue)_ - 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. +- **Entregue:** rename `CampaignTerritoryFields` + consumo no form de plano; core `useCampaignTerritoryFieldsState` + `CampaignTerritoryCoreFields`. -### Fase 2 — Contact picker único _(pendente)_ +### Fase 2 — Contact picker único _(entregue)_ - 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. +- **Entregue:** `AsyncSearchCombobox` + adaptadores; helper `contactSearchQuery` (mín. 2 chars). -### Fase 3 — Helpers FormData / URL _(parcialmente entregue)_ +### Fase 3 — Helpers FormData / URL _(entregue)_ - **`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 _(parcialmente entregue)_ +### Fase 4 — Leituras _(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. _(pendente)_ +- **`[slug]/page.tsx`**: selects por aba via `getActionPlanDetailSelect` + `getActionPlanDetailPageData` (overview sem arrays; tarefas com `depth: 1`; atualizações com `depth: 0` + batch de autores). - **Lista:** ✅ `taskDoneCount` / `taskTotal` + `actionPlanListSelect` sem `tasks`. -- **`getActionPlanLeadershipOptions` → search:** lazy no open do campo; `limit` baixo; sem `depth: 1` em massa. _(pendente)_ +- **`getActionPlanLeadershipOptions` → search:** ✅ `searchActionPlanLeadershipOptions` + `AsyncSearchCombobox` no form. - **`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)_ +- **Detalhe:** ✅ `taskProgress` no view model da overview (contadores, não filtro inline de `tasks`). -### Fase 5 — Escritas + índice _(parcialmente entregue)_ +### Fase 5 — Escritas + índice _(entregue; feed O(n) condicional)_ - **`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)_ +- **Short-circuit de hooks:** ✅ `context.mutationKind: 'taskToggle' | 'appendUpdate'` nas actions; hooks `beforeValidate` retornam cedo. - **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)_ +- **Feed O(n):** monitorar tamanho de `updates`; só então collection `actionPlanUpdate` append-only. _(condicional — não implementado)_ **Migration:** Fases 1–3 sem schema. Migration `20260719_014906_action_plan_list_perf` ✅ entregue (contadores + índice parcial). Sem Consent novo. @@ -146,8 +131,8 @@ flowchart TD - 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/components/campaign/AsyncSearchCombobox.tsx` — shell assíncrono compartilhado (entregue) +- `src/components/campaign/useCampaignTerritoryFieldsState.ts` / `CampaignTerritoryCoreFields.tsx` — core territorial (entregue) - `src/utilities/actionPlanFormData.ts` / `nucleusFormData.ts` / `actionPlanUi.ts` - `src/utilities/actionPlanPageData.ts` / `actionPlanViewModels.ts` / `actionPlanLeadershipOptions.ts` - `src/app/(campaign)/campanha/actions/actionPlan.ts` — toggle/append RMW diff --git a/docs/roadmap.md b/docs/roadmap.md index f7b24dee..8e9eefc6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 parcial 2026-07-19 (FormData território, contadores lista, locks, contactSearchQuery) — débitos pós-simplify registrados no plano; 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 (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`; 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,7 +76,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). +- **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). - **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). @@ -155,7 +155,7 @@ flowchart TD C2["C2 Cadastro nominal de apoiadores ✓"] C6["C6 Escala e DRY pós-C2
(import/listas/forms) ✓"] C3["C3 Eventos / agenda de mobilização ✓"] - C7["C7 Escala e DRY pós-C3
(parcial: contadores/locks/FormData)"] + C7["C7 Escala e DRY pós-C3
(território/contato/leituras/hooks) ✓"] C8["C8 Escala e DRY pós-C6
(locks bulk / leituras / forms DRY) ✓"] C9["C9 Escala e DRY pós-C8
(filtros unificados / loader / forms) ✓"] C10["C10 Escala e DRY pós-C9
(access dedup / scope / errorProps)"] @@ -218,7 +218,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, 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), C7 (após C3; suave: C6 F2 ✓), 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) 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 @@ -241,9 +241,9 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 | 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 _(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, E1+E3, E2 | +| 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 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 | +| 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 | diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx index c1a31051..62ec3c1a 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx @@ -2,10 +2,12 @@ import config from '@payload-config' import { getPayload } from 'payload' import { notFound, redirect } from 'next/navigation' -import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions' +import { + searchActionPlanContactOptions, + searchActionPlanLeadershipOptionsAction, +} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions' import { updateActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions' import { ActionPlanForm } from '@/components/campaign/ActionPlanForm' -import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions' import { getCampaignUser } from '@/utilities/campaignAuth' import { ActionPlanNotFoundError, @@ -28,13 +30,12 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP if (user.role === 'lideranca') redirect('/campanha/planos') if (!slug) notFound() - const [view, coordinators, leadershipOptions] = await Promise.all([ + const [view, coordinators] = await Promise.all([ getActionPlanEditPageData(payload, user, slug).catch((error) => { if (error instanceof ActionPlanNotFoundError) notFound() throw error }), getEligibleNucleusCoordinatorOptions(payload, user), - getActionPlanLeadershipOptions(payload, user), ]) return ( @@ -51,9 +52,9 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP plan={view} coordinators={coordinators} canManageCoordinators={user.role === 'geral'} - leadershipOptions={leadershipOptions} submitLabel="Salvar alterações" searchContacts={searchActionPlanContactOptions} + searchLeaderships={searchActionPlanLeadershipOptionsAction} /> ) diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx index 9e5e2a8a..babdb5c6 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -20,9 +20,9 @@ import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' import { getCampaignUser } from '@/utilities/campaignAuth' import { ActionPlanNotFoundError, - getActionPlanDetailPageData, resolveAccessibleActionPlanContext, } from '@/utilities/actionPlanPageData' +import { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData' import { getActionPlanDetailTabRedirect, resolveActionPlanDetailTab, @@ -49,11 +49,13 @@ export default async function ActionPlanDetailPage({ if (!slug) notFound() const activeTab = resolveActionPlanDetailTab(query) - const context = await resolveAccessibleActionPlanContext(payload, user, slug).catch((error) => { - if (error instanceof ActionPlanNotFoundError) notFound() - throw error - }) - const view = getActionPlanDetailPageData(context) + const context = await resolveAccessibleActionPlanContext(payload, user, slug, activeTab).catch( + (error) => { + if (error instanceof ActionPlanNotFoundError) notFound() + throw error + }, + ) + const view = await getActionPlanDetailPageData(payload, user, context, activeTab) const canonicalTabRedirect = getActionPlanDetailTabRedirect(view.slug, query) if (canonicalTabRedirect) redirect(canonicalTabRedirect) @@ -159,8 +161,7 @@ export default async function ActionPlanDetailPage({

Nenhum coordenador vinculado.

)}

- {view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas - concluídas + {view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas

{view.createdByName ? (

Criado por {view.createdByName}

diff --git a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts index b1097017..e408d9cd 100644 --- a/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts +++ b/src/app/(campaign)/campanha/(app)/planos/contactSearchActions.ts @@ -3,23 +3,19 @@ import config from '@payload-config' import { getPayload } from 'payload' +import type { ContactComboboxOption } from '@/components/campaign/ContactCombobox' import { isContactSearchQueryReady, normalizeContactSearchQuery, } from '@/lib/contactSearchQuery' import { getCampaignUser } from '@/utilities/campaignAuth' - -export type ActionPlanContactOption = { - id: number - name: string - phone: string -} +import { searchActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions' const CONTACT_OPTION_LIMIT = 20 export const searchActionPlanContactOptions = async ( query: string, -): Promise => { +): Promise => { const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) if (!user) throw new Error('Autenticação necessária.') @@ -44,3 +40,12 @@ export const searchActionPlanContactOptions = async ( return result.docs.map(({ id, name, phone }) => ({ id, name, phone })) } + +export const searchActionPlanLeadershipOptionsAction = async ( + query: string, +): Promise> => { + const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()]) + if (!user) throw new Error('Autenticação necessária.') + + return searchActionPlanLeadershipOptions(payload, user, query) +} diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx index 49c9aa60..c726f33b 100644 --- a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx @@ -2,10 +2,12 @@ import config from '@payload-config' import { getPayload } from 'payload' import { redirect } from 'next/navigation' -import { searchActionPlanContactOptions } from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions' +import { + searchActionPlanContactOptions, + searchActionPlanLeadershipOptionsAction, +} from '@/app/(campaign)/campanha/(app)/planos/contactSearchActions' import { createActionPlanFormAction } from '@/app/(campaign)/campanha/(app)/planos/formActions' import { ActionPlanForm } from '@/components/campaign/ActionPlanForm' -import { getActionPlanLeadershipOptions } from '@/utilities/actionPlanLeadershipOptions' import { getCampaignUser } from '@/utilities/campaignAuth' import { getEligibleNucleusCoordinatorOptions } from '@/utilities/nucleusCoordinatorOptions' @@ -15,10 +17,7 @@ export default async function NewActionPlanPage() { if (!user) redirect('/campanha/login') if (user.role !== 'geral' && user.role !== 'coordenador') redirect('/campanha/planos') - const [coordinators, leadershipOptions] = await Promise.all([ - getEligibleNucleusCoordinatorOptions(payload, user), - getActionPlanLeadershipOptions(payload, user), - ]) + const coordinators = await getEligibleNucleusCoordinatorOptions(payload, user) return (
@@ -33,9 +32,9 @@ export default async function NewActionPlanPage() { action={createActionPlanFormAction} coordinators={coordinators} canManageCoordinators={user.role === 'geral'} - leadershipOptions={leadershipOptions} submitLabel="Criar plano" searchContacts={searchActionPlanContactOptions} + searchLeaderships={searchActionPlanLeadershipOptionsAction} />
) diff --git a/src/app/(campaign)/campanha/actions/actionPlan.ts b/src/app/(campaign)/campanha/actions/actionPlan.ts index fe4e7535..88a02089 100644 --- a/src/app/(campaign)/campanha/actions/actionPlan.ts +++ b/src/app/(campaign)/campanha/actions/actionPlan.ts @@ -116,6 +116,7 @@ export const toggleActionPlanTaskRecord = async ( depth: 0, user: currentActor, overrideAccess: false, + context: { mutationKind: 'taskToggle' }, req, }) }, @@ -159,6 +160,7 @@ export const appendActionPlanUpdateRecord = async ( depth: 0, user: currentActor, overrideAccess: false, + context: { mutationKind: 'appendUpdate' }, req, }) }, diff --git a/src/collections/ActionPlan.ts b/src/collections/ActionPlan.ts index ef419fc4..93f13f07 100644 --- a/src/collections/ActionPlan.ts +++ b/src/collections/ActionPlan.ts @@ -34,6 +34,9 @@ import { slugify } from '@/utilities/slug' const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '') +const isActionPlanMutationShortcut = (context: Record | undefined) => + context?.mutationKind === 'taskToggle' || context?.mutationKind === 'appendUpdate' + const ACTION_PLAN_KIND_OPTIONS = actionPlanKinds.map((value) => ({ value, label: actionPlanKindLabels[value], @@ -65,15 +68,22 @@ const actionPlanStaffFieldSnapshot = (doc: Record) => ({ leadership: relationshipId(doc.leadership), }) -const validateActionPlanTerritory = createCampaignTerritoryValidationHook({ +const validateActionPlanTerritoryCore = createCampaignTerritoryValidationHook({ entityLabel: 'plano', }) +const validateActionPlanTerritory: CollectionBeforeValidateHook = (args) => { + if (isActionPlanMutationShortcut(args.context)) return args.data + return validateActionPlanTerritoryCore(args) +} + const setCanonicalActionPlanSlug: CollectionBeforeValidateHook = ({ data, operation, originalDoc, + context, }) => { + if (isActionPlanMutationShortcut(context)) return data if (!data) return data const title = trimmedText(data.title ?? originalDoc?.title) const slug = slugify(title) @@ -92,7 +102,9 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({ data, operation, originalDoc, + context, }) => { + if (isActionPlanMutationShortcut(context)) return data if (!data) return data const nextData = operation === 'update' ? { ...originalDoc, ...data } : data @@ -118,7 +130,8 @@ const validateActionPlanSchedule: CollectionBeforeValidateHook = ({ return data } -const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req }) => { +const validateActionPlanCoordinators: CollectionBeforeValidateHook = async ({ data, req, context }) => { + if (isActionPlanMutationShortcut(context)) return data if (!data) return data if (data.coordinators === undefined) return data @@ -201,16 +214,18 @@ const deriveActionPlanFields: CollectionBeforeChangeHook = ({ data, operation, o } if (operation === 'update' && req.user?.collection === 'campaignUser' && req.user.role === 'lideranca') { - const previous = (originalDoc ?? {}) as Record - const merged = { ...previous, ...(data as Record) } - if ( - JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !== - JSON.stringify(actionPlanStaffFieldSnapshot(merged)) - ) { - throw new APIError( - 'Lideranças só podem marcar tarefas e registrar atualizações no plano.', - 403, - ) + if (!isActionPlanMutationShortcut(req.context)) { + const previous = (originalDoc ?? {}) as Record + const merged = { ...previous, ...(data as Record) } + if ( + JSON.stringify(actionPlanStaffFieldSnapshot(previous)) !== + JSON.stringify(actionPlanStaffFieldSnapshot(merged)) + ) { + throw new APIError( + 'Lideranças só podem marcar tarefas e registrar atualizações no plano.', + 403, + ) + } } if (Array.isArray(data.tasks) && Array.isArray(originalDoc?.tasks)) { diff --git a/src/components/campaign/ActionPlanForm.tsx b/src/components/campaign/ActionPlanForm.tsx index cd9a7bb4..a0125168 100644 --- a/src/components/campaign/ActionPlanForm.tsx +++ b/src/components/campaign/ActionPlanForm.tsx @@ -4,6 +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 { ActionPlanTaskFields } from '@/components/campaign/ActionPlanTaskFields' import { CampaignTerritoryFields } from '@/components/campaign/NucleusTerritoryFields' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' @@ -21,15 +22,16 @@ import { } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' -import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import { 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' import { formatIsoAsBahiaDateTimeInput } from '@/utilities/campaignTime' import type { NucleusCoordinatorOption } from '@/utilities/nucleusCoordinatorOptions' -import { fieldError } from '@/utilities/campaignFormFields' export type ActionPlanFormState = { message?: string @@ -43,16 +45,14 @@ type ActionPlanFormAction = ( formData: FormData, ) => Promise -export type { ActionPlanLeadershipOption } - export type ActionPlanFormFieldsProps = { coordinators?: NucleusCoordinatorOption[] canManageCoordinators?: boolean - leadershipOptions?: ActionPlanLeadershipOption[] plan?: ActionPlanFormViewModel fieldErrors?: Record submittedTitle?: string searchContacts: (query: string) => Promise + searchLeaderships: (query: string) => Promise } const editableStatuses = ['rascunho', 'planejado'] as const @@ -60,11 +60,11 @@ const editableStatuses = ['rascunho', 'planejado'] as const export const ActionPlanFormFields = ({ coordinators = [], canManageCoordinators = false, - leadershipOptions = [], plan, fieldErrors = {}, submittedTitle, searchContacts, + searchLeaderships, }: ActionPlanFormFieldsProps) => { const errorFor = (name: string) => fieldError(fieldErrors, name) const statusIsEditable = @@ -79,6 +79,9 @@ 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>( + plan?.leadership ? { id: plan.leadership.id, label: plan.leadership.label } : null, + ) return ( @@ -300,22 +303,21 @@ export const ActionPlanFormFields = ({ - Liderança vinculada - Liderança vinculada + - Nenhuma - {leadershipOptions.map((option) => ( - - {option.label} - - ))} - + 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." + /> Quando vinculada, a liderança engajada vê o plano e pode marcar tarefas e registrar atualizações. @@ -389,10 +391,10 @@ export const ActionPlanForm = ({ action, coordinators, canManageCoordinators = false, - leadershipOptions = [], plan, submitLabel, searchContacts, + searchLeaderships, }: ActionPlanFormFieldsProps & { action: ActionPlanFormAction submitLabel: string @@ -416,11 +418,11 @@ export const ActionPlanForm = ({
- - - - - {loading ? ( -
-
- ) : failed ? ( -

- Não foi possível buscar contatos. -

- ) : ( - <> - - choose(null)}> - {emptyOptionLabel} - - - {selected ? ( - - choose(selected)} - > - {contactLabel(selected, emptyOptionLabel)} - - - ) : null} - {visibleOptions.length ? ( - - {visibleOptions.map((contact) => ( - choose(contact)} - > - {contactLabel(contact, emptyOptionLabel)} - - ))} - - ) : ( -

- {!isContactSearchQueryReady(query) - ? 'Digite ao menos dois caracteres para buscar.' - : 'Nenhum contato encontrado.'} -

- )} - - )} -
-
-
- + { + const results = await search(query) + for (const contact of results) contactsById.current.set(contact.id, contact) + return results.map((contact) => ({ + id: contact.id, + label: contactLabel(contact), + })) + }} + onChange={(option) => + onChange?.(option ? (contactsById.current.get(option.id) ?? null) : null) + } + /> ) } diff --git a/src/components/campaign/LeadershipPrimaryContactAction.tsx b/src/components/campaign/LeadershipPrimaryContactAction.tsx index 3cd10090..b7d7b401 100644 --- a/src/components/campaign/LeadershipPrimaryContactAction.tsx +++ b/src/components/campaign/LeadershipPrimaryContactAction.tsx @@ -81,6 +81,11 @@ export const LeadershipPrimaryContactAction = ({ router.refresh() }, [router, state.message, state.status]) + const displayState = state.status === 'success' ? {} : state + const nucleusError = fieldError(displayState.fieldErrors, 'nucleus') + const contactError = fieldError(displayState.fieldErrors, 'contact') + const hasError = Boolean(displayState.message || nucleusError || contactError) + return (
- - ))} -
-) - export const NucleusTerritoryAndZonesFields = ({ values, fieldErrors = {}, @@ -59,260 +25,29 @@ export const NucleusTerritoryAndZonesFields = ({ values?: TerritoryAndZonesValues fieldErrors?: Record }) => { - const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? [])) - const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? [])) - const [neighborhoods, setNeighborhoods] = useState(() => - dedupeTrimmedStrings(values?.neighborhoods ?? []), - ) + const state = useCampaignTerritoryFieldsState(values) const [tseZones, setTseZones] = useState(() => sortedUniqueZoneNumbers(values?.tseZones ?? [])) - const [cityDraft, setCityDraft] = useState('') - const [regionDraft, setRegionDraft] = useState('') - const [neighborhoodDraft, setNeighborhoodDraft] = useState('') - const [regionError, setRegionError] = useState() - const [cityError, setCityError] = useState() - const [neighborhoodError, setNeighborhoodError] = useState() const errorFor = (name: string) => fieldError(fieldErrors, name) - const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) - const displayRegions = cities.length > 0 ? derivedRegions : regions - const regionsAreDerived = cities.length > 0 - const neighborhoodsEnabled = cities.length === 1 const { zoneSuggestions, citySuggestions, outsideZones } = useMemo( () => buildTerritorySuggestions({ - cities, - regions: displayRegions, + cities: state.cities, + regions: state.displayRegions, tseZones, }), - [cities, displayRegions, tseZones], + [state.cities, state.displayRegions, tseZones], ) - const visibleRegionError = regionError ?? errorFor('regions') - const visibleCityError = cityError ?? errorFor('cities') - const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods') - - 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) - } - return (
- {cities.map((city) => ( - - ))} - {!regionsAreDerived - ? regions.map((region) => ( - - )) - : null} - {neighborhoodsEnabled - ? neighborhoods.map((neighborhood) => ( - - )) - : null} - - - Territórios de identidade - {displayRegions.length ? ( - `Remover território ${region}`} - /> - ) : null} - {regionsAreDerived ? ( - - Derivados automaticamente dos municípios selecionados. Remova municípios para editar - territórios manualmente. - - ) : ( - { - if (nextRegion) addRegion(nextRegion) - else setRegionDraft('') - }} - onInvalid={() => { - setRegionError('Selecione um território de identidade válido da Bahia.') - }} - error={visibleRegionError} - /> - )} - {visibleRegionError ? ( - {visibleRegionError} - ) : null} - - - - Municípios - addCity(suggestion.city)} - /> - {cities.length ? ( - `Remover município ${city}`} - /> - ) : null} - { - if (nextCity) addCity(nextCity) - else setCityDraft('') - }} - onInvalid={() => { - setCityError('Selecione um município válido da Bahia.') - }} - error={visibleCityError} - /> - - Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só - ficam disponíveis com exatamente um município. - - {visibleCityError ? ( - {visibleCityError} - ) : null} - - - - Bairros - {neighborhoodsEnabled && neighborhoods.length ? ( - - setNeighborhoods((current) => current.filter((item) => item !== neighborhood)) - } - removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`} - /> - ) : null} - - { - 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 - ) { - setNeighborhoods((current) => current.slice(0, -1)) - } - }} - onBlur={() => commitNeighborhood(neighborhoodDraft)} - /> - - - Use Enter para adicionar. Disponível somente quando há exatamente um município. - - {visibleNeighborhoodError ? ( - {visibleNeighborhoodError} - ) : null} - + Zonas TSE diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryFields.tsx index 849d2b39..51c7fbdc 100644 --- a/src/components/campaign/NucleusTerritoryFields.tsx +++ b/src/components/campaign/NucleusTerritoryFields.tsx @@ -1,53 +1,10 @@ 'use client' -import { useMemo, useState } from 'react' -import { XIcon } from 'lucide-react' - -import { StrictCombobox } from '@/components/campaign/StrictCombobox' -import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips' -import { Badge } from '@/components/ui/Badge' -import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field' -import { InputGroup, InputGroupInput } from '@/components/ui/input-group' -import { territoriesForCities } from '@/lib/bahiaTerritories' +import { CampaignTerritoryCoreFields } from '@/components/campaign/CampaignTerritoryCoreFields' import { - dedupeTrimmedStrings, - MAX_NUCLEUS_CITIES, - MAX_NUCLEUS_NEIGHBORHOODS, - MAX_NUCLEUS_REGIONS, -} from '@/lib/schemas/nucleus' -import { buildTerritorySuggestions } from '@/lib/territorySuggestions' -import { - allMunicipalityComboboxOptions, - territoryComboboxOptions, -} from '@/utilities/territoryComboboxOptions' -import { fieldError } from '@/utilities/campaignFormFields' - -type TerritoryValues = { - regions?: string[] | null - cities?: string[] | null - neighborhoods?: string[] | null -} - -const ChipList = ({ - values, - onRemove, - removeLabel, -}: { - values: string[] - onRemove: (value: string) => void - removeLabel: (value: string) => string -}) => ( -
- {values.map((value) => ( - - - - ))} -
-) + useCampaignTerritoryFieldsState, + type CampaignTerritoryValues, +} from '@/components/campaign/useCampaignTerritoryFieldsState' /** * Territory-only fields (regions/cities/neighborhoods) shared by any campaign @@ -59,262 +16,14 @@ export const CampaignTerritoryFields = ({ values, fieldErrors = {}, }: { - values?: TerritoryValues + values?: CampaignTerritoryValues fieldErrors?: Record }) => { - 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() - const [cityError, setCityError] = useState() - const [neighborhoodError, setNeighborhoodError] = useState() - - const errorFor = (name: string) => fieldError(fieldErrors, name) - const derivedRegions = useMemo(() => territoriesForCities(cities), [cities]) - const displayRegions = cities.length > 0 ? derivedRegions : regions - const regionsAreDerived = cities.length > 0 - const neighborhoodsEnabled = cities.length === 1 - - const { citySuggestions } = useMemo( - () => - buildTerritorySuggestions({ - cities, - regions: displayRegions, - tseZones: [], - }), - [cities, displayRegions], - ) - - const visibleRegionError = regionError ?? errorFor('regions') - const visibleCityError = cityError ?? errorFor('cities') - const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods') - - 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 state = useCampaignTerritoryFieldsState(values) return (
- {cities.map((city) => ( - - ))} - {!regionsAreDerived - ? regions.map((region) => ( - - )) - : null} - {neighborhoodsEnabled - ? neighborhoods.map((neighborhood) => ( - - )) - : null} - - - Territórios de identidade - {displayRegions.length ? ( - `Remover território ${region}`} - /> - ) : null} - {regionsAreDerived ? ( - - Derivados automaticamente dos municípios selecionados. Remova municípios para editar - territórios manualmente. - - ) : ( - { - if (nextRegion) addRegion(nextRegion) - else setRegionDraft('') - }} - onInvalid={() => { - setRegionError('Selecione um território de identidade válido da Bahia.') - }} - error={visibleRegionError} - /> - )} - {visibleRegionError ? ( - {visibleRegionError} - ) : null} - - - - Municípios - addCity(suggestion.city)} - /> - {cities.length ? ( - `Remover município ${city}`} - /> - ) : null} - { - if (nextCity) addCity(nextCity) - else setCityDraft('') - }} - onInvalid={() => { - setCityError('Selecione um município válido da Bahia.') - }} - error={visibleCityError} - /> - - Selecione um ou mais municípios. Territórios são derivados automaticamente; bairros só - ficam disponíveis com exatamente um município. - - {visibleCityError ? ( - {visibleCityError} - ) : null} - - - - Bairros - {neighborhoodsEnabled && neighborhoods.length ? ( - - setNeighborhoods((current) => current.filter((item) => item !== neighborhood)) - } - removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`} - /> - ) : null} - - { - 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 - ) { - setNeighborhoods((current) => current.slice(0, -1)) - } - }} - onBlur={() => commitNeighborhood(neighborhoodDraft)} - /> - - - Use Enter para adicionar. Disponível somente quando há exatamente um município. - - {visibleNeighborhoodError ? ( - {visibleNeighborhoodError} - ) : null} - +
) } diff --git a/src/components/campaign/PrimaryContactCombobox.tsx b/src/components/campaign/PrimaryContactCombobox.tsx index 1ba685a4..92f2bd1b 100644 --- a/src/components/campaign/PrimaryContactCombobox.tsx +++ b/src/components/campaign/PrimaryContactCombobox.tsx @@ -1,18 +1,8 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { ChevronsUpDownIcon } from 'lucide-react' +import { useRef } from 'react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandDialog, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/Command' -import { Spinner } from '@/components/ui/Spinner' +import { AsyncSearchCombobox } from '@/components/campaign/AsyncSearchCombobox' import type { NucleusPrimaryContactPageData, PrimaryContactOption, @@ -24,136 +14,36 @@ type PrimaryContactComboboxProps = { search: (query: string) => Promise } -const contactLabel = (contact: PrimaryContactOption | null) => - contact ? `${contact.name} · ${contact.phone}` : 'Sem contato principal' +const contactLabel = (contact: PrimaryContactOption) => + `${contact.name} · ${contact.phone}` export const PrimaryContactCombobox = ({ name, current, search, }: PrimaryContactComboboxProps) => { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [selected, setSelected] = useState(current) - const [result, setResult] = useState({ - current, - options: [], - }) - const [loading, setLoading] = useState(false) - const [failed, setFailed] = useState(false) - const requestId = useRef(0) - - useEffect(() => { - if (!open) return - - const currentRequestId = ++requestId.current - const timeout = window.setTimeout(() => { - setLoading(true) - setFailed(false) - void search(query) - .then((nextResult) => { - if (requestId.current !== currentRequestId) return - setResult({ - current: nextResult.current, - options: nextResult.options.slice(0, nextResult.current ? 99 : 100), - }) - }) - .catch(() => { - if (requestId.current !== currentRequestId) return - setFailed(true) - }) - .finally(() => { - if (requestId.current === currentRequestId) setLoading(false) - }) - }, 250) - - return () => window.clearTimeout(timeout) - }, [open, query, search]) - - const choose = (contact: PrimaryContactOption | null) => { - setSelected(contact) - setOpen(false) - setQuery('') - } + const contactsById = useRef(new Map()) + if (current) contactsById.current.set(current.id, current) return ( - <> - - - - - - - {loading ? ( -
-
- ) : failed ? ( -

- Não foi possível buscar as lideranças. -

- ) : ( - <> - - choose(null)}> - Sem contato principal - - - {result.current ? ( - - choose(result.current)} - > - {contactLabel(result.current)} - - - ) : null} - {result.options.length ? ( - - {result.options.map((contact) => ( - choose(contact)} - > - {contactLabel(contact)} - - ))} - - ) : ( -

- Nenhuma liderança encontrada. -

- )} - - )} -
-
-
- + { + 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, + label: contactLabel(contact), + })) + }} + pinnedOptions={current ? [{ id: current.id, label: contactLabel(current) }] : []} + pinnedGroupHeading="Contato atual" + /> ) } diff --git a/src/utilities/actionPlanLeadershipOptions.ts b/src/utilities/actionPlanLeadershipOptions.ts index 40d3f86b..22584258 100644 --- a/src/utilities/actionPlanLeadershipOptions.ts +++ b/src/utilities/actionPlanLeadershipOptions.ts @@ -2,6 +2,10 @@ import 'server-only' import type { Payload } from 'payload' +import { + isContactSearchQueryReady, + normalizeContactSearchQuery, +} from '@/lib/contactSearchQuery' import type { CampaignUser, Contact, Leadership } from '@/payload-types' import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship' @@ -10,19 +14,44 @@ export type ActionPlanLeadershipOption = { label: string } +const LEADERSHIP_SEARCH_LIMIT = 20 + +const leadershipLabel = (leadership: Leadership): string | null => { + const id = relationshipId(leadership.id) + if (!id) return null + const contact = leadership.contact + return isPopulatedRelationship(contact) ? contact.name : `Liderança #${id}` +} + /** * Engaged leaderships visible to the actor (for linking a plan to a leadership). * Uses overrideAccess: false so Payload row-level access applies. */ -export const getActionPlanLeadershipOptions = async ( +export const searchActionPlanLeadershipOptions = async ( payload: Pick, user: CampaignUser, + query: string, ): Promise => { + const normalizedQuery = typeof query === 'string' ? query : '' + if (!isContactSearchQueryReady(normalizedQuery)) return [] + + const { trimmed, digits } = normalizeContactSearchQuery(normalizedQuery) + const result = await payload.find({ collection: 'leadership', - where: { supportStatus: { equals: 'engajado' } }, + where: { + and: [ + { supportStatus: { equals: 'engajado' } }, + { + or: [ + { 'contact.name': { contains: trimmed } }, + { 'contact.phone': { contains: digits || trimmed } }, + ], + }, + ], + }, depth: 1, - limit: 200, + limit: LEADERSHIP_SEARCH_LIMIT, sort: 'id', select: { contact: true, @@ -36,11 +65,8 @@ export const getActionPlanLeadershipOptions = async ( .map((doc) => { const leadership = doc as Leadership const id = relationshipId(leadership.id) - if (!id) return null - const contact = leadership.contact - const label = isPopulatedRelationship(contact) - ? contact.name - : `Liderança #${id}` + const label = leadershipLabel(leadership) + if (!id || !label) return null return { id, label } }) .filter((option): option is ActionPlanLeadershipOption => option !== null) diff --git a/src/utilities/actionPlanPageData.ts b/src/utilities/actionPlanPageData.ts index 8a7d2247..4b382ff3 100644 --- a/src/utilities/actionPlanPageData.ts +++ b/src/utilities/actionPlanPageData.ts @@ -1,11 +1,11 @@ import type { Payload } from 'payload' import type { ActionPlan, CampaignUser } from '@/payload-types' +import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi' import { - actionPlanDetailSelect, actionPlanFormSelect, actionPlanListSelect, - toActionPlanDetailViewModel, + getActionPlanDetailSelect, toActionPlanFormViewModel, } from '@/utilities/actionPlanViewModels' import { @@ -52,16 +52,20 @@ export const loadActionPlanListPageData = async ( return { result, state } } +const getActionPlanDetailQueryDepth = (activeTab: ActionPlanDetailTab): number => + activeTab === 'updates' ? 0 : 1 + const loadAccessibleActionPlanBySlug = async ( payload: Pick, user: CampaignUser, planSlug: string, - select: typeof actionPlanDetailSelect | typeof actionPlanFormSelect, + select: ReturnType | typeof actionPlanFormSelect, + depth: number, ): Promise => { const result = await payload.find({ collection: 'actionPlan', where: { slug: { equals: planSlug } }, - depth: 1, + depth, limit: 1, pagination: false, select, @@ -77,12 +81,14 @@ export const resolveAccessibleActionPlanContext = async ( payload: Pick, user: CampaignUser, planSlug: string, + activeTab: ActionPlanDetailTab = 'overview', ): Promise => { const document = await loadAccessibleActionPlanBySlug( payload, user, planSlug, - actionPlanDetailSelect, + getActionPlanDetailSelect(activeTab), + getActionPlanDetailQueryDepth(activeTab), ) return { @@ -92,14 +98,11 @@ export const resolveAccessibleActionPlanContext = async ( } } -export const getActionPlanDetailPageData = (context: AccessibleActionPlanContext) => - toActionPlanDetailViewModel(context.document) - export const getActionPlanEditPageData = async ( payload: Payload, user: CampaignUser, planSlug: string, ) => toActionPlanFormViewModel( - await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect), + await loadAccessibleActionPlanBySlug(payload, user, planSlug, actionPlanFormSelect, 1), ) diff --git a/src/utilities/actionPlanViewModels.ts b/src/utilities/actionPlanViewModels.ts index a05b48a0..21b5ccb4 100644 --- a/src/utilities/actionPlanViewModels.ts +++ b/src/utilities/actionPlanViewModels.ts @@ -1,4 +1,5 @@ import type { ActionPlan, CampaignUser, Contact } from '@/payload-types' +import type { ActionPlanDetailTab } from '@/utilities/actionPlanDetailTabUi' import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship' const asStringArray = (value: string[] | null | undefined): string[] => @@ -175,7 +176,7 @@ export const toActionPlanFormViewModel = (plan: ActionPlan): ActionPlanFormViewM })), }) -export const actionPlanDetailSelect = { +export const actionPlanDetailContextSelect = { title: true, slug: true, kind: true, @@ -191,13 +192,31 @@ export const actionPlanDetailSelect = { territoryNotes: true, coordinators: true, responsible: true, - tasks: true, - updates: true, createdBy: true, updatedAt: true, createdAt: true, } as const +export const getActionPlanDetailSelect = (activeTab: ActionPlanDetailTab) => { + if (activeTab === 'overview') { + return { + ...actionPlanDetailContextSelect, + taskDoneCount: true, + taskTotal: true, + } as const + } + if (activeTab === 'tasks') { + return { + ...actionPlanDetailContextSelect, + tasks: true, + } as const + } + return { + ...actionPlanDetailContextSelect, + updates: true, + } as const +} + export type ActionPlanTaskViewModel = { id: string | null title: string @@ -234,6 +253,7 @@ export type ActionPlanDetailViewModel = { coordinators: Array<{ id: number; name: string }> responsibleId: number | null responsibleName: string | null + taskProgress: { done: number; total: number } tasks: ActionPlanTaskViewModel[] updates: ActionPlanUpdateViewModel[] createdByName: string | null @@ -241,7 +261,38 @@ export type ActionPlanDetailViewModel = { createdAt: string } -export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailViewModel => ({ +const mapActionPlanTasks = (plan: ActionPlan): ActionPlanTaskViewModel[] => + (plan.tasks ?? []).map((task) => ({ + id: task.id ?? null, + title: task.title, + responsibleId: relationshipId(task.responsible), + responsibleName: relationshipName(task.responsible), + due: task.due ?? null, + done: Boolean(task.done), + doneAt: task.doneAt ?? null, + })) + +export const mapActionPlanUpdates = ( + plan: ActionPlan, + authorNamesById: ReadonlyMap = new Map(), +): ActionPlanUpdateViewModel[] => + (plan.updates ?? []).map((update) => { + const authorId = relationshipId(update.author) + const populatedName = relationshipName(update.author) + return { + id: update.id ?? null, + body: update.body, + authorName: + populatedName ?? (authorId ? (authorNamesById.get(authorId) ?? null) : null), + createdAt: update.createdAt ?? null, + } + }) + +export const toActionPlanDetailViewModel = ( + plan: ActionPlan, + activeTab: ActionPlanDetailTab = 'overview', + authorNamesById: ReadonlyMap = new Map(), +): ActionPlanDetailViewModel => ({ id: plan.id, title: plan.title, slug: plan.slug, @@ -265,21 +316,13 @@ export const toActionPlanDetailViewModel = (plan: ActionPlan): ActionPlanDetailV .map(({ id, name }) => ({ id, name })) ?? [], responsibleId: relationshipId(plan.responsible), responsibleName: relationshipName(plan.responsible), - tasks: (plan.tasks ?? []).map((task) => ({ - id: task.id ?? null, - title: task.title, - responsibleId: relationshipId(task.responsible), - responsibleName: relationshipName(task.responsible), - due: task.due ?? null, - done: Boolean(task.done), - doneAt: task.doneAt ?? null, - })), - updates: (plan.updates ?? []).map((update) => ({ - id: update.id ?? null, - body: update.body, - authorName: relationshipName(update.author), - createdAt: update.createdAt ?? null, - })), + taskProgress: { + done: plan.taskDoneCount ?? 0, + total: plan.taskTotal ?? 0, + }, + tasks: activeTab === 'tasks' ? mapActionPlanTasks(plan) : [], + updates: + activeTab === 'updates' ? mapActionPlanUpdates(plan, authorNamesById) : [], createdByName: relationshipName(plan.createdBy), updatedAt: plan.updatedAt, createdAt: plan.createdAt,