mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Simplify C2 supporter paths and register C6 scale follow-ups.
Tighten import/create view models after review, and capture the deferred DRY/import-scale work as roadmap item C6.
This commit is contained in:
parent
b079daa9bb
commit
a18e2f15f5
13 changed files with 202 additions and 88 deletions
|
|
@ -35,6 +35,7 @@ alwaysApply: false
|
|||
- **D1 PWA** — manifest + SW servidos por route handlers sob `/campanha` (script em `campaignPwa.ts`), cache `campanha-<buildId>` com `skipWaiting`/`claim`, navegação network-first com fallback `/campanha/offline`, RSC/Flight e `/campanha/convite/*` jamais cacheados; ícones em `public/campaign-icons/`; `InstallPwaToast` (Android `beforeinstallprompt`, iOS Drawer, dispensa em `sessionStorage`); logout limpa Cache API no cliente antes da action.
|
||||
- **A3 Baseline TSE 2022 Fase 1** — collections `electionTally`/`electionCandidateVote`/`electionCandidate` (grupo `Dados Eleitorais`, leitura para `campaignUser`/`users` via `canReadElectionData`, escrita admin-only), migration `20260718_195854_add_election_results`, seed local `pnpm db:seed:tse` (idempotente por escopo year/office/turn, delete+bulk insert via drizzle, guard de banco local). Sem PII (sem CPF/título); identidade cross-ano por `identityKey` hash. UI fica no A4.
|
||||
- **Em 2026-07-18, C2 Cadastro nominal de apoiadores — engenharia pronta (pendente merge/deploy + lote jurídico).** 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 `apoiador-cadastro` / `apoiador-intencao-voto` via `campaignConsent.ts` genérico, actions (create / intenção / import CSV só `geral` / remover dados), UI `/campanha/apoiadores` (lista+KPIs, ficha, wizard import, kit mínimo `wa.me`). Telefone obrigatório; `lideranca` sem acesso à área. Produção com dados reais espera Consent keys + aprovação jurídica (Onda 0).
|
||||
- **Em 2026-07-18, C6 Escala e DRY pós-C2 registrado no roadmap** (após `/simplify` do C2): shells compartilhados com listas de núcleos, import bulk drizzle + skip phone-invariant, preview token, KPI agregado SQL, StrictCombobox/AlertDialog. Plano: [`docs/plans/escala-dry-pos-c2.md`](../../../docs/plans/escala-dry-pos-c2.md). Depende do merge de C2; fases 3–5 cortáveis se a base permanecer pequena.
|
||||
|
||||
## Decisões
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ Componentes:
|
|||
- Uso da base no dia D — C5.
|
||||
- Campo `supportLevel` — fora do v1.
|
||||
- Mesclar `leadership` em `supporter` — `leadership` segue responsável pelo acesso ao app.
|
||||
- **Escala/DRY pós-v1** (shells compartilhados com núcleos, import bulk drizzle, preview token, KPI agregado, StrictCombobox/AlertDialog) — item **C6**, plano [escala-dry-pos-c2.md](escala-dry-pos-c2.md).
|
||||
|
||||
## Bloqueador obrigatório de produção
|
||||
|
||||
|
|
@ -156,6 +157,7 @@ Antes de importar dados reais ou capturar intenção de voto: a assessoria jurí
|
|||
## Revisões
|
||||
|
||||
- **2026-07-18:** auditoria pré-implementação C2. Corrigidas refs defasadas (`campaignConsent.ts` em vez de `campaignInvite.ts` / `supporterConsent.ts`; `contactPhoneInvariant.ts` em vez de `supporterDedup.ts`). Decisões fechadas: telefone obrigatório; kit mínimo no v1; enum de intenção; coexistência com leadership; import só `geral`; `supportLevel` fora do v1; `removeSupporterData` com anonimização condicionada a outros joins.
|
||||
- **2026-07-18 (pós-simplify):** follow-ups de escala/DRY adiados do v1 registrados como **C6** ([escala-dry-pos-c2.md](escala-dry-pos-c2.md)).
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
122
docs/plans/escala-dry-pos-c2.md
Normal file
122
docs/plans/escala-dry-pos-c2.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Escala e DRY pós-C2 (apoiadores + listas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-18
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha C, item C6)
|
||||
Responsável: —
|
||||
|
||||
## Contexto
|
||||
|
||||
O C2 ([cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md)) entregou a engenharia do cadastro nominal: collection `supporter`, actions de create/intenção/import/remover, UI em `/campanha/apoiadores`. A terceira passagem de `/simplify` (2026-07-18) limpou dead code e churns baratos, mas **deixou de fora** refactors e ganhos de escala que os revisores marcaram como importantes e fora do escopo de “cleanup pontual”:
|
||||
|
||||
1. Extrair shells compartilhados com a lista de núcleos (`nucleusUi` / `NucleusFilters` / `NucleusPagination`).
|
||||
2. Unificar `fieldError` / mapeador de erro de form actions (hoje clone de leadership).
|
||||
3. Import CSV em massa com insert via drizzle + bypass do invariante de telefone quando o caller já segura os locks.
|
||||
4. Preview/confirm sem round-trip de até 5k linhas na action.
|
||||
5. KPIs da lista com um único agregado SQL em vez de 3× `count`.
|
||||
6. UX alinhada: `StrictCombobox` no município do create e `AlertDialog` no “Remover dados”.
|
||||
|
||||
Este plano é o registro canônico desses follow-ups. Sem ele, a base nominal funciona para volume baixo, mas o import de 5k linhas e o DRY com núcleos ficam como dívida implícita.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Listas de núcleos e apoiadores compartilham primitivos de URL/paginação/filtro e helpers de form error, sem regressão nas rotas já entregues.
|
||||
- Import CSV de até `MAX_IMPORT_ROWS` (5000) completa dentro do timeout Vercel/DB, com locks de telefone corretos e sem N× hooks redundantes em `Contact`.
|
||||
- Preview/confirm não serializam o conjunto completo `ok` duas vezes pela fronteira da Server Action.
|
||||
- Overview de apoiadores: um round-trip de agregação (ou reuso de `totalDocs` + um agregado) em vez de três `count` paralelos sem `req` compartilhado.
|
||||
- Create de apoiador usa o mesmo `StrictCombobox` de município dos filtros/núcleos; remoção destrutiva usa `AlertDialog` (padrão `ArchiveNucleusDialog`).
|
||||
- Guardrails: `overrideAccess: false` quando houver `user`; escrita multi-collection continua transacional; qualquer bypass de `enforceUniqueContactPhone` só sob locks já adquiridos na mesma txn; sem novo `Consent` / sem collection nova (salvo staging efêmero de import, se a Fase 4 exigir).
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Um item C6, cinco fases ordenadas.** Não criar seis itens de roadmap; o custo de coordenação supera o benefício. Fases podem ser PRs separados, mas a ordem abaixo é a recomendada (UX barata → DRY → KPI → import pesado → preview token).
|
||||
- **Dependência dura de C2.** Este item só faz sentido com o código C2 mesclado; não reabre o escopo de v1 do cadastro nominal.
|
||||
- **Cortável se a base permanecer pequena.** Se a campanha não importar milhares de linhas antes de 16/08, as fases 3–5 podem escorregar; as fases 1–2 (UX + DRY) continuam baratas e valem a pena cedo.
|
||||
- **Bypass do invariante de telefone é opt-in por `context`, nunca global.** Precedente: locks em `contactPhoneInvariant.ts` / hook `enforceUniqueContactPhone` em `Contact.ts`. Caller que já rodou `acquireContactPhoneLocks` na mesma txn pode passar `context: { skipContactPhoneInvariant: true }`; o hook só respeita se a txn já segura os locks (falha fechado caso contrário — validar na implementação).
|
||||
- **Bulk insert segue o padrão TSE seed.** `payload.db.drizzle` em txn (como `pnpm db:seed:tse` / `scripts/seed-tse-results.mjs`), com `ON CONFLICT` / tratamento de unique `supporter_contact_nucleus`, não milhares de `payload.create` sob um único `acquireContactPhoneLocks` de 5k phones.
|
||||
- **Preview token: sem Redis novo no v1 do C6.** Preferir blob assinado curto (HMAC + expiry) ou staging em memória/`unstable_cache` com TTL curto no processo; se insuficiente na Vercel, staging em tabela efêmera com TTL + job de limpeza. Decisão final na Fase 4 com medição.
|
||||
- **i18n e naming** (AGENTS.md): identificadores em inglês (`campaignListUrl`, `CampaignListPagination`, `mapCampaignFormActionError`, `skipContactPhoneInvariant`, `importToken`), strings visíveis em pt-BR.
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
- **Onde vive o staging do preview?** Blob assinado vs tabela `supporterImportBatch` vs cache em processo. **Recomendação:** começar com blob assinado (sem migration); só promover a tabela se o payload + HMAC estourar limites práticos da action.
|
||||
- **Chunk size do bulk import.** **Recomendação:** 200–500 rows por commit interno, com progresso retornado ao wizard; calibrar com teste de carga local contra `teqo_test`.
|
||||
- **KPI: drizzle raw vs Payload `count` + group.** **Recomendação:** um `SELECT … COUNT(*) FILTER` (ou `GROUP BY vote_intention`) via drizzle com o mesmo `where` de acesso que `buildSupporterListWhere` + constraint de `canReadSupporter`; reusar `result.totalDocs` da listagem para o total quando a página já carregou o find.
|
||||
- **Extrair filter shell agora ou só URL + pagination?** **Recomendação:** extrair URL helpers + pagination no primeiro PR; filter shell (`valuesRef` + `useTransition` + disclosure) no segundo — o shell é o mais arriscado para regressão de núcleos.
|
||||
- **Migrar leadership forms para o mapper compartilhado no mesmo PR?** **Recomendação:** sim para o mapper/`fieldError` (poucas linhas); não obrigar leadership a mudar copy de mensagens.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
F1["Fase 1 — UX<br/>StrictCombobox + AlertDialog"]
|
||||
F2["Fase 2 — DRY listas/forms<br/>campaignListUrl · pagination · fieldError"]
|
||||
F3["Fase 3 — KPI agregado SQL"]
|
||||
F4["Fase 4 — Import bulk<br/>drizzle + skipContactPhoneInvariant"]
|
||||
F5["Fase 5 — Preview token<br/>sem round-trip 5k"]
|
||||
|
||||
C2["C2 apoiadores ✓ eng."] --> F1
|
||||
F1 --> F2
|
||||
F2 --> F3
|
||||
F3 --> F4
|
||||
F4 --> F5
|
||||
```
|
||||
|
||||
### Fase 1 — UX alinhada (barata)
|
||||
|
||||
- **`SupporterForm`**: município via `StrictCombobox` + `municipalityComboboxOptions()` (já usado em `SupporterFilters` / `NucleusFilters`); validação continua em `resolveBahiaMunicipality` / `optionalBahiaCity`.
|
||||
- **`RemoveSupporterDataButton`**: trocar `window.confirm` por `AlertDialog` no padrão de `ArchiveNucleusDialog.tsx`.
|
||||
|
||||
### Fase 2 — DRY com núcleos / leadership
|
||||
|
||||
- **`src/utilities/campaignListUrl.ts`** (novo): `firstValue`, `normalizedText`, `strictDecimalInteger`, `inspectRaw*ListParams`, shape de `resolve*ListUrl` — hoje clonados em `nucleusUi.ts` e `supporterUi.ts`. Domínio (`parse*ListParams`, `build*ListWhere`) permanece nos módulos de domínio.
|
||||
- **`CampaignListPagination`**: `SupporterPagination` já reusa `getNucleusPaginationPages`; extrair o markup Pagination compartilhado com `hrefForPage`.
|
||||
- **`fieldError` / `errorProps`**: módulo pequeno (ex. `src/utilities/campaignFormFields.ts`) usado por `LeadershipForm` e `SupporterForm`.
|
||||
- **`mapCampaignFormActionError`**: consolidar o catch FormDataBoundaryError → Zod → safelist → genérico de `novo/formActions.ts`, `[id]/formActions.ts` e `leadershipFormActions.ts`.
|
||||
- **Filter shell** (opcional neste PR ou PR seguinte): `CampaignListFiltersShell` a partir de `NucleusFilters` / `SupporterFilters`.
|
||||
|
||||
### Fase 3 — KPI da lista
|
||||
|
||||
- **`loadSupporterListOverviewData`**: substituir 3× `payload.count` por um agregado SQL (drizzle) com o mesmo escopo de acesso; alinhar total com `totalDocs` da listagem quando possível.
|
||||
- Opcional: `createLocalReq` uma vez e passar `req` aos loaders da página para reutilizar cache de access em `req.context`.
|
||||
|
||||
### Fase 4 — Import em escala
|
||||
|
||||
- **`confirmSupporterImportRecord`**: após locks + lookup em batch, insert de contacts/supporters via `payload.db.drizzle` (espelhar seed TSE), com tratamento de conflito unique.
|
||||
- **`Contact` hook**: honrar `context.skipContactPhoneInvariant` somente quando seguro.
|
||||
- Chunking / `maxDuration` se um único txn de 5k ainda estourar timeout.
|
||||
|
||||
### Fase 5 — Preview sem round-trip
|
||||
|
||||
- **`previewSupporterImport`**: retorna `{ counts, sampleRows, importToken }` (amostra para UI; hoje `slice(0, 100)`).
|
||||
- **`confirmSupporterImport`**: aceita token (ou id de batch) em vez do array completo de rows `ok`.
|
||||
- **`SupporterImportWizard`**: para de guardar/reenviar todas as rows.
|
||||
|
||||
**Migration:** só se a Fase 5 escolher tabela de staging — `pnpm migrate:create add_supporter_import_batch` (TTL + cleanup). Fases 1–4: sem migration de schema de produto.
|
||||
|
||||
## Dependências
|
||||
|
||||
- **Dura:** C2 cadastro nominal (código em `supporter*` / `/campanha/apoiadores`).
|
||||
- **Suave:** nenhuma. Produção com dados reais ainda espera Onda 0 (Consent keys) — C6 não cria chave nova.
|
||||
- Reusa: `nucleusUi.ts`, `NucleusFilters.tsx`, `NucleusPagination.tsx`, `LeadershipForm.tsx`, `ArchiveNucleusDialog.tsx`, `contactPhoneInvariant.ts`, `Contact.ts`, padrão drizzle do seed TSE, `territoryComboboxOptions.ts`.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Reabrir decisões de v1 do C2 (telefone obrigatório, kit mínimo, import só `geral`, etc.) — [cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md).
|
||||
- Agregado de apoiadores no overview de núcleos — follow-up de B1.
|
||||
- GOTV / dia D — C5.
|
||||
- Push/notificações — D2 / [notifications.md](notifications.md).
|
||||
- Índices `pg_trgm` / FTS em `contact` (mencionado na review de perf; só se busca ILIKE virar gargalo medido).
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` — Trilha C, item C6; sequência Janela 2
|
||||
- [cadastro-nominal-apoiadores.md](cadastro-nominal-apoiadores.md) — C2 v1
|
||||
- Review `/simplify` 2026-07-18 (quality / reuse / performance) — origem da lista de fases
|
||||
- `src/app/(campaign)/campanha/actions/supporter.ts` — preview/confirm import
|
||||
- `src/utilities/supporterUi.ts` / `supporterPageData.ts` — lista + KPIs
|
||||
- `src/utilities/nucleusUi.ts` / `src/components/campaign/NucleusFilters.tsx` / `NucleusPagination.tsx` — padrões a extrair
|
||||
- `src/components/campaign/LeadershipForm.tsx` / `ArchiveNucleusDialog.tsx` — fieldError / AlertDialog
|
||||
- `src/collections/Contact.ts` — `enforceUniqueContactPhone`
|
||||
- `scripts/seed-tse-results.mjs` — bulk drizzle em txn
|
||||
- AGENTS.md — `overrideAccess: false`, transações, Contact + locks, naming
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in; C2 engenharia pronta — merge/deploy + Consent keys jurídicos ainda pendentes)
|
||||
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue; C2 engenharia pronta; C6 registrado — escala/DRY pós-C2 para depois do merge)
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -73,6 +73,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig
|
|||
| A3/A4 Baseline TSE 2022 + Gap vs 2022 | `Baseline-Eleitoral-2022` (+ bloco "Baseline 2022" em `Lista-Nucleos-Overview`) | [baseline-eleitoral-tse.md](plans/baseline-eleitoral-tse.md) |
|
||||
| A5 Insights (5 planos) | `Baseline-Eleitoral-2022` (card "Insights do território", uma linha por insight) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) |
|
||||
| C2 Cadastro nominal de apoiadores | `Apoiadores-Lista`, `Apoiador-Ficha`, `Importar-CSV-Apoiadores` | [cadastro-nominal-apoiadores.md](plans/cadastro-nominal-apoiadores.md) |
|
||||
| C6 Escala e DRY pós-C2 | — (sem design novo; reusa os de C2 + padrões de núcleos) | [escala-dry-pos-c2.md](plans/escala-dry-pos-c2.md) |
|
||||
| C3 Eventos / agenda de mobilização | `Planos-de-Acao`, `Novo-Plano-de-Acao` | [eventos-agenda-mobilizacao.md](plans/eventos-agenda-mobilizacao.md) |
|
||||
| D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) |
|
||||
| D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) |
|
||||
|
|
@ -110,6 +111,7 @@ flowchart TD
|
|||
subgraph TrilhaC["Trilha C — operação de campo"]
|
||||
C1["C1 Compartilhar página ✓"]
|
||||
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"]
|
||||
C4["C4 Demandas"]
|
||||
C5["C5 Operação dia D / GOTV<br/>(proposto, a validar)"]
|
||||
|
|
@ -137,52 +139,55 @@ flowchart TD
|
|||
C3 -.relação actionPlan opcional.-> C4
|
||||
JUR --> C2prod["C2 em produção<br/>(dados reais)"]
|
||||
C2 --> C2prod
|
||||
C2 --> C6
|
||||
C2 --> C5
|
||||
C6 -.escala da base.-> C5
|
||||
D1 --> D2
|
||||
JUR -.chave de push.-> D2
|
||||
```
|
||||
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica).
|
||||
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto), C6 (após merge de C2) e D2 push (D1 pronto; falta a chave jurídica).
|
||||
|
||||
### Sequência de execução por janela do calendário
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções): colocar a vertical em produção e consertar a fundação de dados.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ---------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
|
||||
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
|
||||
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
|
||||
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
|
||||
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
|
||||
| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) **(entregue 2026-07-18)** | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ |
|
||||
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
|
||||
| 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
|
||||
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
|
||||
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
|
||||
| 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
|
||||
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda): base nominal + inteligência + agenda prontas para o arranque.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3 |
|
||||
| 8 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3 |
|
||||
| 9 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4 |
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------- | ----------------- |
|
||||
| 7 | C2 Cadastro nominal de apoiadores _(engenharia pronta 2026-07-18 — merge/deploy + Consent keys ainda pendentes)_ | [detalhes](plans/cadastro-nominal-apoiadores.md) | produção: lote jurídico (Onda 0) | A4, C3, C6 |
|
||||
| 8 | C6 Escala e DRY pós-C2 (import em massa, KPI, shells compartilhados com núcleos) | [detalhes](plans/escala-dry-pos-c2.md) | C2 (merge) | A4, C3 |
|
||||
| 9 | A4 Baseline no produto + insight Gap vs 2022 | [detalhes](plans/baseline-eleitoral-tse.md) | A3 + B1 (suave: A2) | C2, C3, C6 |
|
||||
| 10 | C3 Eventos / agenda de mobilização (`actionPlan`) | [detalhes](plans/eventos-agenda-mobilizacao.md) | A1 | C2, A4, C6 |
|
||||
|
||||
**Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de | Paralelizável com |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------- |
|
||||
| 10 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
|
||||
| 11 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
|
||||
| 12 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
|
||||
| 13 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 14 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |
|
||||
| 11 | A5 Insights derivados do baseline (5 itens, paralelizáveis entre si; conversão e classificação exigem limiares de produto) | [conversão](plans/insight-taxa-conversao.md) · [classificação](plans/insight-classificacao-territorial.md) · [alavancagem](plans/insight-alavancagem-chapa.md) · [mobilização](plans/insight-mobilizacao-brancos-nulos.md) · [competitiva](plans/insight-inteligencia-competitiva.md) | A4 | B2/B3, C4, D1 |
|
||||
| 12 | B2 + B3 Mapa da Bahia (geometrias + Leaflet) | [detalhes](plans/mapa-bahia-geometrias.md) | B3 ← B1+B2 (suave: A4 para coroplético de baseline/classe) | A5, C4, D1 |
|
||||
| 13 | C4 Demandas | [detalhes](plans/demandas-campanha.md) | A1 (suave: C3 para a relação `actionPlan`) | A5, B3, D1 |
|
||||
| 14 | D1 PWA `/campanha` **(entregue antecipado em 2026-07-18)** | [detalhes](plans/pwa-campanha.md) | — | tudo |
|
||||
| 15 | D2 Notificações (push + sino) — sino não depende do PWA; push sim | [detalhes](plans/notifications.md) | D1 ✓ (push) + chave `campanha-notificacoes-push` do lote jurídico | A5, B3, C4 |
|
||||
|
||||
**Janela 4 — set → 04/10 (reta final): dobradinha, dia D e estabilização.**
|
||||
|
||||
| Ordem | Item | Plano | Depende de |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 15 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 16 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 17 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 |
|
||||
| 18 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
|
||||
| 16 | A6 Insight dobradinha 2026 | [detalhes](plans/insight-dobradinha-2026.md) | A4 + TSE publicar candidaturas 2026 (externo, após 15/08) + taxonomia de alinhamento (produto) |
|
||||
| 17 | B4 Camada de zonas TSE no mapa | ciclo seguinte do [plano do mapa](plans/mapa-bahia-geometrias.md) | A2 + B3 |
|
||||
| 18 | C5 **Operação dia D / GOTV** _(item proposto nesta revisão — validar com produto)_: confirmação de comparecimento da base nominal em 04/10 (lista de apoiadores por núcleo/zona com marcação "confirmou que vai votar / votou", visão de cobertura para a coordenação). É o uso final da base construída em C2; a literatura de campanha trata a mobilização do dia como onde eleições apertadas se decidem. Sem plano ainda. | design: [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) | C2 (suave: C6 para escala da base) |
|
||||
| 19 | Congelamento: a partir de ~20/09, só correção de bug e dados; nada de migration arriscada perto do dia D. | — | — |
|
||||
|
||||
**Fill-ins (qualquer janela, quando houver folga; nenhum bloqueia nada):**
|
||||
|
||||
|
|
@ -191,7 +196,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
|
|||
- **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_
|
||||
- **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_
|
||||
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B3 mapa Leaflet, C4 demandas, D2 push (mantendo o sino), fill-ins (D1 PWA já entregue, saiu da lista de cortes). **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
|
||||
**Cortes seguros se o prazo apertar** (nesta ordem): B4 camada de zonas, B3 mapa Leaflet, C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2 (UX + DRY) se houver folga. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap — são respectivamente o risco legal, a base de dados, a operação da propaganda e o instrumento de alocação de esforço.
|
||||
|
||||
### Itens consolidados/removidos nesta revisão (2026-07-17)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import config from '@payload-config'
|
||||
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
setSupporterVoteIntentionFormAction,
|
||||
} from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
|
||||
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
||||
import { ConsentText } from '@/components/campaign/ConsentText'
|
||||
import { RemoveSupporterDataButton } from '@/components/campaign/RemoveSupporterDataButton'
|
||||
|
|
@ -22,8 +20,8 @@ import {
|
|||
loadSupporterDetailPageData,
|
||||
SupporterNotFoundError,
|
||||
} from '@/utilities/supporterPageData'
|
||||
import { parseSupporterId } from '@/utilities/supporterViewModels'
|
||||
import { canAccessSupporterArea, supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import { parseSupporterId } from '@/utilities/supporterViewModels'
|
||||
|
||||
type SupporterDetailPageProps = {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -59,7 +57,8 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
|
|||
? supporterVoteIntentionLabels[supporter.voteIntention]
|
||||
: null
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ?? 'https://jorgesolla.com.br'
|
||||
const siteUrl =
|
||||
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') ?? 'https://jorgesolla.com.br'
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import config from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
|
|
|
|||
|
|
@ -187,11 +187,7 @@ const upsertContactByPhone = async ({
|
|||
return { contactID: contact.id, reused: false }
|
||||
}
|
||||
|
||||
const createValidatedSupporter = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: unknown,
|
||||
) => {
|
||||
const createValidatedSupporter = async (payload: Payload, actor: CampaignUser, input: unknown) => {
|
||||
const data = supporterCreateSchema.parse(input)
|
||||
|
||||
try {
|
||||
|
|
@ -271,11 +267,8 @@ const createValidatedSupporter = async (
|
|||
}
|
||||
}
|
||||
|
||||
export const createSupporterRecord = (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: unknown,
|
||||
) => createValidatedSupporter(payload, actor, input)
|
||||
export const createSupporterRecord = (payload: Payload, actor: CampaignUser, input: unknown) =>
|
||||
createValidatedSupporter(payload, actor, input)
|
||||
|
||||
export const createSupporter = async (input: unknown) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
|
|
@ -350,9 +343,7 @@ const mapImportHeader = (header: string): string | null => {
|
|||
return aliases[normalized] ?? null
|
||||
}
|
||||
|
||||
const parseVoteIntentionCell = (
|
||||
value: string,
|
||||
): SupporterVoteIntention | undefined | 'invalid' => {
|
||||
const parseVoteIntentionCell = (value: string): SupporterVoteIntention | undefined | 'invalid' => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const normalized = normalizeImportHeader(trimmed).replace(/-/g, '_')
|
||||
|
|
@ -524,9 +515,8 @@ export const previewSupporterImportText = async (
|
|||
const counts = {
|
||||
ok: rows.filter(isSupporterImportOkRow).length,
|
||||
duplicate: rows.filter((row) => row.status === 'duplicado_pelo_telefone').length,
|
||||
error: rows.filter(
|
||||
(row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone',
|
||||
).length,
|
||||
error: rows.filter((row) => row.status !== 'ok' && row.status !== 'duplicado_pelo_telefone')
|
||||
.length,
|
||||
}
|
||||
|
||||
return { rows, counts }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { SupporterFormState } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
|
||||
|
|
@ -21,10 +21,10 @@ import { Input } from '@/components/ui/input'
|
|||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import type { SupporterVoteIntention } from '@/lib/schemas/supporter'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/utilities/phone'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import type { SupporterNucleusOption } from '@/utilities/supporterViewModels'
|
||||
|
||||
export type SupporterFormAction = (
|
||||
state: SupporterFormState,
|
||||
|
|
@ -218,9 +218,7 @@ export const SupporterForm = ({
|
|||
</Field>
|
||||
|
||||
<Field data-invalid={nucleusField.invalid}>
|
||||
<FieldLabel htmlFor="supporter-nucleus">
|
||||
Núcleo{requireNucleus ? ' *' : ''}
|
||||
</FieldLabel>
|
||||
<FieldLabel htmlFor="supporter-nucleus">Núcleo{requireNucleus ? ' *' : ''}</FieldLabel>
|
||||
<NativeSelect
|
||||
id="supporter-nucleus"
|
||||
name="nucleus"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { CheckCircle2Icon, FileUpIcon, UploadIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { CheckCircle2Icon, FileUpIcon, UploadIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
|
|
@ -17,12 +17,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field'
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
|
|
@ -95,9 +90,7 @@ export const SupporterImportWizard = () => {
|
|||
setStep(1)
|
||||
} catch (previewError) {
|
||||
setError(
|
||||
previewError instanceof Error
|
||||
? previewError.message
|
||||
: 'Não foi possível ler o arquivo.',
|
||||
previewError instanceof Error ? previewError.message : 'Não foi possível ler o arquivo.',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -145,10 +138,16 @@ export const SupporterImportWizard = () => {
|
|||
<span
|
||||
className={cn(
|
||||
'inline-flex size-7 items-center justify-center rounded-full border text-xs font-semibold',
|
||||
index <= step ? 'border-primary bg-primary text-primary-foreground' : 'text-muted-foreground',
|
||||
index <= step
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{index < step ? <CheckCircle2Icon className="size-4" aria-hidden="true" /> : index + 1}
|
||||
{index < step ? (
|
||||
<CheckCircle2Icon className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
index + 1
|
||||
)}
|
||||
</span>
|
||||
<span className={index <= step ? 'font-medium' : 'text-muted-foreground'}>{label}</span>
|
||||
{index < steps.length - 1 ? <span className="text-muted-foreground">→</span> : null}
|
||||
|
|
@ -235,9 +234,7 @@ export const SupporterImportWizard = () => {
|
|||
<TableCell>{row.telefone}</TableCell>
|
||||
<TableCell>{row.municipio || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{row.voteIntention
|
||||
? supporterVoteIntentionLabels[row.voteIntention]
|
||||
: '—'}
|
||||
{row.voteIntention ? supporterVoteIntentionLabels[row.voteIntention] : '—'}
|
||||
</TableCell>
|
||||
<TableCell>{importStatusLabels[row.status]}</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -282,7 +279,11 @@ export const SupporterImportWizard = () => {
|
|||
disabled={!operatorAttested || preview.counts.ok === 0 || isPending}
|
||||
onClick={confirmImport}
|
||||
>
|
||||
{isPending ? <Spinner data-icon="inline-start" /> : <UploadIcon data-icon="inline-start" />}
|
||||
{isPending ? (
|
||||
<Spinner data-icon="inline-start" />
|
||||
) : (
|
||||
<UploadIcon data-icon="inline-start" />
|
||||
)}
|
||||
{isPending ? 'Importando…' : `Importar ${preview.counts.ok} apoiadores`}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Link from 'next/link'
|
||||
import { PhoneIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -13,8 +13,8 @@ import {
|
|||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import { formatBrazilianPhoneInput } from '@/utilities/phone'
|
||||
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
|
||||
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
|
||||
|
||||
export type SupporterListProps = {
|
||||
supporters: SupporterListItemViewModel[]
|
||||
|
|
|
|||
|
|
@ -9,18 +9,12 @@ import {
|
|||
} from '@/lib/schemas/primitives'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
export const supporterVoteIntentions = [
|
||||
'certo',
|
||||
'tende_a_certo',
|
||||
'indeciso',
|
||||
'outro',
|
||||
] as const
|
||||
export const supporterVoteIntentions = ['certo', 'tende_a_certo', 'indeciso', 'outro'] as const
|
||||
|
||||
export type SupporterVoteIntention = (typeof supporterVoteIntentions)[number]
|
||||
|
||||
export const isSupporterVoteIntention = (value: unknown): value is SupporterVoteIntention =>
|
||||
typeof value === 'string' &&
|
||||
(supporterVoteIntentions as readonly string[]).includes(value)
|
||||
typeof value === 'string' && (supporterVoteIntentions as readonly string[]).includes(value)
|
||||
|
||||
const canonicalMunicipalityBySearchValue = new Map(
|
||||
bahiaMunicipalities.map((city) => [normalizeSearchPhrase(city), city]),
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ import {
|
|||
getSupporterRegistrationConsent,
|
||||
getSupporterVoteIntentionConsent,
|
||||
} from '@/utilities/campaignConsent'
|
||||
import {
|
||||
toSupporterDetailViewModel,
|
||||
type SupporterDetailViewModel,
|
||||
type SupporterListOverviewViewModel,
|
||||
type SupporterNucleusOption,
|
||||
} from '@/utilities/supporterViewModels'
|
||||
import {
|
||||
buildSupporterListWhere,
|
||||
resolveSupporterListUrl,
|
||||
supporterPageSize,
|
||||
type SupporterListState,
|
||||
} from '@/utilities/supporterUi'
|
||||
import {
|
||||
toSupporterDetailViewModel,
|
||||
type SupporterDetailViewModel,
|
||||
type SupporterListOverviewViewModel,
|
||||
type SupporterNucleusOption,
|
||||
} from '@/utilities/supporterViewModels'
|
||||
|
||||
type SupporterListSearchParams = Record<string, string | string[] | undefined>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export type SupporterDetailViewModel = {
|
|||
const contactFromSupporter = (supporter: { contact: Supporter['contact'] }): Contact | null =>
|
||||
isPopulatedRelationship<Contact>(supporter.contact) ? supporter.contact : null
|
||||
|
||||
const nucleusFromSupporter = (supporter: { nucleus?: Supporter['nucleus'] }): ElectoralNucleus | null =>
|
||||
const nucleusFromSupporter = (supporter: {
|
||||
nucleus?: Supporter['nucleus']
|
||||
}): ElectoralNucleus | null =>
|
||||
isPopulatedRelationship<ElectoralNucleus>(supporter.nucleus) ? supporter.nucleus : null
|
||||
|
||||
export const toSupporterListItemViewModel = (
|
||||
|
|
|
|||
Loading…
Reference in a new issue