mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Deliver C7 scale/DRY for action plans: territory composition, tab-scoped reads, and hook shortcuts.
This commit is contained in:
parent
cc66ec3c31
commit
15c756bb10
18 changed files with 301 additions and 991 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):** 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<br/>(import/listas/forms) ✓"]
|
||||
C3["C3 Eventos / agenda de mobilização ✓"]
|
||||
C7["C7 Escala e DRY pós-C3<br/>(parcial: contadores/locks/FormData)"]
|
||||
C7["C7 Escala e DRY pós-C3<br/>(território/contato/leituras/hooks) ✓"]
|
||||
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)"]
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<p className="text-muted-foreground">Nenhum coordenador vinculado.</p>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
{view.tasks.filter((task) => task.done).length}/{view.tasks.length} tarefas
|
||||
concluídas
|
||||
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
|
||||
</p>
|
||||
{view.createdByName ? (
|
||||
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
|
||||
|
|
|
|||
|
|
@ -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<ActionPlanContactOption[]> => {
|
||||
): Promise<ContactComboboxOption[]> => {
|
||||
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<Array<{ id: number; label: string }>> => {
|
||||
const [payload, user] = await Promise.all([getPayload({ config }), getCampaignUser()])
|
||||
if (!user) throw new Error('Autenticação necessária.')
|
||||
|
||||
return searchActionPlanLeadershipOptions(payload, user, query)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import { slugify } from '@/utilities/slug'
|
|||
|
||||
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
const isActionPlanMutationShortcut = (context: Record<string, unknown> | 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<string, unknown>) => ({
|
|||
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<string, unknown>
|
||||
const merged = { ...previous, ...(data as Record<string, unknown>) }
|
||||
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<string, unknown>
|
||||
const merged = { ...previous, ...(data as Record<string, unknown>) }
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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<ActionPlanFormState>
|
||||
|
||||
export type { ActionPlanLeadershipOption }
|
||||
|
||||
export type ActionPlanFormFieldsProps = {
|
||||
coordinators?: NucleusCoordinatorOption[]
|
||||
canManageCoordinators?: boolean
|
||||
leadershipOptions?: ActionPlanLeadershipOption[]
|
||||
plan?: ActionPlanFormViewModel
|
||||
fieldErrors?: Record<string, string[]>
|
||||
submittedTitle?: string
|
||||
searchContacts: (query: string) => Promise<ContactComboboxOption[]>
|
||||
searchLeaderships: (query: string) => Promise<ActionPlanLeadershipOption[]>
|
||||
}
|
||||
|
||||
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 (
|
||||
<FieldGroup>
|
||||
|
|
@ -300,22 +303,21 @@ export const ActionPlanFormFields = ({
|
|||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('leadership'))}>
|
||||
<FieldLabel htmlFor="leadership">Liderança vinculada</FieldLabel>
|
||||
<NativeSelect
|
||||
id="leadership"
|
||||
<FieldLabel>Liderança vinculada</FieldLabel>
|
||||
<AsyncSearchCombobox
|
||||
name="leadership"
|
||||
defaultValue={plan?.leadership?.id ? String(plan.leadership.id) : ''}
|
||||
aria-invalid={Boolean(errorFor('leadership'))}
|
||||
aria-describedby={errorFor('leadership') ? 'leadership-error' : undefined}
|
||||
className="w-full **:data-[slot=native-select]:min-h-11"
|
||||
>
|
||||
<NativeSelectOption value="">Nenhuma</NativeSelectOption>
|
||||
{leadershipOptions.map((option) => (
|
||||
<NativeSelectOption key={option.id} value={String(option.id)}>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
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."
|
||||
/>
|
||||
<FieldDescription>
|
||||
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 = ({
|
|||
<ActionPlanFormFields
|
||||
coordinators={coordinators}
|
||||
canManageCoordinators={canManageCoordinators}
|
||||
leadershipOptions={leadershipOptions}
|
||||
plan={plan}
|
||||
fieldErrors={state.fieldErrors}
|
||||
submittedTitle={state.submittedTitle}
|
||||
searchContacts={searchContacts}
|
||||
searchLeaderships={searchLeaderships}
|
||||
/>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
|
|
|
|||
|
|
@ -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 { isContactSearchQueryReady } from '@/lib/contactSearchQuery'
|
||||
|
||||
export type ContactComboboxOption = {
|
||||
|
|
@ -30,8 +20,8 @@ type ContactComboboxProps = {
|
|||
onChange?: (contact: ContactComboboxOption | null) => void
|
||||
}
|
||||
|
||||
const contactLabel = (contact: ContactComboboxOption | null, emptyLabel: string): string =>
|
||||
contact ? `${contact.name} · ${contact.phone}` : emptyLabel
|
||||
const contactLabel = (contact: ContactComboboxOption): string =>
|
||||
`${contact.name} · ${contact.phone}`
|
||||
|
||||
export const ContactCombobox = ({
|
||||
name,
|
||||
|
|
@ -41,132 +31,31 @@ export const ContactCombobox = ({
|
|||
emptyOptionLabel = 'Nenhum responsável',
|
||||
onChange,
|
||||
}: ContactComboboxProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState<ContactComboboxOption | null>(current)
|
||||
const [options, setOptions] = useState<ContactComboboxOption[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const requestId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!isContactSearchQueryReady(query)) {
|
||||
setOptions([])
|
||||
setLoading(false)
|
||||
setFailed(false)
|
||||
return
|
||||
}
|
||||
|
||||
const currentRequestId = ++requestId.current
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLoading(true)
|
||||
setFailed(false)
|
||||
void search(query.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, search])
|
||||
|
||||
const choose = (contact: ContactComboboxOption | null) => {
|
||||
setSelected(contact)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
onChange?.(contact)
|
||||
}
|
||||
|
||||
const visibleOptions = options.filter((option) => option.id !== selected?.id)
|
||||
const contactsById = useRef(new Map<number, ContactComboboxOption>())
|
||||
if (current) contactsById.current.set(current.id, current)
|
||||
|
||||
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}: ${contactLabel(selected, emptyOptionLabel)}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="truncate">{contactLabel(selected, emptyOptionLabel)}</span>
|
||||
<ChevronsUpDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={label}
|
||||
description="Busque por nome ou celular."
|
||||
showCloseButton
|
||||
>
|
||||
<Command shouldFilter={false} label="Buscar contato por nome ou celular">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar por nome ou celular"
|
||||
aria-label="Buscar contato por nome ou celular"
|
||||
/>
|
||||
<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 buscar contatos.
|
||||
</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">{contactLabel(selected, emptyOptionLabel)}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{visibleOptions.length ? (
|
||||
<CommandGroup heading="Contatos">
|
||||
{visibleOptions.map((contact) => (
|
||||
<CommandItem
|
||||
key={contact.id}
|
||||
value={`contact-${contact.id}`}
|
||||
onSelect={() => choose(contact)}
|
||||
>
|
||||
<span className="truncate">{contactLabel(contact, emptyOptionLabel)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{!isContactSearchQueryReady(query)
|
||||
? 'Digite ao menos dois caracteres para buscar.'
|
||||
: 'Nenhum contato encontrado.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
<AsyncSearchCombobox
|
||||
name={name}
|
||||
label={label}
|
||||
value={
|
||||
current ? { id: current.id, label: contactLabel(current) } : null
|
||||
}
|
||||
emptyOptionLabel={emptyOptionLabel}
|
||||
dialogDescription="Busque por nome ou celular."
|
||||
isQueryReady={isContactSearchQueryReady}
|
||||
queryTooShortMessage="Digite ao menos dois caracteres para buscar."
|
||||
search={async (query) => {
|
||||
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)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<form
|
||||
action={action}
|
||||
|
|
@ -89,7 +94,7 @@ export const LeadershipPrimaryContactAction = ({
|
|||
<PrimaryContactFormFields
|
||||
nucleusId={nucleusId}
|
||||
contactId={contactId}
|
||||
state={state.status === 'success' ? {} : state}
|
||||
state={displayState}
|
||||
/>
|
||||
<Button type="submit" variant="outline" className="min-h-11 w-full" disabled={pending}>
|
||||
{pending ? (
|
||||
|
|
|
|||
|
|
@ -1,57 +1,23 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { XIcon } from 'lucide-react'
|
||||
|
||||
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
|
||||
import { CampaignTerritoryCoreFields } from '@/components/campaign/CampaignTerritoryCoreFields'
|
||||
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
|
||||
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
|
||||
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 {
|
||||
dedupeTrimmedStrings,
|
||||
MAX_NUCLEUS_CITIES,
|
||||
MAX_NUCLEUS_NEIGHBORHOODS,
|
||||
MAX_NUCLEUS_REGIONS,
|
||||
} from '@/lib/schemas/nucleus'
|
||||
useCampaignTerritoryFieldsState,
|
||||
type CampaignTerritoryValues,
|
||||
} from '@/components/campaign/useCampaignTerritoryFieldsState'
|
||||
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
|
||||
import {
|
||||
allMunicipalityComboboxOptions,
|
||||
territoryComboboxOptions,
|
||||
} from '@/utilities/territoryComboboxOptions'
|
||||
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
|
||||
import { fieldError } from '@/utilities/campaignFormFields'
|
||||
|
||||
type TerritoryAndZonesValues = {
|
||||
regions?: string[] | null
|
||||
cities?: string[] | null
|
||||
neighborhoods?: string[] | null
|
||||
type TerritoryAndZonesValues = CampaignTerritoryValues & {
|
||||
tseZones?: number[] | null
|
||||
}
|
||||
|
||||
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 NucleusTerritoryAndZonesFields = ({
|
||||
values,
|
||||
fieldErrors = {},
|
||||
|
|
@ -59,260 +25,29 @@ export const NucleusTerritoryAndZonesFields = ({
|
|||
values?: TerritoryAndZonesValues
|
||||
fieldErrors?: Record<string, string[]>
|
||||
}) => {
|
||||
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<string>()
|
||||
const [cityError, setCityError] = useState<string>()
|
||||
const [neighborhoodError, setNeighborhoodError] = useState<string>()
|
||||
|
||||
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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{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="nucleus-lookup-a">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="nucleus-lookup-a"
|
||||
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="nucleus-lookup-a-error">{visibleRegionError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="nucleus-lookup-b">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="nucleus-lookup-b"
|
||||
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="nucleus-lookup-b-error">{visibleCityError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-disabled={!neighborhoodsEnabled}
|
||||
data-invalid={Boolean(visibleNeighborhoodError)}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<FieldLabel htmlFor="neighborhoodDraft">Bairros</FieldLabel>
|
||||
{neighborhoodsEnabled && neighborhoods.length ? (
|
||||
<ChipList
|
||||
values={neighborhoods}
|
||||
onRemove={(neighborhood) =>
|
||||
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
|
||||
}
|
||||
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
|
||||
/>
|
||||
) : null}
|
||||
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
|
||||
<InputGroupInput
|
||||
id="neighborhoodDraft"
|
||||
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 ? 'neighborhoods-error' : '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
|
||||
) {
|
||||
setNeighborhoods((current) => current.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitNeighborhood(neighborhoodDraft)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FieldDescription id="neighborhoods-description">
|
||||
Use Enter para adicionar. Disponível somente quando há exatamente um município.
|
||||
</FieldDescription>
|
||||
{visibleNeighborhoodError ? (
|
||||
<FieldError id="neighborhoods-error">{visibleNeighborhoodError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<CampaignTerritoryCoreFields
|
||||
state={state}
|
||||
idPrefix="nucleus"
|
||||
fieldErrors={fieldErrors}
|
||||
citySuggestions={citySuggestions}
|
||||
/>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('tseZones'))} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="tseZoneDraft">Zonas TSE</FieldLabel>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => (
|
||||
<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>
|
||||
)
|
||||
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<string, string[]>
|
||||
}) => {
|
||||
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 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 (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{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="plan-lookup-a">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="plan-lookup-a"
|
||||
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="plan-lookup-a-error">{visibleRegionError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="plan-lookup-b">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="plan-lookup-b"
|
||||
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="plan-lookup-b-error">{visibleCityError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
data-disabled={!neighborhoodsEnabled}
|
||||
data-invalid={Boolean(visibleNeighborhoodError)}
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<FieldLabel htmlFor="plan-neighborhoodDraft">Bairros</FieldLabel>
|
||||
{neighborhoodsEnabled && neighborhoods.length ? (
|
||||
<ChipList
|
||||
values={neighborhoods}
|
||||
onRemove={(neighborhood) =>
|
||||
setNeighborhoods((current) => current.filter((item) => item !== neighborhood))
|
||||
}
|
||||
removeLabel={(neighborhood) => `Remover bairro ${neighborhood}`}
|
||||
/>
|
||||
) : null}
|
||||
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
|
||||
<InputGroupInput
|
||||
id="plan-neighborhoodDraft"
|
||||
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 ? 'plan-neighborhoods-error' : 'plan-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
|
||||
) {
|
||||
setNeighborhoods((current) => current.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitNeighborhood(neighborhoodDraft)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<FieldDescription id="plan-neighborhoods-description">
|
||||
Use Enter para adicionar. Disponível somente quando há exatamente um município.
|
||||
</FieldDescription>
|
||||
{visibleNeighborhoodError ? (
|
||||
<FieldError id="plan-neighborhoods-error">{visibleNeighborhoodError}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<CampaignTerritoryCoreFields state={state} idPrefix="plan" fieldErrors={fieldErrors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NucleusPrimaryContactPageData>
|
||||
}
|
||||
|
||||
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<PrimaryContactOption | null>(current)
|
||||
const [result, setResult] = useState<NucleusPrimaryContactPageData>({
|
||||
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<number, PrimaryContactOption>())
|
||||
if (current) contactsById.current.set(current.id, current)
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="hidden" name={name} value={selected?.id ?? ''} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full justify-between font-normal"
|
||||
aria-label={`Contato principal: ${selected?.name ?? 'nenhum'}`}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="truncate">{contactLabel(selected)}</span>
|
||||
<ChevronsUpDownIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Selecionar contato principal"
|
||||
description="Busque entre as lideranças engajadas deste núcleo."
|
||||
showCloseButton
|
||||
>
|
||||
<Command shouldFilter={false} label="Buscar liderança por nome ou celular">
|
||||
<CommandInput
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar por nome ou celular"
|
||||
aria-label="Buscar liderança por nome ou celular"
|
||||
/>
|
||||
<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 buscar as lideranças.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<CommandGroup heading="Seleção">
|
||||
<CommandItem value="none" onSelect={() => choose(null)}>
|
||||
Sem contato principal
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{result.current ? (
|
||||
<CommandGroup heading="Contato atual">
|
||||
<CommandItem
|
||||
value={`current-${result.current.id}`}
|
||||
data-checked={selected?.id === result.current.id}
|
||||
onSelect={() => choose(result.current)}
|
||||
>
|
||||
<span className="truncate">{contactLabel(result.current)}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
{result.options.length ? (
|
||||
<CommandGroup heading="Lideranças engajadas">
|
||||
{result.options.map((contact) => (
|
||||
<CommandItem
|
||||
key={contact.id}
|
||||
value={`contact-${contact.id}`}
|
||||
data-checked={selected?.id === contact.id}
|
||||
onSelect={() => choose(contact)}
|
||||
>
|
||||
<span className="truncate">{contactLabel(contact)}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhuma liderança encontrada.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
<AsyncSearchCombobox
|
||||
name={name}
|
||||
label="Selecionar contato principal"
|
||||
value={current ? { id: current.id, label: contactLabel(current) } : null}
|
||||
emptyOptionLabel="Sem contato principal"
|
||||
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,
|
||||
label: contactLabel(contact),
|
||||
}))
|
||||
}}
|
||||
pinnedOptions={current ? [{ id: current.id, label: contactLabel(current) }] : []}
|
||||
pinnedGroupHeading="Contato atual"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ? 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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
query: string,
|
||||
): Promise<ActionPlanLeadershipOption[]> => {
|
||||
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)
|
||||
? contact.name
|
||||
: `Liderança #${id}`
|
||||
const label = leadershipLabel(leadership)
|
||||
if (!id || !label) return null
|
||||
return { id, label }
|
||||
})
|
||||
.filter((option): option is ActionPlanLeadershipOption => option !== null)
|
||||
|
|
|
|||
|
|
@ -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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
planSlug: string,
|
||||
select: typeof actionPlanDetailSelect | typeof actionPlanFormSelect,
|
||||
select: ReturnType<typeof getActionPlanDetailSelect> | typeof actionPlanFormSelect,
|
||||
depth: number,
|
||||
): Promise<ActionPlan> => {
|
||||
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<Payload, 'find'>,
|
||||
user: CampaignUser,
|
||||
planSlug: string,
|
||||
activeTab: ActionPlanDetailTab = 'overview',
|
||||
): Promise<AccessibleActionPlanContext> => {
|
||||
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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<number, string> = 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<number, string> = 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue