Support multi-municipality and multi-neighborhood nucleus territory.

Replace scalar region/city/neighborhood with hasMany arrays so nuclei can span multiple Bahia municipalities while keeping neighborhoods scoped to a single city.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 18:31:51 -03:00
parent c42b16baab
commit b035cc4a2f
42 changed files with 7748 additions and 540 deletions

View file

@ -77,7 +77,8 @@ alwaysApply: false
- Advisory locks de runtime usam a sessão exata da transação Payload via `postgresTransactionLocks.ts`, com chaves textuais deduplicadas/ordenadas e namespaces independentes (`vote-estimate`, `nucleus-updates`, `primary-contact`, convites, telefone de contato e designação de coordenação). Leases de Consent permanecem exclusivamente em helpers de teste.
- A fronteira pública de convites exporta somente as três Server Actions; helpers privilegiados de SQL, autorização, token, consentimento e recuperação ficam em módulos `server-only`, com limites arquiteturais cobertos por teste.
- Em 2026-07-17, `Contact.city` passou a opcional porque Núcleos podem representar um Território de Identidade sem município/localidade; os fluxos públicos continuam exigindo cidade em seus próprios schemas. O cadastro de liderança mascara o celular no cliente, normaliza 11 dígitos no servidor e preserva valores/erros após falhas.
- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único; filtros da lista mudam de `equals` para `contains`. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: "Território multi-município e multi-bairro".
- Em 2026-07-17, decisão de produto: `Município` e `Bairro` do Núcleo passam a aceitar múltiplas entradas (arrays planos `cities`/`neighborhoods`), e `region` vira `regions` derivado no servidor a partir dos municípios, permitindo cruzar Territórios de Identidade. Bairros exigem município único. Modelo A (arrays planos) escolhido em vez de array estruturado por município. Plano: [`docs/plans/territorio-multi-municipio-bairro.md`](../../../docs/plans/territorio-multi-municipio-bairro.md); item do roadmap: A1.
- Em 2026-07-18, **A1 entregue:** `regions`/`cities`/`neighborhoods` como `text` + `hasMany` (tabela `electoral_nucleus_texts`); migration `20260718_190559_territorio_multi_municipio_bairro` com backfill; filtros da lista permanecem single-select na URL e consultam com `{ equals }` (associação ao array — não o operador substring `contains`); UI de `/campanha` com chips multi + bairros só com 1 município; dashboard e criação de liderança atualizados (`Contact.city` só quando `cities.length === 1`).
- O DTO paginado de atualizações contém somente filtro/paginação/feed; a visão geral chama um loader dedicado que busca `limit: 3` e apenas os autores necessários à prévia.
- A lista paginada de lideranças seleciona e serializa somente os campos da linha; avaliação interna e dados completos de contato são carregados apenas para a liderança selecionada. O fechamento usa `onCloseAutoFocus` no Sheet e `finalFocus` no Drawer, focaliza a linha de origem (ou o título do Núcleo) antes de navegar para a URL canônica, sem RAF/timeout.
- Fonte: `/Users/francisco.solla/.cursor/plans/núcleos_eleitorais_mvp_7f788d29.plan.md`.

View file

@ -1,8 +1,8 @@
# Território multi-município e multi-bairro
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Status: **implementado** (2026-07-18)
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A1)
Responsável: —
## Referência visual (UX Pilot)
@ -20,137 +20,58 @@ Como usar (parte deste plano — bloco Território):
## Contexto
Hoje o território do Núcleo Eleitoral é modelado com campos **single-valued** em `src/collections/ElectoralNucleus.ts`:
- `region` (Território de Identidade, `text`, único, indexado);
- `city` (Município, `text`, único, indexado);
- `neighborhood` (Bairro, `text`, único);
- `locality` (Localidade, `text`, livre);
- `territoryNotes` (`textarea`).
As regras atuais (em `validateNucleusTerritoryAndZones` no collection e em `validateGeographyAndZones` no Zod `src/lib/schemas/nucleus.ts`, com parsing em `parseSharedNucleusFormData` em `src/utilities/nucleusUi.ts`) são:
- `region` deve ser território válido da Bahia; `city` deve ser município válido da Bahia;
- se `region` + `city`, então `city` deve pertencer a `region` (`territoryForCity(city) === region`);
- `neighborhood` exige `city`;
- pelo menos um de `region`/`city`/`locality`.
Isso não cobre dois casos reais da operação: um núcleo que abrange **mais de um município** (sem ser um Território de Identidade inteiro) e um núcleo que abrange **mais de um bairro** (sem ser um município inteiro). Decisão de produto (2026-07-17): `Município` e `Bairro` passam a aceitar múltiplas entradas, e um núcleo pode **cruzar Territórios de Identidade** quando abrange municípios de territórios distintos.
O MVP modelava o território do Núcleo com campos single-valued (`region` / `city` / `neighborhood`). Isso não cobria núcleos multi-município (incluindo cross-territory) nem multi-bairro. Decisão de produto (2026-07-17): Modelo A — arrays planos — com `regions` derivado no servidor a partir de `cities` quando houver municípios.
## Decisão de modelagem (Modelo A + cross-territory)
Escolhido o **Modelo A** (arrays planos) em vez de array estruturado por município, porque o caso combinado "vários municípios **com** bairros em cada" não é realista para a operação — bairros só fazem sentido escopados a um único município.
- `region` → **`regions`** (`text[]`): array de Territórios de Identidade. **Derivado no servidor** a partir dos municípios selecionados quando `cities` não vazio; definível manualmente só quando `cities` vazio (caso "núcleo = território inteiro").
- `city` → **`cities`** (`text[]`): múltiplos municípios, podem pertencer a territórios distintos (cross-territory).
- `neighborhood` → **`neighborhoods`** (`text[]`): múltiplos bairros (texto livre, como hoje).
- `region` → **`regions`** (`text` + `hasMany`, max 27): Territórios de Identidade. **Derivado no servidor** a partir dos municípios quando `cities` não vazio; editável manualmente só quando `cities` vazio.
- `city` → **`cities`** (`text` + `hasMany`, max 27): múltiplos municípios, podem cruzar TIs.
- `neighborhood` → **`neighborhoods`** (`text` + `hasMany`, max 30): múltiplos bairros; exige exatamente 1 município.
- `locality` e `territoryNotes`: inalterados.
### Regras de validação (substituem as atuais)
### Regras de validação
1. Pelo menos um de: `regions`, `cities`, `locality`.
2. Cada `city` deve ser município válido da Bahia; cada `region` deve ser território válido.
3. Se `cities` não vazio → `regions` é **exatamente** o conjunto (deduplicado) dos territórios desses municípios. O servidor **ignora** o valor de `regions` enviado e rederiva (mesmo padrão de `setCanonicalNucleusSlug`).
4. Se `cities` vazio → `regions` é o que o usuário escolher (1+ territórios inteiros).
5. Se `neighborhoods` não vazio → `cities` deve ter **exatamente 1** elemento. Bairros só fazem sentido num único município.
6. Bairros: trim, dedup, não vazios.
2. Cada city/region deve ser válido na Bahia.
3. Se `cities` não vazio → `regions = territoriesForCities(cities)` (servidor ignora o valor enviado).
4. Se `cities` vazio → `regions` é a escolha manual (1+ TIs).
5. Se `neighborhoods` não vazio → `cities.length === 1`.
6. Trim + dedup + tetos `maxRows`.
### Diagrama
## Implementação (como ficou)
```mermaid
flowchart TD
Start[Formulário Núcleo] --> HasCities{cities vazio?}
HasCities -->|não| Derive["regions = territórios das cities (servidor)"]
HasCities -->|sim| Manual["regions = escolha manual (1+)"]
Derive --> CheckNh{neighborhoods não vazio?}
Manual --> CheckNh
CheckNh -->|sim| RequireOne["exigir cities.length = 1"]
CheckNh -->|não| Save[persistir]
RequireOne --> Save
```
### Storage (correção vs rascunho)
## Objetivos
Não usamos Postgres `text[]` + GIN. O Payload 3.82 modela `text` + `hasMany` na tabela compartilhada `electoral_nucleus_texts` (`parent_id` / `path` / `order` / `text`). Isso preserva admin, types e Local API sem schema paralelo. `select` hasMany para `regions` foi rejeitado: criaria enum Postgres com nomes acentuados dos 27 TIs (dados vivos em `bahiaTerritories.ts`).
- Schema do `electoralNucleus` com `regions`/`cities`/`neighborhoods` como arrays, com migration que preserva dados existentes.
- Validação server-side autoritativa (hook `beforeValidate` + Zod) com as 6 regras acima; o cliente apenas reflete o estado derivado.
- Formulário de criar/editar núcleo (`NucleusTerritoryFields`) com município multi-seleção (chips), bairro como tag input habilitado só com 1 município, e território como exibição derivada + seleção manual quando sem municípios.
- Filtros da lista de núcleos (`NucleusFilters`) continuam single-select por território e por município; a consulta muda de `equals` para `contains` (núcleo aparece quando toca aquele território/município).
- View models e DTOs discriminados por papel atualizados para arrays; renderização em lista/detalhe apresenta arrays como badges ou lista separada por vírgula.
- Testes int cobrindo: multi-município, cross-territory, multi-bairro, regra do município único, derivação de `regions`, e filtro `contains`.
### Filtro (correção vs rascunho)
## Decisões travadas
O rascunho dizia `contains`, mas em Payload `contains` é substring. A associação “array contém este valor” usa `{ regions: { equals } }` / `{ cities: { equals } }`. A URL da lista continua single-select (`region` / `city`).
- **Modelo A (arrays planos).** Sem array estruturado por município; sem tabela aninhada. Bairros exigem município único (regra 5).
- **Cross-territory permitido.** Múltiplos municípios podem pertencer a territórios distintos; `regions` reflete o conjunto exato.
- **`regions` derivado no servidor quando há `cities`.** O cliente envia `cities` (e opcionalmente `regions` só para o caso sem municípios); o hook rederiva `regions` a partir de `cities` e ignora o valor enviado quando `cities` não vazio. Mesmo princípio do `slug` canônico.
- **Renomear campos para o plural** (`region`→`regions`, `city`→`cities`, `neighborhood`→`neighborhoods`) para clareza semântica e consistência com identificadores em inglês (AGENTS.md).
- **Migration in-place com backfill.** Dados existentes já cumprem `city ∈ region` (validação atual), então `regions = ARRAY[region]`, `cities = ARRAY[city]`, `neighborhoods = ARRAY[neighborhood]` é correto. Down migration recria colunas single a partir do primeiro elemento e `RAISE EXCEPTION` se algum array tiver >1 elemento (proteção contra perda).
- **Índices GIN em `regions` e `cities`** para o operador `contains` dos filtros.
- **Filtros continuam single-select.** `NucleusFilters` não vira multi-select; só a consulta (`buildNucleusListWhere`) muda de `equals` para `contains`. O estado da URL (`NucleusListState`) mantém `region`/`city` single.
- **Sem `Consent`, sem server action nova, sem collection nova.** Só schema + UI + validação + migration.
- **i18n e naming** seguem AGENTS.md: identificadores em inglês, strings visíveis em pt-BR.
- **Executar cedo, antes do volume de dados reais e antes de Eventos/Demandas** (decisão de sequenciamento 2026-07-17). Três razões: (1) a migration in-place com backfill é trivial e de risco mínimo enquanto há poucos núcleos — cada semana de dados reais aumenta o custo de validação; (2) [`zonas-por-municipio.md`](zonas-por-municipio.md) nasce contra `cities[]` e depende desta ordem; (3) as collections `actionPlan` ([eventos-agenda-mobilizacao.md](eventos-agenda-mobilizacao.md)) e `campaignDemand` ([demandas-campanha.md](demandas-campanha.md)) copiam o bloco de território do núcleo — se nascerem antes desta mudança, herdam o modelo single e criam dívida imediata de migração dupla.
### Arquivos tocados
## Questões em aberto
- Collection + hook: `src/collections/ElectoralNucleus.ts`
- Helper: `territoriesForCities` em `src/lib/bahiaTerritories.ts`
- Zod: `src/lib/schemas/nucleus.ts`
- FormData: `src/utilities/nucleusFormData.ts` + `repeatedFormTexts` em `src/lib/formData.ts`
- Query/VM/UI: `nucleusUi`, `nucleusViewModels`, `nucleusPageData`, dashboard, `NucleusTerritoryFields`, list/detail
- Liderança: `Contact.city` = `cities[0]` só quando `cities.length === 1`, senão `locality ?? null`
- Migration: `src/migrations/20260718_190559_territorio_multi_municipio_bairro.ts` (backfill + down com `RAISE EXCEPTION` se `count > 1`)
- **Tipo do campo Payload.** Para `cities`/`neighborhoods` (texto livre repetido), usar `array` de subcampo `text` (cria tabela `electoral_nucleus_cities`/`electoral_nucleus_neighborhoods`) ou `select` com `hasMany`? `select` não serve para bairros (lista não é fixa). Recomendação: `array` de `text` para `cities` e `neighborhoods`; para `regions` (lista fixa de 27), `select` com `hasMany` ou `array` de `text` com validação — decidir na implementação pelo equilíbrio entre UX no admin Payload e complexidade da migration.
- **Admin Payload UI.** O formulário da campanha é custom React; o admin Payload também edita núcleos. Para `array` de `text`, o admin mostra repeatable text inputs (sem combobox). Aceitável para staff; o combobox bonito fica só no `/campanha`. Confirmar com produto se é suficiente.
- **Detecção de "tseZones alterado" em update** (interação com o plano [Zonas TSE por município](zonas-por-municipio.md)): com múltiplos municípios, o auto-preenchimento de zonas passa a ser a **união** das zonas dos municípios selecionados. Esse plano atualiza a premissa do outro.
- **Limite máximo de municípios/bairros.** Definir um teto razoável (ex.: 27 municípios, 30 bairros) para evitar abuso e limitar o tamanho do array no form/URL. A definir na implementação.
- **Núcleo cross-territory no filtro por território.** Confirmar com produto que um núcleo com municípios em 2 territórios deve aparecer ao filtrar por **qualquer um** dos dois (comportamento esperado do `contains`).
## Questões em aberto — resolvidas na implementação
## Abordagem proposta
Arquivos:
- **`src/collections/ElectoralNucleus.ts`**: renomear campos para `regions`/`cities`/`neighborhoods` como arrays; reescrever `validateNucleusTerritoryAndZones` com as 6 regras (incluindo derivação de `regions` a partir de `cities`); atualizar `defaultColumns` para `regions`/`cities`.
- **`src/lib/schemas/nucleus.ts`**: Zod com `regions`/`cities`/`neighborhoods` como arrays (`z.array(z.string())`); `superRefine` com as 6 regras, espelhando o hook.
- **`src/lib/bahiaTerritories.ts`**: adicionar `territoriesForCities(cities: string[]): BahiaIdentityTerritory[]` (conjunto único e ordenado dos territórios dos municípios), reusando `territoryForCity`.
- **`src/utilities/nucleusUi.ts`**: `parseSharedNucleusFormData` lê `cities`/`neighborhoods` repetidos do FormData e deriva `regions` (quando `cities` não vazio); `buildNucleusListWhere` passa a `regions: { contains: state.region }` e `cities: { contains: state.city }`; `NucleusListState` mantém `region`/`city` single (filtro).
- **`src/utilities/nucleusViewModels.ts`**: todos os view models (`NucleusListViewModel`, `NucleusFormViewModel`, `Staff/LeadershipNucleusTabsViewModel`, `NucleusDetailBaseViewModel`) passam a `regions: string[]`, `cities: string[]`, `neighborhoods: string[]`; atualizar mappers (`toNucleusListViewModel`, `toNucleusFormViewModel`, `toNucleusDetailBaseViewModel`, `toStaffNucleusTabsViewModel`, `toLeadershipNucleusTabsViewModel`) e os `select` (`nucleusFormSelect`, `nucleusListSelect`, `nucleusStaffDetailSelect`, `nucleusLeadershipDetailSelect`, `nucleusContextSelect`).
- **`src/utilities/nucleusPageData.ts`**: atualizar `nucleusContextSelect` e os selects por tab para os novos nomes.
- **`src/components/campaign/NucleusTerritoryFields.tsx`**: município → combobox multi-seleção (chips); bairro → tag input habilitado só quando `cities.length === 1` (limpar bairros ao passar para >1); território → exibição derivada (badges dos territórios das cities) + seleção manual só quando sem municípios; hidden inputs emitem `regions`/`cities`/`neighborhoods` repetidos.
- **`src/components/campaign/NucleusFilters.tsx`**: sem mudança de UI; só consome o novo `buildNucleusListWhere`.
- **Componentes de renderização** (`NucleusCard`, `NucleusList`, detalhe overview/territory tab): apresentar `regions`/`cities`/`neighborhoods` como badges ou lista separada por vírgula.
- **`src/payload-types.ts`**: regenerar com `pnpm generate:types`.
- **Migration** `src/migrations/<ts>_territorio_multi_municipio_bairro.ts` (+ `.json` + `index.ts`): ver seção Migration.
## Migration
`pnpm migrate:create territorio_multi_municipio_bairro` e editar à mão (skill `payload-migrations` permite data migration):
- adicionar colunas `regions text[]`, `cities text[]`, `neighborhoods text[]` em `electoral_nucleus`;
- backfill:
- `regions = CASE WHEN region IS NOT NULL THEN ARRAY[region] ELSE ARRAY[]::text[] END`;
- `cities = CASE WHEN city IS NOT NULL THEN ARRAY[city] ELSE ARRAY[]::text[] END`;
- `neighborhoods = CASE WHEN neighborhood IS NOT NULL THEN ARRAY[neighborhood] ELSE ARRAY[]::text[] END`;
- (dados existentes já cumprem `city ∈ region`, então `regions = ARRAY[region]` está correto);
- drop colunas antigas `region`/`city`/`neighborhood`;
- drop índices antigos `electoral_nucleus_region_idx` e criar GIN em `regions` e `cities` para o operador `contains`;
- **down**: recriar colunas single `region`/`city`/`neighborhood` a partir do primeiro elemento de cada array, recriar índice btree, e `RAISE EXCEPTION` se algum array tiver `cardinality > 1` (proteção contra perda de dados multi-município/bairro).
| Questão | Resolução |
| --- | --- |
| Tipo do campo Payload | `text` + `hasMany` para os três campos |
| Admin Payload UI | inputs nativos hasMany; combobox rico só em `/campanha` |
| Limite máximo | 27 municípios/TIs, 30 bairros |
| Filtro cross-territory | aparece ao filtrar por qualquer TI tocado (`equals`) |
## Dependências
- Reusa `territoryForCity`/`citiesForTerritory`/`bahiaMunicipalities` (`src/lib/bahiaTerritories.ts`), `CitiesByState.BA` (`src/lib/cities`), `StrictCombobox` (`src/components/campaign/StrictCombobox`), `municipalityComboboxOptions`/`territoryComboboxOptions` (`src/utilities/territoryComboboxOptions`).
- **Interage com o plano [Zonas TSE por município](zonas-por-municipio.md)**: o auto-preenchimento de zonas passa a ser a união das zonas dos municípios selecionados (em vez de "a zona do município"). Atualizar a premissa daquele plano quando ambos forem implementados.
- A2 ([zonas-por-municipio.md](zonas-por-municipio.md)) nasce contra `cities[]` (união das zonas).
- C3/C4 (eventos/demandas) devem copiar o bloco multi de território.
## Não escopo
- Array estruturado por município (`areas: [{ city, neighborhoods }]`) — caso combinado "vários municípios com bairros em cada" fica fora.
- Mapa / PostGIS / polígonos GeoJSON (roadmap "Mapa / PostGIS").
- Polígonos de zonas TSE — só o cadastro tabular (ver plano [Zonas TSE por município](zonas-por-municipio.md)).
- Multi-tenant / white-label.
- Alterar `Contact.city` (que virou opcional para representar território sem município) — escopo de `Contact`, não do núcleo.
## Referências
- `docs/roadmap.md` (item novo; seção "Campanha → Próximos ciclos")
- `.cursor/rules/projects/nucleos-eleitorais.mdc` (decisão a registrar)
- `src/collections/ElectoralNucleus.ts` — campos e `validateNucleusTerritoryAndZones`
- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones`
- `src/utilities/nucleusUi.ts` — `parseSharedNucleusFormData`, `buildNucleusListWhere`
- `src/utilities/nucleusViewModels.ts` — view models e selects
- `src/components/campaign/NucleusTerritoryFields.tsx`, `NucleusFilters.tsx` — UI
- `src/lib/bahiaTerritories.ts` — `territoryForCity`, `citiesForTerritory`
- `docs/plans/zonas-por-municipio.md` — premissa de auto-preenchimento de zonas a atualizar
- AGENTS.md — naming conventions, padrão de migration, "Bahia implícita no Núcleo"
Array estruturado por município; mapa/PostGIS; auto-preenchimento A2; mudar `Contact.city` além da regra de derivação na criação de liderança.

View file

@ -1,10 +1,14 @@
# Zonas TSE por município (auto-preenchimento)
Status: rascunho
Atualizado em: 2026-07-17
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Campanha → Próximos ciclos")
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A2)
Responsável: —
## Premissa atualizada (A1 entregue)
A1 ([territorio-multi-municipio-bairro.md](territorio-multi-municipio-bairro.md)) renomeou o território do núcleo para arrays `regions` / `cities` / `neighborhoods` (`text` + `hasMany`). Este plano **nasce contra `cities[]`**: o auto-preenchimento de zonas passa a ser a **união** das zonas oficiais de todos os municípios selecionados (não “a zona do município”). Bairros continuam a exigir exatamente um município (regra A1), então os modos com bairro permanecem escopados a um único município.
## Referência visual (UX Pilot)
Design: [`Formulario-Territorio.png`](../design-refs/latest/Formulario-Territorio.png) · [`Formulario-Territorio.html`](../design-refs/latest/Formulario-Territorio.html) — **compartilhado com [territorio-multi-municipio-bairro.md](territorio-multi-municipio-bairro.md)** (a mesma tela cobre os dois planos).
@ -20,7 +24,7 @@ Como usar (parte deste plano — seção "Zonas Eleitorais (TSE)"):
## Contexto
Hoje o campo **Zonas TSE** do formulário do núcleo (`TseZoneInput` em `src/components/campaign/TseZoneInput.tsx`) é sempre de preenchimento manual livre: o usuário digita números de 1 a 999, separados por vírgula/espaço/Enter, e o servidor só valida unicidade e intervalo (`parseTseZoneNumbers` em `src/utilities/nucleusUi.ts`, `validateNucleusTerritoryAndZones` em `src/collections/ElectoralNucleus.ts`, `validateGeographyAndZones` em `src/lib/schemas/nucleus.ts`). Não há nenhuma relação entre o município selecionado e as zonas informadas — o usuário precisa saber (ou adivinhar) quais zonas cobrem aquele município.
Hoje o campo **Zonas TSE** do formulário do núcleo (`TseZoneInput` em `src/components/campaign/TseZoneInput.tsx`) é sempre de preenchimento manual livre: o usuário digita números de 1 a 999, separados por vírgula/espaço/Enter, e o servidor só valida unicidade e intervalo (`parseTseZoneNumbers` em `src/utilities/tseZone.ts`, `validateNucleusTerritoryAndZones` em `src/collections/ElectoralNucleus.ts`). Não há nenhuma relação entre o(s) município(s) selecionado(s) e as zonas informadas — o usuário precisa saber (ou adivinhar) quais zonas cobrem aquele território.
O TSE publica, como dado aberto, a correspondência oficial entre município e zona eleitoral (cada par município×zona aparece no conjunto "Eleitorado por município e zona", por UF). A decisão de produto (2026-07-17) é usar esse cadastro oficial para **preencher automaticamente** as Zonas TSE do núcleo a partir do município selecionado, sem edição manual nesse caso. O território de identidade já é auto-selecionado hoje quando o município muda (`territoryForCity` em `src/lib/bahiaTerritories.ts`, já wired em `NucleusTerritoryFields`); este plano preserva esse comportamento e o documenta como parte do mesmo fluxo.

View file

@ -139,7 +139,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): A1, B1, B2,
| 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 | [detalhes](plans/territorio-multi-municipio-bairro.md) | — (executar cedo: migration barata antes do volume de dados; pré-requisito de A2, C3, C4) | B1, C1, A3 |
| 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 por município (auto-preenchimento) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1, C1, A3 |
| 4 | B1 Overview da lista de núcleos | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
| 5 | C1 Compartilhar página (quick win de campo) | [detalhes](plans/compartilhar-pagina.md) | — | tudo |

View file

@ -35,7 +35,7 @@ import {
resolveNucleusDetailTab,
} from '@/utilities/nucleusDetailTabUi'
import { NucleusNotFoundError } from '@/utilities/nucleusPageData'
import { organizationKindLabels } from '@/utilities/nucleusUi'
import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi'
type NucleusDetailPageProps = {
params: Promise<{ slug: string }>
@ -80,9 +80,7 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu
null,
isStaffView ? view.proposedVoteEstimateVersion : null,
)
const territory = [view.neighborhood, view.locality, view.city, view.region]
.filter(Boolean)
.join(' · ')
const territory = formatNucleusTerritoryLabel(view)
const voteEstimateFocusTargetId = `vote-estimate-${view.id}-focus-target`
return (

View file

@ -94,7 +94,8 @@ const createValidatedLeadershipRecord = async (
const contactReused = Boolean(contactID)
if (!contactID) {
const city = nucleus.city ?? nucleus.locality
const cities = Array.isArray(nucleus.cities) ? nucleus.cities : []
const city = cities.length === 1 ? cities[0] : (nucleus.locality ?? null)
const contact = await payload.create({
collection: 'contact',
data: {

View file

@ -4,8 +4,13 @@ import { APIError } from 'payload'
import {
isBahiaIdentityTerritory,
isBahiaMunicipality,
validateBahiaTerritoryPair,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
canCreateElectoralNucleus,
canCreateNucleusCoordinators,
@ -24,6 +29,31 @@ import { slugify } from '@/utilities/slug'
const trimmedText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
const normalizeTextArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
const seen = new Set<string>()
const result: string[] = []
for (const item of value) {
const trimmed = typeof item === 'string' ? item.trim() : ''
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
const resolveTerritoryArray = (
data: Record<string, unknown> | undefined,
originalDoc: Record<string, unknown> | undefined,
field: 'regions' | 'cities' | 'neighborhoods',
operation: 'create' | 'update' | 'delete',
): string[] => {
if (operation === 'update' && data && !(field in data)) {
return normalizeTextArray(originalDoc?.[field])
}
return normalizeTextArray(data?.[field])
}
const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({
data,
operation,
@ -32,34 +62,57 @@ const validateNucleusTerritoryAndZones: CollectionBeforeValidateHook = ({
if (!data) return data
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const region = trimmedText(nextData?.region)
const city = trimmedText(nextData?.city)
const neighborhood = trimmedText(nextData?.neighborhood)
const cities = resolveTerritoryArray(data, originalDoc, 'cities', operation)
const neighborhoods = resolveTerritoryArray(data, originalDoc, 'neighborhoods', operation)
let regions = resolveTerritoryArray(data, originalDoc, 'regions', operation)
const locality = trimmedText(nextData?.locality)
const territoryNotes = trimmedText(nextData?.territoryNotes)
if (region && !isBahiaIdentityTerritory(region)) {
throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
if (cities.length > MAX_NUCLEUS_CITIES) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_CITIES} municípios.`, 400)
}
if (city && !isBahiaMunicipality(city)) {
throw new APIError('Selecione um município válido da Bahia.', 400)
if (regions.length > MAX_NUCLEUS_REGIONS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_REGIONS} territórios.`, 400)
}
if (!validateBahiaTerritoryPair(region, city)) {
throw new APIError('O município não pertence ao território de identidade selecionado.', 400)
if (neighborhoods.length > MAX_NUCLEUS_NEIGHBORHOODS) {
throw new APIError(`Informe no máximo ${MAX_NUCLEUS_NEIGHBORHOODS} bairros.`, 400)
}
if (neighborhood && !city) {
throw new APIError('Informe o município antes do bairro.', 400)
for (const city of cities) {
if (!isBahiaMunicipality(city)) {
throw new APIError('Selecione um município válido da Bahia.', 400)
}
}
if (!region && !city && !locality) {
if (cities.length > 0) {
regions = territoriesForCities(cities)
} else {
for (const region of regions) {
if (!isBahiaIdentityTerritory(region)) {
throw new APIError('Selecione um território de identidade válido da Bahia.', 400)
}
}
}
if (neighborhoods.length > 0 && cities.length !== 1) {
throw new APIError(
cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
400,
)
}
if (regions.length === 0 && cities.length === 0 && !locality) {
throw new APIError(
'Informe o território de identidade, município ou localidade do núcleo.',
400,
)
}
data.region = region || null
data.city = city || null
data.neighborhood = neighborhood || null
data.regions = regions
data.cities = cities
data.neighborhoods = cities.length === 1 ? neighborhoods : []
data.locality = locality || null
data.territoryNotes = territoryNotes || null
@ -131,7 +184,7 @@ export const ElectoralNucleus: CollectionConfig = {
admin: {
group: 'Campanha',
useAsTitle: 'name',
defaultColumns: ['name', 'status', 'region', 'city', 'updatedAt'],
defaultColumns: ['name', 'status', 'regions', 'cities', 'updatedAt'],
},
access: {
create: canCreateElectoralNucleus,
@ -247,29 +300,40 @@ export const ElectoralNucleus: CollectionConfig = {
filterOptions: eligibleNucleusCoordinatorWhere,
},
{
name: 'region',
name: 'regions',
type: 'text',
label: 'Território de identidade',
label: 'Territórios de identidade',
hasMany: true,
index: true,
validate: (value: unknown) =>
value === null ||
value === undefined ||
(typeof value === 'string' && isBahiaIdentityTerritory(value))
maxRows: MAX_NUCLEUS_REGIONS,
validate: (value: unknown) => {
if (value === null || value === undefined) return true
if (!Array.isArray(value)) {
return 'Selecione um território de identidade válido da Bahia.'
}
return value.every(
(item) => typeof item === 'string' && isBahiaIdentityTerritory(item.trim()),
)
? true
: 'Selecione um território de identidade válido da Bahia.',
: 'Selecione um território de identidade válido da Bahia.'
},
},
{
name: 'city',
name: 'cities',
type: 'text',
label: 'Município',
label: 'Municípios',
hasMany: true,
maxLength: 120,
index: true,
maxRows: MAX_NUCLEUS_CITIES,
},
{
name: 'neighborhood',
name: 'neighborhoods',
type: 'text',
label: 'Bairro',
label: 'Bairros',
hasMany: true,
maxLength: 160,
maxRows: MAX_NUCLEUS_NEIGHBORHOODS,
},
{
name: 'locality',

View file

@ -28,6 +28,7 @@ import {
type NucleusDetailTab,
} from '@/utilities/nucleusDetailTabUi'
import type { AccessibleNucleusContext } from '@/utilities/nucleusPageData'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
import type {
NucleusDetailViewModel,
NucleusTabsViewModel,
@ -190,9 +191,7 @@ const OverviewContent = ({
</CardHeader>
<CardContent>
<p>
{[nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region]
.filter(Boolean)
.join(' · ')}
{formatNucleusTerritoryLabel(nucleus)}
</p>
</CardContent>
</Card>
@ -252,9 +251,7 @@ const TerritoryContent = ({ nucleus }: { nucleus: NucleusTabsViewModel }) => (
</CardHeader>
<CardContent className="flex flex-col gap-2">
<p>
{[nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region]
.filter(Boolean)
.join(' · ')}
{formatNucleusTerritoryLabel(nucleus)}
</p>
{nucleus.kind === 'staff' && nucleus.territoryNotes ? (
<p className="text-muted-foreground">{nucleus.territoryNotes}</p>

View file

@ -14,7 +14,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { organizationKindLabels } from '@/utilities/nucleusUi'
import { formatNucleusTerritoryLabel, organizationKindLabels } from '@/utilities/nucleusUi'
import type { NucleusListViewModel } from '@/utilities/nucleusViewModels'
export type NucleusListProps = {
@ -32,7 +32,7 @@ const getInitials = (name: string): string =>
.toUpperCase()
const getTerritory = (nucleus: NucleusListViewModel): string =>
[nucleus.neighborhood, nucleus.locality, nucleus.city, nucleus.region].filter(Boolean).join(' · ')
formatNucleusTerritoryLabel(nucleus)
const getOrganization = (nucleus: NucleusListViewModel): string =>
nucleus.organizationLabel ?? organizationKindLabels[nucleus.organizationKind]

View file

@ -1,22 +1,62 @@
'use client'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { territoryForCity } from '@/lib/bahiaTerritories'
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 {
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import {
municipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
type TerritoryValues = {
region?: string | null
city?: string | null
neighborhood?: string | null
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
}
const uniquePreserveOrder = (values: string[]): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
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 NucleusTerritoryFields = ({
values,
fieldErrors = {},
@ -24,86 +64,240 @@ export const NucleusTerritoryFields = ({
values?: TerritoryValues
fieldErrors?: Record<string, string[]>
}) => {
const [region, setRegion] = useState(values?.region ?? '')
const [city, setCity] = useState(values?.city ?? '')
const [neighborhood, setNeighborhood] = useState(values?.neighborhood ?? '')
const [regions, setRegions] = useState(() => uniquePreserveOrder(values?.regions ?? []))
const [cities, setCities] = useState(() => uniquePreserveOrder(values?.cities ?? []))
const [neighborhoods, setNeighborhoods] = useState(() =>
uniquePreserveOrder(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) => fieldErrors[name]?.[0]
const visibleRegionError = regionError ?? errorFor('region')
const visibleCityError = cityError ?? errorFor('city')
const cityOptions = municipalityComboboxOptions(region)
const derivedRegions = useMemo(() => territoriesForCities(cities), [cities])
const displayRegions = cities.length > 0 ? derivedRegions : regions
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const visibleRegionError = regionError ?? errorFor('regions')
const visibleCityError = cityError ?? errorFor('cities')
const visibleNeighborhoodError = neighborhoodError ?? errorFor('neighborhoods')
const availableCityOptions = municipalityComboboxOptions().filter(
(option) => !cities.includes(option.value),
)
const availableRegionOptions = territoryComboboxOptions.filter(
(option) => !regions.includes(option.value),
)
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 = uniquePreserveOrder([...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) => uniquePreserveOrder([...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) => uniquePreserveOrder([...current, trimmed]))
setNeighborhoodDraft('')
setNeighborhoodError(undefined)
}
return (
<div className="grid gap-4 sm:grid-cols-2">
<input type="hidden" name="region" value={region} />
<input type="hidden" name="city" value={city} />
<Field data-invalid={Boolean(visibleRegionError)}>
<FieldLabel htmlFor="nucleus-lookup-a">Território de identidade</FieldLabel>
<StrictCombobox
id="nucleus-lookup-a"
options={territoryComboboxOptions}
value={region}
onValueChange={(nextRegion) => {
if (city && nextRegion && territoryForCity(city) !== nextRegion) {
setCity('')
setNeighborhood('')
}
setRegion(nextRegion)
setRegionError(undefined)
setCityError(undefined)
}}
onInvalid={() => {
setRegionError('Selecione um território de identidade válido da Bahia.')
}}
error={visibleRegionError}
/>
{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)}>
<FieldLabel htmlFor="nucleus-lookup-b">Município</FieldLabel>
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor="nucleus-lookup-b">Municípios</FieldLabel>
{cities.length ? (
<ChipList
values={cities}
onRemove={removeCity}
removeLabel={(city) => `Remover município ${city}`}
/>
) : null}
<StrictCombobox
id="nucleus-lookup-b"
options={cityOptions}
value={city}
options={availableCityOptions}
value={cityDraft}
onValueChange={(nextCity) => {
if (nextCity !== city) setNeighborhood('')
setCity(nextCity)
setCityError(undefined)
const nextRegion = territoryForCity(nextCity)
if (nextRegion) {
setRegion(nextRegion)
setRegionError(undefined)
}
if (nextCity) addCity(nextCity)
else setCityDraft('')
}}
onInvalid={() => {
setNeighborhood('')
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={!city.trim()} data-invalid={Boolean(errorFor('neighborhood'))}>
<FieldLabel htmlFor="neighborhood">Bairro</FieldLabel>
<Input
id="neighborhood"
name="neighborhood"
value={neighborhood}
onChange={(event) => setNeighborhood(event.target.value)}
disabled={!city.trim()}
autoComplete="address-level3"
aria-invalid={Boolean(errorFor('neighborhood'))}
aria-describedby={errorFor('neighborhood') ? 'neighborhood-error' : undefined}
/>
{errorFor('neighborhood') ? (
<FieldError id="neighborhood-error">{errorFor('neighborhood')}</FieldError>
<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>
</div>

View file

@ -649,3 +649,12 @@ export const validateBahiaTerritoryPair = (
export const citiesForTerritory = (territory: BahiaIdentityTerritory): readonly string[] =>
bahiaIdentityTerritoryRecords.find(({ name }) => name === territory)?.municipalities ?? []
export const territoriesForCities = (cities: readonly string[]): BahiaIdentityTerritory[] => {
const territories = new Set<BahiaIdentityTerritory>()
for (const city of cities) {
const territory = territoryForCity(city)
if (territory) territories.add(territory)
}
return bahiaIdentityTerritories.filter((territory) => territories.has(territory))
}

View file

@ -114,6 +114,21 @@ export const repeatedRelationshipFormValues = (formData: FormData, field: string
return [...new Set(values)].sort((left, right) => left - right)
}
export const repeatedFormTexts = (formData: FormData, field: string): string[] => {
const seen = new Set<string>()
const values: string[] = []
for (const value of formData.getAll(field)) {
if (typeof value !== 'string') {
throw new FormDataBoundaryError(field, 'Arquivos não são aceitos neste campo.')
}
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
values.push(trimmed)
}
return values
}
export const checkboxFormValue = (formData: FormData, field: string): boolean => {
const entry = formEntry(formData, field)
if (!entry.present) return false

View file

@ -1,9 +1,9 @@
import { z } from 'zod'
import {
bahiaIdentityTerritories,
isBahiaIdentityTerritory,
isBahiaMunicipality,
validateBahiaTerritoryPair,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
positiveRelationshipId,
@ -33,15 +33,58 @@ export const sectorKinds = [
'outro',
] as const
const optionalBahiaCity = trimmedOptionalText(120).refine(
(value) => value === undefined || isBahiaMunicipality(value),
'Selecione um município válido da Bahia.',
)
export const MAX_NUCLEUS_REGIONS = 27
export const MAX_NUCLEUS_CITIES = 27
export const MAX_NUCLEUS_NEIGHBORHOODS = 30
const nullableBahiaCity = trimmedNullableText(120).refine(
(value) => value === undefined || value === null || isBahiaMunicipality(value),
'Selecione um município válido da Bahia.',
)
const dedupeTrimmedStrings = (values: string[]): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {
const trimmed = value.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
result.push(trimmed)
}
return result
}
const regionsArraySchema = z
.array(z.string())
.max(MAX_NUCLEUS_REGIONS)
.transform(dedupeTrimmedStrings)
.superRefine((regions, context) => {
for (const [index, region] of regions.entries()) {
if (!isBahiaIdentityTerritory(region)) {
context.addIssue({
code: 'custom',
message: 'Selecione um território de identidade válido da Bahia.',
path: [index],
})
}
}
})
const citiesArraySchema = z
.array(z.string())
.max(MAX_NUCLEUS_CITIES)
.transform(dedupeTrimmedStrings)
.superRefine((cities, context) => {
for (const [index, city] of cities.entries()) {
if (city.length > 120 || !isBahiaMunicipality(city)) {
context.addIssue({
code: 'custom',
message: 'Selecione um município válido da Bahia.',
path: [index],
})
}
}
})
const neighborhoodsArraySchema = z
.array(z.string().max(160))
.max(MAX_NUCLEUS_NEIGHBORHOODS)
.transform(dedupeTrimmedStrings)
const tseZoneSchema = z.object({
zoneNumber: z.number().int().min(1).max(999),
@ -70,9 +113,9 @@ const ticketAllianceSchema = z.object({
const nucleusFieldsSchema = z.object({
name: z.string().trim().min(2).max(160),
region: z.enum(bahiaIdentityTerritories).optional(),
city: optionalBahiaCity,
neighborhood: trimmedOptionalText(160),
regions: regionsArraySchema.optional(),
cities: citiesArraySchema.optional(),
neighborhoods: neighborhoodsArraySchema.optional(),
locality: trimmedOptionalText(160),
territoryNotes: trimmedOptionalText(2000),
organizationKind: z.enum(organizationKinds),
@ -87,9 +130,9 @@ const nucleusFieldsSchema = z.object({
})
type TerritoryValidationInput = {
region?: (typeof bahiaIdentityTerritories)[number] | null
city?: string | null
neighborhood?: string | null
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
locality?: string | null
tseZones?: Array<{ zoneNumber: number }>
}
@ -99,37 +142,59 @@ const validateTerritoryAndZones = (
context: z.RefinementCtx,
mode: 'create' | 'patch',
) => {
const regions = data.regions ?? []
const cities = data.cities ?? []
const neighborhoods = data.neighborhoods ?? []
const locality = data.locality ?? undefined
const geographyWasFullyProvided =
data.region !== undefined && data.city !== undefined && data.locality !== undefined
data.regions !== undefined && data.cities !== undefined && data.locality !== undefined
if (
!data.region &&
!data.city &&
!data.locality &&
regions.length === 0 &&
cities.length === 0 &&
!locality &&
(mode === 'create' || geographyWasFullyProvided)
) {
context.addIssue({
code: 'custom',
message: 'Informe o território de identidade, município ou localidade do núcleo.',
path: ['city'],
path: ['cities'],
})
}
const neighborhoodLacksCity =
mode === 'create' ? !data.city : data.city === null
if (data.neighborhood && neighborhoodLacksCity) {
if (neighborhoods.length > 0 && cities.length !== 1) {
context.addIssue({
code: 'custom',
message: 'Informe o município antes do bairro.',
path: ['neighborhood'],
message:
cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
path: ['neighborhoods'],
})
}
if (!validateBahiaTerritoryPair(data.region, data.city)) {
context.addIssue({
code: 'custom',
message: 'O município não pertence ao território de identidade selecionado.',
path: ['city'],
})
if (cities.length > 0) {
for (const city of cities) {
if (!isBahiaMunicipality(city)) {
context.addIssue({
code: 'custom',
message: 'Selecione um município válido da Bahia.',
path: ['cities'],
})
break
}
}
} else {
for (const region of regions) {
if (!isBahiaIdentityTerritory(region)) {
context.addIssue({
code: 'custom',
message: 'Selecione um território de identidade válido da Bahia.',
path: ['regions'],
})
break
}
}
}
const zoneNumbers = data.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? []
@ -148,14 +213,26 @@ export const nucleusCreateSchema = nucleusFieldsSchema
organizationKind: z.enum(organizationKinds).default('territorial'),
})
.superRefine((data, context) => validateTerritoryAndZones(data, context, 'create'))
.transform((data) => {
const cities = data.cities ?? []
const regions =
cities.length > 0 ? territoriesForCities(cities) : (data.regions ?? []).filter(isBahiaIdentityTerritory)
const neighborhoods = cities.length === 1 ? (data.neighborhoods ?? []) : []
return {
...data,
regions,
cities,
neighborhoods,
}
})
export const nucleusUpdateSchema = nucleusFieldsSchema
.partial()
.extend({
id: positiveRelationshipId,
region: z.enum(bahiaIdentityTerritories).nullable().optional(),
city: nullableBahiaCity,
neighborhood: trimmedNullableText(160),
regions: regionsArraySchema.nullable().optional(),
cities: citiesArraySchema.nullable().optional(),
neighborhoods: neighborhoodsArraySchema.nullable().optional(),
locality: trimmedNullableText(160),
territoryNotes: trimmedNullableText(2000),
organizationLabel: trimmedNullableText(160),
@ -171,6 +248,32 @@ export const nucleusUpdateSchema = nucleusFieldsSchema
.optional(),
})
.superRefine((data, context) => validateTerritoryAndZones(data, context, 'patch'))
.transform((data) => {
const cities = data.cities === undefined ? undefined : (data.cities ?? [])
const neighborhoods =
data.neighborhoods === undefined
? undefined
: cities !== undefined && cities.length !== 1
? []
: (data.neighborhoods ?? [])
const regions =
cities === undefined
? data.regions === undefined
? undefined
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
: cities.length > 0
? territoriesForCities(cities)
: data.regions === undefined
? []
: (data.regions ?? []).filter(isBahiaIdentityTerritory)
return {
...data,
...(regions !== undefined ? { regions } : {}),
...(cities !== undefined ? { cities } : {}),
...(neighborhoods !== undefined ? { neighborhoods } : {}),
}
})
export type NucleusCreateInput = z.input<typeof nucleusCreateSchema>
export type NucleusUpdateInput = z.input<typeof nucleusUpdateSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'electoral_nucleus_texts'
) THEN
CREATE TABLE "electoral_nucleus_texts" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer NOT NULL,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"text" varchar
);
ALTER TABLE "electoral_nucleus_texts"
ADD CONSTRAINT "electoral_nucleus_texts_parent_fk"
FOREIGN KEY ("parent_id") REFERENCES "public"."electoral_nucleus"("id")
ON DELETE cascade ON UPDATE no action;
CREATE INDEX "electoral_nucleus_texts_order_parent"
ON "electoral_nucleus_texts" USING btree ("order", "parent_id");
CREATE INDEX "electoral_nucleus_texts_parent_idx"
ON "electoral_nucleus_texts" USING btree ("parent_id");
CREATE INDEX "electoral_nucleus_texts_path_idx"
ON "electoral_nucleus_texts" USING btree ("path");
CREATE INDEX "electoral_nucleus_texts_text_idx"
ON "electoral_nucleus_texts" USING btree ("text");
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'electoral_nucleus'
AND column_name = 'region'
) THEN
INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text")
SELECT 1, "id", 'regions', "region"
FROM "electoral_nucleus"
WHERE "region" IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "electoral_nucleus_texts" existing
WHERE existing."parent_id" = "electoral_nucleus"."id"
AND existing."path" = 'regions'
);
INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text")
SELECT 1, "id", 'cities', "city"
FROM "electoral_nucleus"
WHERE "city" IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "electoral_nucleus_texts" existing
WHERE existing."parent_id" = "electoral_nucleus"."id"
AND existing."path" = 'cities'
);
INSERT INTO "electoral_nucleus_texts" ("order", "parent_id", "path", "text")
SELECT 1, "id", 'neighborhoods', "neighborhood"
FROM "electoral_nucleus"
WHERE "neighborhood" IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM "electoral_nucleus_texts" existing
WHERE existing."parent_id" = "electoral_nucleus"."id"
AND existing."path" = 'neighborhoods'
);
DROP INDEX IF EXISTS "electoral_nucleus_region_idx";
DROP INDEX IF EXISTS "electoral_nucleus_city_idx";
ALTER TABLE "electoral_nucleus" DROP COLUMN "region";
ALTER TABLE "electoral_nucleus" DROP COLUMN "city";
ALTER TABLE "electoral_nucleus" DROP COLUMN "neighborhood";
END IF;
END $$;
`)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'electoral_nucleus_texts'
) THEN
IF EXISTS (
SELECT 1
FROM "electoral_nucleus_texts"
GROUP BY "parent_id", "path"
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION
'Cannot down-migrate territorio_multi_municipio_bairro: one or more nuclei have multiple regions, cities, or neighborhoods.';
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'electoral_nucleus'
AND column_name = 'region'
) THEN
ALTER TABLE "electoral_nucleus" ADD COLUMN "region" varchar;
ALTER TABLE "electoral_nucleus" ADD COLUMN "city" varchar;
ALTER TABLE "electoral_nucleus" ADD COLUMN "neighborhood" varchar;
END IF;
UPDATE "electoral_nucleus" nucleus
SET "region" = texts."text"
FROM "electoral_nucleus_texts" texts
WHERE texts."parent_id" = nucleus."id"
AND texts."path" = 'regions'
AND texts."order" = 1;
UPDATE "electoral_nucleus" nucleus
SET "city" = texts."text"
FROM "electoral_nucleus_texts" texts
WHERE texts."parent_id" = nucleus."id"
AND texts."path" = 'cities'
AND texts."order" = 1;
UPDATE "electoral_nucleus" nucleus
SET "neighborhood" = texts."text"
FROM "electoral_nucleus_texts" texts
WHERE texts."parent_id" = nucleus."id"
AND texts."path" = 'neighborhoods'
AND texts."order" = 1;
DROP TABLE "electoral_nucleus_texts" CASCADE;
CREATE INDEX IF NOT EXISTS "electoral_nucleus_region_idx"
ON "electoral_nucleus" USING btree ("region");
CREATE INDEX IF NOT EXISTS "electoral_nucleus_city_idx"
ON "electoral_nucleus" USING btree ("city");
END IF;
END $$;
`)
}

View file

@ -4,6 +4,7 @@ import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_a
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
export const migrations = [
@ -37,10 +38,14 @@ export const migrations = [
down: migration_20260718_010733_consolidate_campaign_schema.down,
name: '20260718_010733_consolidate_campaign_schema',
},
{
up: migration_20260718_190559_territorio_multi_municipio_bairro.up,
down: migration_20260718_190559_territorio_multi_municipio_bairro.down,
name: '20260718_190559_territorio_multi_municipio_bairro',
},
{
up: migration_20260718_195854_add_election_results.up,
down: migration_20260718_195854_add_election_results.down,
name: '20260718_195854_add_election_results',
},
]

View file

@ -351,9 +351,9 @@ export interface ElectoralNucleus {
slug: string;
status: 'ativo' | 'arquivado';
coordinators?: (number | CampaignUser)[] | null;
region?: string | null;
city?: string | null;
neighborhood?: string | null;
regions?: string[] | null;
cities?: string[] | null;
neighborhoods?: string[] | null;
locality?: string | null;
territoryNotes?: string | null;
organizationKind:
@ -994,9 +994,9 @@ export interface ElectoralNucleusSelect<T extends boolean = true> {
slug?: T;
status?: T;
coordinators?: T;
region?: T;
city?: T;
neighborhood?: T;
regions?: T;
cities?: T;
neighborhoods?: T;
locality?: T;
territoryNotes?: T;
organizationKind?: T;

View file

@ -15,9 +15,9 @@ import { requireRelationshipId } from '@/utilities/relationship'
const dashboardStaffNucleusSelect = {
name: true,
slug: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
organizationKind: true,
organizationLabel: true,
@ -31,9 +31,9 @@ const dashboardStaffNucleusSelect = {
const dashboardLeadershipNucleusSelect = {
name: true,
slug: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
organizationKind: true,
organizationLabel: true,
@ -51,9 +51,9 @@ type RawDashboardNucleus = {
id: number
name: string
slug: string
region?: DashboardNucleusRecord['region']
city?: string | null
neighborhood?: string | null
regions?: string[] | null
cities?: string[] | null
neighborhoods?: string[] | null
locality?: string | null
organizationKind: DashboardNucleusRecord['organizationKind']
organizationLabel?: string | null
@ -64,6 +64,9 @@ type RawDashboardNucleus = {
lastUpdateAt?: string | null
}
const asStringArray = (value: string[] | null | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
type RawDashboardLeadership = {
nucleus: number | { id: number }
supportStatus?: unknown
@ -76,9 +79,9 @@ const toDashboardNucleusRecord = (
id: nucleus.id,
name: nucleus.name,
slug: nucleus.slug,
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
organizationKind: nucleus.organizationKind,
organizationLabel: nucleus.organizationLabel ?? null,

View file

@ -1,5 +1,6 @@
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import type { SupportStatus } from '@/lib/schemas/leadership'
import { formatNucleusTerritoryLabel } from '@/utilities/nucleusUi'
export type CoordinatorSummary = {
id: number
@ -11,9 +12,9 @@ export type DashboardNucleusRecord = {
id: number
name: string
slug: string
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
organizationKind: ElectoralNucleus['organizationKind']
organizationLabel: string | null
@ -105,8 +106,8 @@ const percentage = (part: number, total: number): number =>
const isOverdue = (lastUpdateAt: string | null, now: Date): boolean =>
!lastUpdateAt || now.getTime() - new Date(lastUpdateAt).getTime() > 7 * 24 * 60 * 60 * 1000
const territoryLabel = ({ neighborhood, locality, city, region }: DashboardNucleusRecord): string =>
[neighborhood, locality, city, region].filter(Boolean).join(' · ') || 'Território não informado'
const territoryLabel = (nucleus: DashboardNucleusRecord): string =>
formatNucleusTerritoryLabel(nucleus) || 'Território não informado'
const organizationLabel = ({
organizationKind,

View file

@ -4,7 +4,7 @@ import {
bahiaIdentityTerritories,
type BahiaIdentityTerritory,
isBahiaMunicipality,
validateBahiaTerritoryPair,
territoriesForCities,
} from '@/lib/bahiaTerritories'
import {
organizationKinds,
@ -16,6 +16,7 @@ import {
checkboxFormValue,
FormDataBoundaryError,
optionalFormText,
repeatedFormTexts,
repeatedRelationshipFormValues,
requiredRelationshipFormValue,
} from '@/lib/formData'
@ -27,15 +28,19 @@ const parseZoneNumbers = (value: string | undefined): Array<{ zoneNumber: number
const parseSharedNucleusFormData = (formData: FormData) => {
const organizationKind = optionalFormText(formData, 'organizationKind')
const sectorKind = optionalFormText(formData, 'sectorKind')
const region = optionalFormText(formData, 'region')
const city = optionalFormText(formData, 'city')
const cities = repeatedFormTexts(formData, 'cities')
const regions =
cities.length > 0
? territoriesForCities(cities)
: (repeatedFormTexts(formData, 'regions') as BahiaIdentityTerritory[])
const neighborhoods = cities.length === 1 ? repeatedFormTexts(formData, 'neighborhoods') : []
const partnerName = optionalFormText(formData, 'partnerName')
return {
name: optionalFormText(formData, 'name') ?? '',
region: region as BahiaIdentityTerritory | undefined,
city,
neighborhood: city ? optionalFormText(formData, 'neighborhood') : undefined,
regions,
cities,
neighborhoods,
locality: optionalFormText(formData, 'locality'),
territoryNotes: optionalFormText(formData, 'territoryNotes'),
organizationKind: organizationKind as (typeof organizationKinds)[number],
@ -54,24 +59,40 @@ const parseSharedNucleusFormData = (formData: FormData) => {
}
const validateTerritoryFormData = (
territory: { region?: string; city?: string; locality?: string },
territory: {
regions: string[]
cities: string[]
neighborhoods: string[]
locality?: string
},
{ requireGeography }: { requireGeography: boolean },
) => {
if (territory.region && !bahiaIdentityTerritories.includes(territory.region as never)) {
throw new FormDataBoundaryError('region', 'Território de identidade inválido.')
for (const region of territory.regions) {
if (!bahiaIdentityTerritories.includes(region as never)) {
throw new FormDataBoundaryError('regions', 'Território de identidade inválido.')
}
}
if (territory.city && !isBahiaMunicipality(territory.city)) {
throw new FormDataBoundaryError('city', 'Município inválido.')
for (const city of territory.cities) {
if (!isBahiaMunicipality(city)) {
throw new FormDataBoundaryError('cities', 'Município inválido.')
}
}
if (!validateBahiaTerritoryPair(territory.region as BahiaIdentityTerritory, territory.city)) {
if (territory.neighborhoods.length > 0 && territory.cities.length !== 1) {
throw new FormDataBoundaryError(
'city',
'O município não pertence ao território de identidade selecionado.',
'neighborhoods',
territory.cities.length === 0
? 'Informe o município antes do bairro.'
: 'Bairros só podem ser informados quando há exatamente um município.',
)
}
if (requireGeography && !territory.region && !territory.city && !territory.locality) {
if (
requireGeography &&
territory.regions.length === 0 &&
territory.cities.length === 0 &&
!territory.locality
) {
throw new FormDataBoundaryError(
'city',
'cities',
'Informe o território de identidade, município ou localidade do núcleo.',
)
}
@ -96,9 +117,9 @@ export const parseNucleusUpdateFormData = (formData: FormData): NucleusUpdateInp
return {
...shared,
id: requiredRelationshipFormValue(formData, 'id'),
region: shared.region ?? null,
city: shared.city ?? null,
neighborhood: shared.neighborhood ?? null,
regions: shared.regions,
cities: shared.cities,
neighborhoods: shared.neighborhoods,
locality: shared.locality ?? null,
territoryNotes: shared.territoryNotes ?? null,
organizationLabel: shared.organizationLabel ?? null,

View file

@ -36,9 +36,9 @@ const nucleusContextSelect = {
name: true,
slug: true,
status: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
organizationKind: true,
organizationLabel: true,

View file

@ -33,6 +33,26 @@ export const sectorKindLabels: Record<NonNullable<ElectoralNucleus['sectorKind']
outro: 'Outro',
}
export const formatNucleusTerritoryLabel = ({
neighborhoods = [],
locality,
cities = [],
regions = [],
}: {
neighborhoods?: string[] | null
locality?: string | null
cities?: string[] | null
regions?: string[] | null
}): string =>
[
(neighborhoods ?? []).join(', '),
locality,
(cities ?? []).join(', '),
(regions ?? []).join(', '),
]
.filter(Boolean)
.join(' · ')
export type NucleusListState = {
page: number
q?: string
@ -130,8 +150,8 @@ export const buildNucleusListWhere = (state: NucleusListState): Where => {
}
filters.push({ or: searchFilters })
}
if (state.region) filters.push({ region: { equals: state.region } })
if (state.city) filters.push({ city: { equals: state.city } })
if (state.region) filters.push({ regions: { equals: state.region } })
if (state.city) filters.push({ cities: { equals: state.city } })
if (state.tseZone) {
filters.push({ 'tseZones.zoneNumber': { equals: state.tseZone } })
}

View file

@ -1,12 +1,15 @@
import type { CampaignUser, ElectoralNucleus } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
const asStringArray = (value: string[] | null | undefined): string[] =>
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
export const nucleusFormSelect = {
name: true,
slug: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
territoryNotes: true,
organizationKind: true,
@ -20,9 +23,9 @@ export const nucleusListSelect = {
name: true,
slug: true,
coordinators: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
organizationKind: true,
organizationLabel: true,
@ -37,9 +40,9 @@ export type NucleusListViewModel = {
name: string
slug: string
coordinators: Array<{ id: number; name: string }>
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
organizationKind: ElectoralNucleus['organizationKind']
organizationLabel: string | null
@ -53,9 +56,9 @@ export const nucleusStaffDetailSelect = {
name: true,
slug: true,
status: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
territoryNotes: true,
organizationKind: true,
@ -91,9 +94,9 @@ export const nucleusLeadershipDetailSelect = {
name: true,
slug: true,
status: true,
region: true,
city: true,
neighborhood: true,
regions: true,
cities: true,
neighborhoods: true,
locality: true,
organizationKind: true,
organizationLabel: true,
@ -105,9 +108,9 @@ export type NucleusFormViewModel = {
id: number
name: string
slug: string
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
territoryNotes: string | null
organizationKind: ElectoralNucleus['organizationKind']
@ -124,9 +127,9 @@ export type NucleusFormViewModel = {
export type StaffNucleusTabsViewModel = {
kind: 'staff'
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
territoryNotes: string | null
tseZones: number[]
@ -150,9 +153,9 @@ export type StaffNucleusTabsViewModel = {
export type LeadershipNucleusTabsViewModel = {
kind: 'leadership'
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
tseZones: number[]
}
@ -164,9 +167,9 @@ type NucleusDetailBaseViewModel = {
name: string
slug: string
status: ElectoralNucleus['status']
region: ElectoralNucleus['region']
city: string | null
neighborhood: string | null
regions: string[]
cities: string[]
neighborhoods: string[]
locality: string | null
organizationKind: ElectoralNucleus['organizationKind']
organizationLabel: string | null
@ -206,9 +209,9 @@ export const toNucleusListViewModel = (nucleus: ElectoralNucleus): NucleusListVi
isPopulatedRelationship<CampaignUser>(coordinator),
)
.map(({ id, name }) => ({ id, name })) ?? [],
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
organizationKind: nucleus.organizationKind,
organizationLabel: nucleus.organizationLabel ?? null,
@ -222,9 +225,9 @@ export const toNucleusFormViewModel = (nucleus: ElectoralNucleus): NucleusFormVi
id: nucleus.id,
name: nucleus.name,
slug: nucleus.slug,
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
territoryNotes: nucleus.territoryNotes ?? null,
organizationKind: nucleus.organizationKind,
@ -246,9 +249,9 @@ const toNucleusDetailBaseViewModel = (nucleus: ElectoralNucleus): NucleusDetailB
name: nucleus.name,
slug: nucleus.slug,
status: nucleus.status,
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
organizationKind: nucleus.organizationKind,
organizationLabel: nucleus.organizationLabel ?? null,
@ -295,9 +298,9 @@ export const toStaffNucleusTabsViewModel = (
nucleus: ElectoralNucleus,
): StaffNucleusTabsViewModel => ({
kind: 'staff',
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
territoryNotes: nucleus.territoryNotes ?? null,
tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [],
@ -328,9 +331,9 @@ export const toLeadershipNucleusTabsViewModel = (
nucleus: ElectoralNucleus,
): LeadershipNucleusTabsViewModel => ({
kind: 'leadership',
region: nucleus.region ?? null,
city: nucleus.city ?? null,
neighborhood: nucleus.neighborhood ?? null,
regions: asStringArray(nucleus.regions),
cities: asStringArray(nucleus.cities),
neighborhoods: asStringArray(nucleus.neighborhoods),
locality: nucleus.locality ?? null,
tseZones: nucleus.tseZones?.map(({ zoneNumber }) => zoneNumber) ?? [],
})

View file

@ -75,7 +75,7 @@ test.describe('Campaign authentication and role scope', () => {
collection: 'electoralNucleus',
data: {
name: foreignName,
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -133,7 +133,7 @@ test.describe('Campaign authentication and role scope', () => {
collection: 'electoralNucleus',
data: {
name: nucleusName,
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -143,7 +143,7 @@ test.describe('Campaign authentication and role scope', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo fora do escopo'),
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -27,7 +27,7 @@ test.describe('Campaign composition and accessibility', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo composição'),
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -27,7 +27,7 @@ test.describe('Campaign estimates and field updates', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo estimativa'),
city: 'Salvador',
cities: ['Salvador'],
coordinators: [staff.id],
organizationKind: 'territorial',
} as never,
@ -86,7 +86,7 @@ test.describe('Campaign estimates and field updates', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo atualização'),
city: 'Salvador',
cities: ['Salvador'],
coordinators: [staff.id],
organizationKind: 'territorial',
} as never,

View file

@ -26,7 +26,7 @@ test.describe('Campaign leadership and invite journeys', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo liderança'),
region: 'Irecê',
regions: ['Irecê'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -95,7 +95,7 @@ test.describe('Campaign leadership and invite journeys', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo painel'),
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -252,7 +252,7 @@ test.describe('Campaign leadership and invite journeys', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo convite'),
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -27,7 +27,7 @@ test.describe('Campaign nucleus journeys', () => {
await campaign.login(page, generalEmail, password)
await page.goto(`${campaign.baseURL}/campanha/nucleos/novo`)
await page.getByLabel('Nome do núcleo *').fill(nucleusName)
await page.getByLabel('Território de identidade').fill('Irecê')
await page.getByLabel('Territórios de identidade').fill('Irecê')
await page.getByRole('option', { name: 'Irecê', exact: true }).click()
const zoneInput = page.getByLabel('Adicionar Zona TSE')
await zoneInput.pressSequentially('58,12 58')
@ -92,7 +92,7 @@ test.describe('Campaign nucleus journeys', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo designação'),
city: 'Salvador',
cities: ['Salvador'],
coordinators: [general.id],
organizationKind: 'territorial',
} as never,
@ -145,7 +145,7 @@ test.describe('Campaign nucleus journeys', () => {
collection: 'electoralNucleus',
data: {
name: nucleusName,
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -199,7 +199,7 @@ test.describe('Campaign nucleus journeys', () => {
collection: 'electoralNucleus',
data: {
name: campaign.fixtures.value('Núcleo responsivo'),
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -269,7 +269,7 @@ export const test = base.extend<CampaignE2ETestFixtures>({
collection: 'electoralNucleus',
data: {
name: sentinelName,
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -354,7 +354,7 @@ export class CampaignFixtures {
name,
slug: input.slug ?? this.value('nucleo'),
status: input.status ?? 'ativo',
city: input.city === undefined ? 'Salvador' : input.city,
cities: input.cities === undefined ? ['Salvador'] : input.cities,
organizationKind: input.organizationKind ?? 'territorial',
}
const nucleus = await this.rootPayload.create({

View file

@ -92,19 +92,19 @@ describe('Bahia identity territory mapping', () => {
expect(validateBahiaTerritoryPair('Chapada Diamantina', 'Salvador')).toBe(false)
})
it('rejects mismatched territory and municipality pairs on the server', () => {
const mismatched = {
name: 'Núcleo incompatível',
region: 'Chapada Diamantina',
city: 'Salvador',
it('derives the region from cities, overriding a mismatched manually provided region', () => {
const withMismatchedRegion = {
name: 'Núcleo com território derivado',
regions: ['Chapada Diamantina'],
cities: ['Salvador'],
organizationKind: 'territorial',
} as const
expect(() => nucleusCreateSchema.parse(mismatched)).toThrow(
'município não pertence ao território',
)
expect(() => nucleusUpdateSchema.parse({ id: 1, ...mismatched })).toThrow(
'município não pertence ao território',
)
expect(nucleusCreateSchema.parse(withMismatchedRegion).regions).toEqual([
'Metropolitano de Salvador',
])
expect(
nucleusUpdateSchema.parse({ id: 1, ...withMismatchedRegion }).regions,
).toEqual(['Metropolitano de Salvador'])
})
})

View file

@ -324,7 +324,7 @@ describe('campaign authentication foundation', () => {
slug: slugify(nucleusName),
status: nucleusStatus,
coordinators: [target.id],
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
},
depth: 0,

View file

@ -125,9 +125,7 @@ const createPublicInviteScenario = async (
collection: 'electoralNucleus',
data: {
name: campaignFixtures().value('Núcleo convite UI'),
territoryKind: 'municipio',
state: 'BA',
city: 'Salvador',
cities: ['Salvador'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -107,7 +107,7 @@ describe('campaign leadership exported form actions', () => {
collection: 'electoralNucleus',
data: {
name: campaignFixtures().value('Núcleo regional action'),
region: 'Irecê',
regions: ['Irecê'],
organizationKind: 'territorial',
} as never,
depth: 0,
@ -158,7 +158,7 @@ describe('campaign leadership exported form actions', () => {
collection: 'electoralNucleus',
data: {
name: campaignFixtures().value('Núcleo ação mínima'),
region: 'Irecê',
regions: ['Irecê'],
organizationKind: 'territorial',
} as never,
depth: 0,

View file

@ -35,7 +35,7 @@ const validNucleusInput = {
get name() {
return campaignFixtures().value('Núcleo Chapada')
},
region: 'Chapada Diamantina' as const,
regions: ['Chapada Diamantina'],
locality: 'Chapada Diamantina',
organizationKind: 'territorial' as const,
tseZones: [{ zoneNumber: 58, label: '58ª ZE — Seabra' }],
@ -59,10 +59,10 @@ describe('electoral nucleus domain', () => {
).toBe(417)
})
it('requires a region, city, or locality for every nucleus', () => {
it('requires regions, cities, or a locality for every nucleus', () => {
const result = nucleusCreateSchema.safeParse({
...validNucleusInput,
region: undefined,
regions: undefined,
locality: undefined,
})
@ -79,12 +79,13 @@ describe('electoral nucleus domain', () => {
expect(
nucleusUpdateSchema.parse({
id: 1,
city: ' ',
cities: [],
territoryNotes: ' ',
}),
).toEqual({
id: 1,
city: null,
cities: [],
regions: [],
territoryNotes: null,
})
expect(nucleusUpdateSchema.parse({ id: 1 })).toEqual({ id: 1 })
@ -136,7 +137,7 @@ describe('electoral nucleus domain', () => {
sectorKind: 'sindical',
})
expect(result.region).toBe('Chapada Diamantina')
expect(result.regions).toEqual(['Chapada Diamantina'])
expect(result.organizationKind).toBe('sindicato')
expect(result.sectorKind).toBe('sindical')
})
@ -202,26 +203,26 @@ describe('electoral nucleus domain', () => {
expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1)
})
it('validates Bahia regions, cities, territory pairs, and neighborhood dependencies', async () => {
it('validates Bahia regions, cities, and neighborhood dependencies', async () => {
expect(() =>
nucleusCreateSchema.parse({
...validNucleusInput,
region: 'Região inventada',
regions: ['Região inventada'],
}),
).toThrow()
expect(() =>
nucleusCreateSchema.parse({
...validNucleusInput,
city: 'Município inventado',
cities: ['Município inventado'],
}),
).toThrow('município válido')
expect(() =>
nucleusCreateSchema.parse({
...validNucleusInput,
city: undefined,
neighborhood: 'Centro',
cities: undefined,
neighborhoods: ['Centro'],
}),
).toThrow('município antes do bairro')
@ -230,86 +231,122 @@ describe('electoral nucleus domain', () => {
collection: 'electoralNucleus',
data: {
...validNucleusInput,
city: 'Salvador',
cities: ['Município inventado'],
} as never,
depth: 0,
}),
).rejects.toThrow('município não pertence ao território')
).rejects.toThrow('município válido')
})
it('derives regions from multiple cities across territories, overriding a manual region', async () => {
const nucleus = await payload.create({
collection: 'electoralNucleus',
data: {
...validNucleusInput,
city: 'Seabra',
name: campaignFixtures().value('Núcleo multi-território'),
regions: ['Irecê'],
cities: ['Seabra', 'Salvador'],
} as never,
depth: 0,
})
await expect(
payload.update({
collection: 'electoralNucleus',
id: nucleus.id,
data: { city: 'Salvador' },
depth: 0,
}),
).rejects.toThrow('município não pertence ao território')
expect(nucleus.cities).toEqual(['Seabra', 'Salvador'])
expect(nucleus.regions).toEqual(['Chapada Diamantina', 'Metropolitano de Salvador'])
const updated = await payload.update({
collection: 'electoralNucleus',
id: nucleus.id,
data: { cities: ['Salvador'] },
depth: 0,
})
expect(updated.cities).toEqual(['Salvador'])
expect(updated.regions).toEqual(['Metropolitano de Salvador'])
})
it('allows a neighborhood-only PATCH to preserve the existing city', async () => {
it('allows multiple neighborhoods only when exactly one city is set', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const nucleus = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com bairro atualizado'),
city: 'Seabra',
name: campaignFixtures().value('Núcleo com bairros múltiplos'),
cities: ['Seabra'],
neighborhoods: ['Centro', 'Bairro Novo'],
})
const updated = await updateElectoralNucleus(payload, general, {
id: nucleus.id,
neighborhood: 'Centro',
})
expect(updated.city).toBe('Seabra')
expect(updated.neighborhood).toBe('Centro')
expect(nucleus.cities).toEqual(['Seabra'])
expect(nucleus.neighborhoods).toEqual(['Centro', 'Bairro Novo'])
})
it('rejects explicitly clearing city while providing a neighborhood', () => {
it('rejects neighborhoods when more than one city is selected', async () => {
expect(() =>
nucleusCreateSchema.parse({
...validNucleusInput,
cities: ['Seabra', 'Salvador'],
neighborhoods: ['Centro'],
}),
).toThrow('exatamente um município')
await expect(
payload.create({
collection: 'electoralNucleus',
data: {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com bairro em dois municípios'),
cities: ['Seabra', 'Salvador'],
neighborhoods: ['Centro'],
} as never,
depth: 0,
}),
).rejects.toThrow('exatamente um município')
})
it('requires resending cities together with neighborhoods on a PATCH', () => {
expect(() =>
nucleusUpdateSchema.parse({
id: 1,
city: null,
neighborhood: 'Centro',
neighborhoods: ['Centro'],
}),
).toThrow('município antes do bairro')
})
it('rejects clearing city when an omitted existing neighborhood remains', async () => {
it('rejects explicitly clearing cities while providing a neighborhood', () => {
expect(() =>
nucleusUpdateSchema.parse({
id: 1,
cities: [],
neighborhoods: ['Centro'],
}),
).toThrow('município antes do bairro')
})
it('rejects clearing cities when an omitted existing neighborhood remains', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const nucleus = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com bairro preservado'),
city: 'Seabra',
neighborhood: 'Centro',
cities: ['Seabra'],
neighborhoods: ['Centro'],
})
await expect(
updateElectoralNucleus(payload, general, {
id: nucleus.id,
city: null,
cities: null,
}),
).rejects.toThrow('município antes do bairro')
})
it('allows the form to clear city and neighborhood when other geography remains', async () => {
it('allows the form to clear cities and neighborhoods when other geography remains', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const nucleus = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com geografia pelo formulário'),
city: 'Seabra',
neighborhood: 'Centro',
cities: ['Seabra'],
neighborhoods: ['Centro'],
})
const formData = new FormData()
formData.set('id', String(nucleus.id))
formData.set('name', nucleus.name)
formData.set('region', 'Chapada Diamantina')
formData.append('regions', 'Chapada Diamantina')
formData.set('locality', 'Zona rural')
formData.set('organizationKind', 'territorial')
@ -319,9 +356,9 @@ describe('electoral nucleus domain', () => {
parseNucleusUpdateFormData(formData),
)
expect(updated.city).toBeNull()
expect(updated.neighborhood).toBeNull()
expect(updated.region).toBe('Chapada Diamantina')
expect(updated.cities).toEqual([])
expect(updated.neighborhoods).toEqual([])
expect(updated.regions).toEqual(['Chapada Diamantina'])
expect(updated.locality).toBe('Zona rural')
})
@ -370,7 +407,7 @@ describe('electoral nucleus domain', () => {
collection: 'electoralNucleus',
data: {
...validNucleusInput,
region: undefined,
regions: undefined,
locality: ' ',
} as never,
}),
@ -397,8 +434,8 @@ describe('electoral nucleus domain', () => {
collection: 'electoralNucleus',
id: nucleus.id,
data: {
city: null,
region: null,
cities: [],
regions: [],
locality: null,
},
}),
@ -409,16 +446,16 @@ describe('electoral nucleus domain', () => {
data: {
...validNucleusInput,
name: campaignFixtures().value('Núcleo com geografia normalizada'),
region: 'Chapada Diamantina',
city: 'Seabra',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
locality: ' Chapada Diamantina ',
neighborhood: ' Centro ',
neighborhoods: [' Centro '],
tseZones: [{ zoneNumber: 64 }],
} as never,
})
expect(trimmed.region).toBe('Chapada Diamantina')
expect(trimmed.neighborhood).toBe('Centro')
expect(trimmed.regions).toEqual(['Chapada Diamantina'])
expect(trimmed.neighborhoods).toEqual(['Centro'])
expect(trimmed.locality).toBe('Chapada Diamantina')
})
@ -463,7 +500,7 @@ describe('electoral nucleus domain', () => {
expect(nucleus.status).toBe('ativo')
expect(nucleus.createdBy).toBe(general.id)
expect(nucleus.region).toBe('Chapada Diamantina')
expect(nucleus.regions).toEqual(['Chapada Diamantina'])
expect(nucleus.coordinators).toContain(general.id)
})
@ -533,7 +570,7 @@ describe('electoral nucleus domain', () => {
const coordinator = await campaignFixtures().createCampaignUser('coordenador')
const formData = new FormData()
formData.set('name', campaignFixtures().value('Núcleo via formulário'))
formData.set('city', 'Salvador')
formData.append('cities', 'Salvador')
formData.set('organizationKind', 'territorial')
const created = await createElectoralNucleus(
@ -834,13 +871,47 @@ describe('electoral nucleus domain', () => {
).toBe(true)
})
it('filters nuclei by regions and cities using Payload equals queries', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const chapada = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo Chapada equals'),
cities: ['Seabra'],
})
const salvador = await createElectoralNucleus(payload, general, {
...validNucleusInput,
name: campaignFixtures().value('Núcleo Salvador equals'),
cities: ['Salvador'],
})
const byRegion = await payload.find({
collection: 'electoralNucleus',
depth: 0,
where: { regions: { equals: 'Chapada Diamantina' } },
user: general,
overrideAccess: false,
})
expect(byRegion.docs.map(({ id }) => id)).toContain(chapada.id)
expect(byRegion.docs.map(({ id }) => id)).not.toContain(salvador.id)
const byCity = await payload.find({
collection: 'electoralNucleus',
depth: 0,
where: { cities: { equals: 'Salvador' } },
user: general,
overrideAccess: false,
})
expect(byCity.docs.map(({ id }) => id)).toContain(salvador.id)
expect(byCity.docs.map(({ id }) => id)).not.toContain(chapada.id)
})
it('explicitly clears editable optional nucleus fields', async () => {
const general = await campaignFixtures().createCampaignUser('geral')
const coordinator = await campaignFixtures().createCampaignUser('coordenador')
const nucleus = await createElectoralNucleus(payload, general, {
...validNucleusInput,
coordinators: [coordinator.id],
city: 'Seabra',
cities: ['Seabra'],
locality: 'Centro',
organizationLabel: 'Associação local',
sectorKind: 'rural',
@ -857,7 +928,7 @@ describe('electoral nucleus domain', () => {
id: nucleus.id,
coordinators: [],
tseZones: [],
city: null,
cities: null,
locality: 'Centro',
sectorKind: null,
organizationLabel: null,
@ -871,7 +942,8 @@ describe('electoral nucleus domain', () => {
expect(cleared.coordinators).toEqual([coordinator.id])
expect(cleared.tseZones).toEqual([])
expect(cleared.city).toBeNull()
expect(cleared.cities).toEqual([])
expect(cleared.regions).toEqual([])
expect(cleared.locality).toBe('Centro')
expect(cleared.sectorKind).toBeNull()
expect(cleared.organizationLabel).toBeNull()
@ -884,10 +956,10 @@ describe('electoral nucleus domain', () => {
const clearedLocality = await updateElectoralNucleus(payload, general, {
id: nucleus.id,
city: 'Seabra',
cities: ['Seabra'],
locality: null,
})
expect(clearedLocality.city).toBe('Seabra')
expect(clearedLocality.cities).toEqual(['Seabra'])
expect(clearedLocality.locality).toBeNull()
})

View file

@ -29,8 +29,8 @@ describe('campaign accessible nucleus detail context', () => {
})
const nucleus = await campaignFixtures().createNucleus({
name: campaignFixtures().value('Núcleo contexto real'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
organizationKind: 'territorial',
})

View file

@ -73,8 +73,8 @@ describe('campaign nucleus update page data queries', () => {
})
const nucleus = await campaignFixtures().createNucleus({
name: campaignFixtures().value('Núcleo feed real'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
organizationKind: 'territorial',
})
for (let index = 0; index < 4; index += 1) {
@ -121,8 +121,8 @@ describe('campaign nucleus update page data queries', () => {
})
const nucleus = await campaignFixtures().createNucleus({
name: campaignFixtures().value('Núcleo preview real'),
region: 'Metropolitano de Salvador',
city: 'Salvador',
regions: ['Metropolitano de Salvador'],
cities: ['Salvador'],
organizationKind: 'territorial',
})
for (let index = 0; index < 4; index += 1) {

View file

@ -43,7 +43,7 @@ describe('campaign primary contact page data', () => {
collection: 'electoralNucleus',
data: {
name: campaignFixtures().value('Núcleo contato principal'),
region: 'Chapada Diamantina',
regions: ['Chapada Diamantina'],
locality: 'Chapada Diamantina',
organizationKind: 'territorial',
} as never,
@ -143,7 +143,7 @@ describe('campaign primary contact page data', () => {
data: {
name: campaignFixtures().value('Núcleo contato escopo'),
coordinators: [coordinator.id],
region: 'Chapada Diamantina',
regions: ['Chapada Diamantina'],
locality: 'Chapada Diamantina',
organizationKind: 'territorial',
} as never,

View file

@ -287,8 +287,8 @@ describe('campaign nucleus automatic filters', () => {
{
or: [{ name: { contains: '58' } }, { 'tseZones.zoneNumber': { equals: 58 } }],
},
{ region: { equals: 'Chapada Diamantina' } },
{ city: { equals: 'Seabra' } },
{ regions: { equals: 'Chapada Diamantina' } },
{ cities: { equals: 'Seabra' } },
{ 'tseZones.zoneNumber': { equals: 58 } },
{ coordinators: { exists: true } },
{ confirmedVoteEstimate: { exists: false } },

View file

@ -269,9 +269,9 @@ describe('campaign nucleus intelligence UI', () => {
slug: 'nucleo-seguro',
status: 'ativo',
coordinators: [99],
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
organizationKind: 'territorial',
sectorKind: 'rural',
tseZones: [],
@ -315,9 +315,9 @@ describe('campaign nucleus intelligence UI', () => {
collection: 'campaignUser',
},
],
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
locality: 'Centro',
territoryNotes: 'Estratégia territorial interna',
organizationKind: 'territorial',
@ -376,9 +376,9 @@ describe('campaign nucleus intelligence UI', () => {
name: 'Núcleo seguro',
slug: 'nucleo-seguro',
status: 'ativo',
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
locality: 'Centro',
organizationKind: 'territorial',
organizationLabel: 'Organização local',
@ -386,9 +386,9 @@ describe('campaign nucleus intelligence UI', () => {
confirmedVoteEstimate: 1200,
tabs: {
kind: 'leadership',
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
locality: 'Centro',
tseZones: [58],
},

View file

@ -77,6 +77,7 @@ vi.mock('@/utilities/nucleusUpdateUi', () => ({
}))
vi.mock('@/utilities/nucleusUi', () => ({
organizationKindLabels: { territorial: 'Territorial' },
formatNucleusTerritoryLabel: () => 'Metropolitano de Salvador',
}))
vi.mock('@/utilities/phone', () => ({ buildWhatsAppUrl: vi.fn() }))
vi.mock('@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions', () => ({
@ -144,10 +145,10 @@ describe('campaign nucleus summary layout', () => {
status: 'ativo',
organizationKind: 'territorial',
organizationLabel: null,
neighborhood: null,
neighborhoods: [],
locality: null,
city: 'Salvador',
region: 'Metropolitano de Salvador',
cities: ['Salvador'],
regions: ['Metropolitano de Salvador'],
tseZones: [],
confirmedVoteEstimate: null,
proposedVoteEstimate: null,

View file

@ -78,28 +78,29 @@ describe('campaign nucleus UI contracts', () => {
expect(matchesAtWordStart('São Félix', 'fel')).toBe(true)
})
it('selects a municipality with the keyboard and derives its territory', async () => {
it('adds a municipality as a chip via the strict combobox and derives its territory chip', async () => {
render(createElement(NucleusTerritoryFields))
const region = screen.getByLabelText('Território de identidade') as HTMLInputElement
const city = screen.getByLabelText('Município') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'Seab' } })
fireEvent.keyDown(city, { key: 'ArrowDown' })
fireEvent.keyDown(city, { key: 'Enter' })
await waitFor(() => expect(city.value).toBe('Seabra'))
expect(region.value).toBe('Chapada Diamantina')
await waitFor(() => expect(city.value).toBe(''))
expect(screen.getByText('Seabra')).toBeTruthy()
expect(screen.getByText('Chapada Diamantina')).toBeTruthy()
expect(screen.queryByLabelText('Territórios de identidade')).toBeNull()
})
it('uses neutral editable lookup identifiers and submits only canonical hidden fields', () => {
it('uses neutral editable lookup identifiers and exposes only the canonical hidden fields', () => {
const { container } = render(
createElement(NucleusTerritoryFields, {
fieldErrors: { region: ['Território inválido.'], city: ['Município inválido.'] },
fieldErrors: { regions: ['Território inválido.'], cities: ['Município inválido.'] },
}),
)
const region = screen.getByLabelText('Território de identidade') as HTMLInputElement
const city = screen.getByLabelText('Município') as HTMLInputElement
const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
for (const input of [region, city]) {
expect(input.autocomplete).toBe('off')
@ -118,10 +119,11 @@ describe('campaign nucleus UI contracts', () => {
expect(region.id).toBe('nucleus-lookup-a')
expect(city.id).toBe('nucleus-lookup-b')
expect(region.id).not.toBe(city.id)
expect(container.querySelectorAll('input[type="hidden"][name="region"]')).toHaveLength(1)
expect(container.querySelectorAll('input[type="hidden"][name="city"]')).toHaveLength(1)
expect(container.querySelectorAll('[name="region"]')).toHaveLength(1)
expect(container.querySelectorAll('[name="city"]')).toHaveLength(1)
expect(container.querySelectorAll('input[type="hidden"][name="cities"]')).toHaveLength(0)
expect(container.querySelectorAll('input[type="hidden"][name="regions"]')).toHaveLength(0)
expect(container.querySelectorAll('input[type="hidden"][name="neighborhoods"]')).toHaveLength(
0,
)
expect(region.getAttribute('aria-describedby')).toBe('nucleus-lookup-a-error')
expect(city.getAttribute('aria-describedby')).toBe('nucleus-lookup-b-error')
expect(screen.getByText('Território inválido.').id).toBe('nucleus-lookup-a-error')
@ -130,7 +132,7 @@ describe('campaign nucleus UI contracts', () => {
it('shows only word-start municipality matches', () => {
render(createElement(NucleusTerritoryFields))
const city = screen.getByLabelText('Município')
const city = screen.getByLabelText('Municípios')
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'sal' } })
@ -141,10 +143,10 @@ describe('campaign nucleus UI contracts', () => {
expect(screen.queryByRole('option', { name: 'Cruz das Almas' })).toBeNull()
})
it('clears invalid strict values on blur and exposes their errors', () => {
it('clears an invalid region or city draft on blur and exposes an error', () => {
render(createElement(NucleusTerritoryFields))
const region = screen.getByLabelText('Território de identidade') as HTMLInputElement
const city = screen.getByLabelText('Município') as HTMLInputElement
const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.change(region, { target: { value: 'Território inventado' } })
fireEvent.blur(region)
@ -159,45 +161,94 @@ describe('campaign nucleus UI contracts', () => {
expect(screen.getByText('Selecione um município válido da Bahia.')).toBeTruthy()
})
it('filters municipalities to the selected territory', () => {
it('allows manually adding regions only until a city is selected, after which they become derived', async () => {
render(createElement(NucleusTerritoryFields))
const region = screen.getByLabelText('Território de identidade')
const city = screen.getByLabelText('Município')
const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(region)
fireEvent.change(region, { target: { value: 'Itaparica' } })
fireEvent.blur(region)
fireEvent.keyDown(region, { key: 'ArrowDown' })
fireEvent.keyDown(region, { key: 'Enter' })
expect(screen.getByText('Itaparica')).toBeTruthy()
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'pa' } })
fireEvent.change(city, { target: { value: 'Salvador' } })
fireEvent.keyDown(city, { key: 'ArrowDown' })
expect(screen.getByRole('option', { name: 'Paulo Afonso' })).toBeTruthy()
expect(screen.queryByRole('option', { name: 'Salvador' })).toBeNull()
fireEvent.keyDown(city, { key: 'Enter' })
await waitFor(() => expect(screen.queryByLabelText('Territórios de identidade')).toBeNull())
expect(screen.queryByText('Itaparica')).toBeNull()
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
})
it('auto-selects territory and clears neighborhood when municipality changes or clears', () => {
it('derives regions from multiple cities across territories', () => {
render(createElement(NucleusTerritoryFields))
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'Seab' } })
fireEvent.keyDown(city, { key: 'ArrowDown' })
fireEvent.keyDown(city, { key: 'Enter' })
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'Salv' } })
fireEvent.keyDown(city, { key: 'ArrowDown' })
fireEvent.keyDown(city, { key: 'Enter' })
expect(screen.getByText('Seabra')).toBeTruthy()
expect(screen.getByText('Salvador')).toBeTruthy()
expect(screen.getByText('Chapada Diamantina')).toBeTruthy()
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
})
it('enables neighborhoods only when exactly one city is set, resetting them otherwise', async () => {
render(
createElement(NucleusTerritoryFields, {
values: {
city: 'Seabra',
neighborhood: 'Centro',
},
values: { cities: ['Seabra'], neighborhoods: ['Centro'] },
}),
)
const region = screen.getByLabelText('Território de identidade') as HTMLInputElement
const city = screen.getByLabelText('Município') as HTMLInputElement
const neighborhood = screen.getByLabelText('Bairro') as HTMLInputElement
const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement
expect(neighborhood.disabled).toBe(false)
expect(screen.getByText('Centro')).toBeTruthy()
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'Salvador' } })
fireEvent.blur(city)
expect(region.value).toBe('Metropolitano de Salvador')
expect(neighborhood.value).toBe('')
fireEvent.keyDown(city, { key: 'ArrowDown' })
fireEvent.keyDown(city, { key: 'Enter' })
fireEvent.change(neighborhood, { target: { value: 'Barra' } })
fireEvent.change(city, { target: { value: '' } })
fireEvent.blur(city)
expect(city.value).toBe('')
expect(neighborhood.value).toBe('')
expect(neighborhood.disabled).toBe(true)
await waitFor(() => expect(neighborhood.disabled).toBe(true))
expect(screen.queryByText('Centro')).toBeNull()
})
it('removes a city chip and re-derives the territory from the remaining cities', async () => {
render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra', 'Salvador'] } }))
expect(screen.getByText('Chapada Diamantina')).toBeTruthy()
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Remover município Seabra'))
await waitFor(() => expect(screen.queryByText('Chapada Diamantina')).toBeNull())
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
expect(screen.queryByText('Seabra')).toBeNull()
})
it('adds multiple neighborhoods via Enter and removes the last one via Backspace', () => {
render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra'] } }))
const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement
fireEvent.change(neighborhood, { target: { value: 'Centro' } })
fireEvent.keyDown(neighborhood, { key: 'Enter' })
fireEvent.change(neighborhood, { target: { value: 'Bairro Novo' } })
fireEvent.keyDown(neighborhood, { key: 'Enter' })
expect(screen.getByText('Centro')).toBeTruthy()
expect(screen.getByText('Bairro Novo')).toBeTruthy()
fireEvent.keyDown(neighborhood, { key: 'Backspace' })
expect(screen.queryByText('Bairro Novo')).toBeNull()
expect(screen.getByText('Centro')).toBeTruthy()
})
it('builds an active, scoped Payload query from URL filters', () => {
@ -217,8 +268,8 @@ describe('campaign nucleus UI contracts', () => {
{
or: [{ name: { contains: '58' } }, { 'tseZones.zoneNumber': { equals: 58 } }],
},
{ region: { equals: 'Chapada Diamantina' } },
{ city: { equals: 'Seabra' } },
{ regions: { equals: 'Chapada Diamantina' } },
{ cities: { equals: 'Seabra' } },
{ 'tseZones.zoneNumber': { equals: 58 } },
{ coordinators: { exists: true } },
{ confirmedVoteEstimate: { exists: false } },
@ -242,9 +293,8 @@ describe('campaign nucleus UI contracts', () => {
it('parses the progressive-enhancement create form into domain input', () => {
const formData = new FormData()
formData.set('name', 'Núcleo Chapada')
formData.set('region', 'Chapada Diamantina')
formData.set('city', 'Seabra')
formData.set('neighborhood', 'Centro')
formData.append('cities', 'Seabra')
formData.append('neighborhoods', 'Centro')
formData.set('organizationKind', 'territorial')
formData.set('tseZones', '58, 59')
formData.append('coordinators', '12')
@ -252,9 +302,9 @@ describe('campaign nucleus UI contracts', () => {
expect(parseNucleusCreateFormData(formData)).toEqual({
name: 'Núcleo Chapada',
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
locality: undefined,
territoryNotes: undefined,
organizationKind: 'territorial',
@ -270,7 +320,6 @@ describe('campaign nucleus UI contracts', () => {
const formData = new FormData()
formData.set('id', '12')
formData.set('name', 'Núcleo Chapada')
formData.set('region', '')
formData.set('organizationKind', 'territorial')
formData.set('locality', 'Centro')
formData.append('coordinators', '99')
@ -280,9 +329,9 @@ describe('campaign nucleus UI contracts', () => {
expect(parsed).toMatchObject({
id: 12,
tseZones: [],
region: null,
city: null,
neighborhood: null,
regions: [],
cities: [],
neighborhoods: [],
locality: 'Centro',
sectorKind: null,
organizationLabel: null,
@ -296,13 +345,11 @@ describe('campaign nucleus UI contracts', () => {
expect(parsed).not.toHaveProperty('coordinators')
})
it('rejects clearing region, city, and locality with a linked city error', () => {
it('rejects clearing regions, cities, and locality together', () => {
const formData = new FormData()
formData.set('id', '12')
formData.set('name', 'Núcleo Chapada')
formData.set('organizationKind', 'territorial')
formData.set('region', '')
formData.set('city', '')
formData.set('locality', '')
expect(() => parseNucleusUpdateFormData(formData)).toThrow(
@ -310,17 +357,16 @@ describe('campaign nucleus UI contracts', () => {
)
})
it('drops neighborhood input when the city is blank', () => {
it('drops neighborhood input when no city is selected', () => {
const formData = new FormData()
formData.set('id', '12')
formData.set('name', 'Núcleo Chapada')
formData.set('region', 'Chapada Diamantina')
formData.set('city', '')
formData.set('neighborhood', 'Centro')
formData.append('regions', 'Chapada Diamantina')
formData.append('neighborhoods', 'Centro')
formData.set('locality', 'Comunidade rural')
formData.set('organizationKind', 'territorial')
expect(parseNucleusUpdateFormData(formData).neighborhood).toBeNull()
expect(parseNucleusUpdateFormData(formData).neighborhoods).toEqual([])
})
it('parses Zona TSE tags as sorted unique bounded numbers', () => {
@ -360,9 +406,9 @@ describe('campaign nucleus UI contracts', () => {
slug: 'nucleo-seguro',
status: 'ativo',
coordinators: [99],
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: 'Centro',
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: ['Centro'],
organizationKind: 'territorial',
sectorKind: 'rural',
tseZones: [],
@ -394,8 +440,7 @@ describe('campaign nucleus UI contracts', () => {
it('rejects malformed Zona TSE tokens instead of dropping them', () => {
const formData = new FormData()
formData.set('name', 'Núcleo Chapada')
formData.set('region', 'Chapada Diamantina')
formData.set('city', 'Seabra')
formData.append('cities', 'Seabra')
formData.set('organizationKind', 'territorial')
formData.set('tseZones', '58, inválida')
@ -438,9 +483,9 @@ describe('campaign nucleus UI contracts', () => {
name: 'Quilombo Rio das Rãs',
slug: 'quilombo-rio-das-ras',
coordinators: [{ id: 2, name: 'João Silva' }],
region: 'Velho Chico',
city: 'Bom Jesus da Lapa',
neighborhood: null,
regions: ['Velho Chico'],
cities: ['Bom Jesus da Lapa'],
neighborhoods: [],
locality: null,
organizationKind: 'territorial',
organizationLabel: null,
@ -471,8 +516,8 @@ describe('campaign nucleus UI contracts', () => {
)
expect(html).toContain('Nome do núcleo')
expect(html).toContain('Território de identidade')
expect(html).toContain('Município')
expect(html).toContain('Territórios de identidade')
expect(html).toContain('Municípios')
expect(html).toContain('Zonas TSE')
expect(html).toContain('Coordenação Geral (você)')
expect(html).toContain('João Silva')
@ -501,9 +546,9 @@ describe('campaign nucleus UI contracts', () => {
id: 12,
name: 'Núcleo Chapada',
slug: 'nucleo-chapada',
region: 'Chapada Diamantina',
city: 'Seabra',
neighborhood: null,
regions: ['Chapada Diamantina'],
cities: ['Seabra'],
neighborhoods: [],
locality: null,
territoryNotes: null,
organizationKind: 'territorial',