mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
c42b16baab
commit
b035cc4a2f
42 changed files with 7748 additions and 540 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 $$;
|
||||
`)
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ?? [],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@ describe('campaign authentication foundation', () => {
|
|||
slug: slugify(nucleusName),
|
||||
status: nucleusStatus,
|
||||
coordinators: [target.id],
|
||||
city: 'Salvador',
|
||||
cities: ['Salvador'],
|
||||
organizationKind: 'territorial',
|
||||
},
|
||||
depth: 0,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue