Add opt-in TSE zone and territory suggestion chips for nuclei.

Ship A2 with a static Bahia municipality↔zone map, pure suggestion engine, and form chips that never auto-apply geography.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 20:04:04 -03:00
parent 7e9660ef8f
commit ac7f2775cd
17 changed files with 3691 additions and 88 deletions

View file

@ -85,6 +85,7 @@ alwaysApply: false
- 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. 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, decisão de produto (revisão A2): no formulário de território, sugestões cruzadas **opt-in** via chips `{rótulo} +` (município/TI → união de ZEs faltantes; TI → municípios irmãos; ZE → municípios oficiais), em vez do auto-preenchimento forçado + Zonas TSE read-only da versão 2026-07-17 do plano. TI a partir do município continua derivado (A1). Cadastro estático município↔zona (`bahiaTseZones`) permanece. Plano: [`docs/plans/zonas-por-municipio.md`](../../../docs/plans/zonas-por-municipio.md); item do roadmap: A2.
- Em 2026-07-18, **A2 entregue:** `src/lib/bahiaTseZones.ts` (TSE 2024 `detalhe_votacao_munzona` BA, fixture `bahia-tse-zones.official.json`), `territorySuggestions.ts`, `TerritorySuggestionChips` + `NucleusTerritoryAndZonesFields` no formulário de criar/editar núcleo; `TseZoneInput` controlado; aviso âmbar opcional para ZE fora do cadastro da geografia; sem migration.
- 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.

View file

@ -1,10 +1,13 @@
# Zonas TSE por município + sugestões cruzadas de território
Status: rascunho
Status: entregue
Atualizado em: 2026-07-18
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A → A2)
Responsável: —
Revisões:
- 2026-07-18 (entrega): cadastro estático `bahiaTseZones` a partir do TSE 2024 `detalhe_votacao_munzona` BA (SHA-256 no cabeçalho); motor `territorySuggestions` + chips opt-in no coordenador `NucleusTerritoryAndZonesFields`; sem migration. Corrigido drift factual: a validação server-side existente chama-se `validateTerritoryAndZones` (não `validateGeographyAndZones`). Reuso de `canonicalizeMunicipalityName` / aliases de A3 na geração do dataset.
## 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[]`**: sugestões e validações leem o conjunto de municípios/territórios selecionados. Bairros continuam a exigir exatamente um município (regra A1).
@ -58,7 +61,7 @@ Exemplo canônico (TI oficial `Vale do Jiquiriçá`):
- **Sugestão opt-in, nunca auto-gravação forçada de ZEs/municípios irmãos.** Adicionar Itiruçu **não** preenche `tseZones` sozinho; só aparece o chip até o usuário clicar (ou digitar). Municípios irmãos e municípios-por-ZE idem. (Decisão de produto 2026-07-18; revisa o auto-preenchimento forçado de 2026-07-17.)
- **TI a partir do município continua automático** via A1 (`territoriesForCities` / rederivação no servidor). Isso **não** é chip de sugestão — é derivação. Remover municípios restaura edição manual de `regions`.
- **Mapeamento como dado estático versionado.** `src/lib/bahiaTseZones.ts` a partir do “Eleitorado por município e zona” (UF BA), cabeçalho com URL, versão/data e SHA-256 — estilo `bahiaTerritories.ts`. Fixture independente nunca lido a partir do módulo TypeScript.
- **Mapeamento como dado estático versionado.** `src/lib/bahiaTseZones.ts` a partir do TSE 2024 `detalhe_votacao_munzona` (UF BA — cobertura eleição-validada dos 417 municípios; preferido aos resultados 2022 por risco de remanejamento de zonas), cabeçalho com URL, data de extração e SHA-256 — estilo `bahiaTerritories.ts`. Nomes TSE reconciliados via `canonicalizeMunicipalityName`. Fixture independente nunca lido a partir do módulo TypeScript.
- **Sem migration.** Campo `tseZones` já é `array<{ zoneNumber, label }>`.
- **Estado compartilhado.** Hoje `NucleusTerritoryFields` e `TseZoneInput` são siblings com estado independente em `NucleusForm`. Subir estado de `regions` / `cities` / `neighborhoods` / `tseZones` para um coordenador cliente (ex. `NucleusTerritoryAndZonesFields`) que emite hidden inputs e calcula sugestões.
- **União, não replace.** Clique em 「Itiruçu +」 = `tseZones ∪ tseZonesForCity('Itiruçu')`. Clique em 「Vale do Jiquiriçá +」 = união de todas as ZEs de `citiesForTerritory('Vale do Jiquiriçá')`. Clique em 「Maracás +」 = adiciona o município (respeitando `MAX_NUCLEUS_CITIES`).
@ -139,7 +142,7 @@ Componentes:
- `src/components/campaign/NucleusForm.tsx`, `NucleusTerritoryFields.tsx`, `TseZoneInput.tsx` — formulário a coordenar
- `src/lib/bahiaTerritories.ts`, `tests/fixtures/bahia-identity-territories.official.json`, `tests/int/bahiaTerritories.int.spec.ts` — padrão a espelhar
- `src/collections/ElectoralNucleus.ts` — `validateNucleusTerritoryAndZones`
- `src/lib/schemas/nucleus.ts` — `validateGeographyAndZones`
- `src/lib/schemas/nucleus.ts` — `validateTerritoryAndZones`
- `src/utilities/tseZone.ts` / `nucleusUi.ts` — parse de ZEs
- `docs/design-refs/latest/Formulario-Territorio.{png,html}` — referência visual (banner + “sugerida”)
- Portal de Dados Abertos do TSE — “Eleitorado por município e zona” (UF BA): https://dadosabertos.tse.jus.br/

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 revisado: sugestões cruzadas opt-in no formulário de território, em vez de auto-preenchimento forçado)
Atualizado em: 2026-07-18 (MVP + Ciclo 2 deployados; A2 entregue: cadastro município↔ZE + chips opt-in no formulário de território)
Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos.
@ -56,6 +56,10 @@ MVP de território + reporte implementado e enviado (ondas 1–8 + refactors de
- **C1 Compartilhar página** — diálogo no detalhe do núcleo com destinatários WhatsApp (coordenação geral, coordenadores, lideranças engajadas) + copiar link; envia só o link, não concede acesso. [Plano](plans/compartilhar-pagina.md).
- **D1 PWA `/campanha`** — manifest + service worker escopados em `/campanha`, shell offline, toast de instalação (Android/iOS) e limpeza de cache no logout; fundação do push (D2). [Plano](plans/pwa-campanha.md).
### Ciclo 2+ — A2 entregue em 2026-07-18
- **A2 Zonas TSE + sugestões cruzadas** — cadastro estático `bahiaTseZones` (TSE 2024 `detalhe_votacao_munzona` BA, 417 municípios), motor puro `territorySuggestions`, coordenador `NucleusTerritoryAndZonesFields` com chips `{rótulo} +` opt-in (município/TI → ZEs; irmãos do TI e cidades da ZE → Municípios). Sem migration, sem igualdade forçada no save. [Plano](plans/zonas-por-municipio.md).
### Referências de design (UX Pilot, 2026-07-18)
Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/design-refs/latest/`](design-refs/latest/) (pares `.png` + `.html` com o mesmo nome). **A UX/estrutura é a referência a seguir; a paleta não é** — os arquivos usam a paleta antiga (vermelho escuro `#8E0E23`, navy `#1B2B4B`, dourado `#C8874B`); toda implementação usa os tokens claros do tema `data-theme='campaign'` (`src/app/(frontend)/styles.css`: fundo branco, primário `#C51414`, superfícies neutras). Cada plano detalha o uso na sua seção "Referência visual (UX Pilot)".
@ -89,7 +93,7 @@ flowchart TD
subgraph TrilhaA["Trilha A — dados eleitorais e território"]
A1["A1 Território multi-município/bairro<br/>(migration) ✓"]
A2["A2 Zonas TSE + sugestões cruzadas<br/>(chips opt-in território↔ZE)"]
A2["A2 Zonas TSE + sugestões cruzadas<br/>(chips opt-in território↔ZE) ✓"]
A3["A3 Baseline TSE 2022 — Fase 1<br/>(import, collections) ✓"]
A4["A4 Baseline no produto + Gap vs 2022<br/>(detalhe, overview)"]
A5["A5 Insights: conversão, classificação,<br/>alavancagem, mobilização, competitiva"]
@ -138,7 +142,7 @@ flowchart TD
JUR -.chave de push.-> D2
```
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A2 (nasce contra `cities[]`; UX revisada 2026-07-18 para chips `{rótulo} +` opt-in), A4 (A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica).
Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (engenharia — produção espera o jurídico), além dos fill-ins (visitados recentemente, listas globais, reset de senha, higiene PascalCase). **Entregues em 2026-07-18** (✓ no grafo): A1, A2, A3, B1, C1 e D1 — ver "Ciclo 2" acima. Destravados por eles: A4 (A2/A3 + B1 prontos), C3/C4 (A1 pronto) e D2 push (D1 pronto; falta a chave jurídica).
### Sequência de execução por janela do calendário
@ -148,7 +152,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): B2, C2 (enge
| ----- | ---------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------- | ----------------- |
| 1 | Onda 0 (jurídico em lote + deploy + lançamento) | — | externo | tudo |
| 2 | A1 Território multi-município/bairro **(entregue 2026-07-18)** | [detalhes](plans/territorio-multi-municipio-bairro.md) | — | B1, C1, A3 |
| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ |
| 3 | A2 Zonas TSE + sugestões cruzadas (chips opt-in território↔ZE) **(entregue 2026-07-18)** | [detalhes](plans/zonas-por-municipio.md) | A1 (nasce contra `cities[]`) | B1 ✓, C1 ✓, A3 ✓ |
| 4 | B1 Overview da lista de núcleos **(entregue 2026-07-18)** | [detalhes](plans/overview-lista-nucleos.md) | — | A1, A2, C1 |
| 5 | C1 Compartilhar página **(entregue 2026-07-18)** | [detalhes](plans/compartilhar-pagina.md) | — | tudo |
| 6 | A3 Baseline TSE 2022 — Fase 1 (import) **(entregue 2026-07-18)** | [detalhes](plans/baseline-eleitoral-tse.md) | — (dado público; sem bloqueador LGPD) | tudo |

View file

@ -3,8 +3,7 @@
import { useActionState } from 'react'
import Link from 'next/link'
import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -169,7 +168,7 @@ export const NucleusFormFields = ({
</CardHeader>
<CardContent>
<FieldGroup>
<NucleusTerritoryFields values={nucleus} fieldErrors={fieldErrors} />
<NucleusTerritoryAndZonesFields values={nucleus} fieldErrors={fieldErrors} />
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('locality'))}>
<FieldLabel htmlFor="locality">Localidade</FieldLabel>
@ -206,10 +205,6 @@ export const NucleusFormFields = ({
<FieldError id="territoryNotes-error">{errorFor('territoryNotes')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('tseZones'))}>
<FieldLabel htmlFor="tseZoneDraft">Zonas TSE</FieldLabel>
<TseZoneInput defaultValues={nucleus?.tseZones ?? []} error={errorFor('tseZones')} />
</Field>
</FieldGroup>
</CardContent>
</Card>

View file

@ -4,36 +4,30 @@ import { useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import { StrictCombobox } from '@/components/campaign/StrictCombobox'
import { TerritorySuggestionChips } from '@/components/campaign/TerritorySuggestionChips'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { territoriesForCities } from '@/lib/bahiaTerritories'
import {
dedupeTrimmedStrings,
MAX_NUCLEUS_CITIES,
MAX_NUCLEUS_NEIGHBORHOODS,
MAX_NUCLEUS_REGIONS,
} from '@/lib/schemas/nucleus'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
import {
municipalityComboboxOptions,
allMunicipalityComboboxOptions,
territoryComboboxOptions,
} from '@/utilities/territoryComboboxOptions'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
type TerritoryValues = {
type TerritoryAndZonesValues = {
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
tseZones?: number[] | null
}
const ChipList = ({
@ -57,18 +51,19 @@ const ChipList = ({
</div>
)
export const NucleusTerritoryFields = ({
export const NucleusTerritoryAndZonesFields = ({
values,
fieldErrors = {},
}: {
values?: TerritoryValues
values?: TerritoryAndZonesValues
fieldErrors?: Record<string, string[]>
}) => {
const [regions, setRegions] = useState(() => uniquePreserveOrder(values?.regions ?? []))
const [cities, setCities] = useState(() => uniquePreserveOrder(values?.cities ?? []))
const [regions, setRegions] = useState(() => dedupeTrimmedStrings(values?.regions ?? []))
const [cities, setCities] = useState(() => dedupeTrimmedStrings(values?.cities ?? []))
const [neighborhoods, setNeighborhoods] = useState(() =>
uniquePreserveOrder(values?.neighborhoods ?? []),
dedupeTrimmedStrings(values?.neighborhoods ?? []),
)
const [tseZones, setTseZones] = useState(() => sortedUniqueZoneNumbers(values?.tseZones ?? []))
const [cityDraft, setCityDraft] = useState('')
const [regionDraft, setRegionDraft] = useState('')
const [neighborhoodDraft, setNeighborhoodDraft] = useState('')
@ -82,16 +77,28 @@ export const NucleusTerritoryFields = ({
const regionsAreDerived = cities.length > 0
const neighborhoodsEnabled = cities.length === 1
const { zoneSuggestions, citySuggestions, outsideZones } = useMemo(
() =>
buildTerritorySuggestions({
cities,
regions: displayRegions,
tseZones,
}),
[cities, displayRegions, tseZones],
)
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 availableCityOptions = useMemo(() => {
const selected = new Set(cities)
return allMunicipalityComboboxOptions.filter((option) => !selected.has(option.value))
}, [cities])
const availableRegionOptions = useMemo(() => {
const selected = new Set(regions)
return territoryComboboxOptions.filter((option) => !selected.has(option.value))
}, [regions])
const addCity = (nextCity: string) => {
if (!nextCity) return
@ -101,7 +108,7 @@ export const NucleusTerritoryFields = ({
return
}
setCities((current) => {
const next = uniquePreserveOrder([...current, nextCity])
const next = dedupeTrimmedStrings([...current, nextCity])
if (next.length !== 1) setNeighborhoods([])
return next
})
@ -126,7 +133,7 @@ export const NucleusTerritoryFields = ({
setRegionDraft('')
return
}
setRegions((current) => uniquePreserveOrder([...current, nextRegion]))
setRegions((current) => dedupeTrimmedStrings([...current, nextRegion]))
setRegionDraft('')
setRegionError(undefined)
}
@ -152,7 +159,7 @@ export const NucleusTerritoryFields = ({
setNeighborhoodDraft('')
return
}
setNeighborhoods((current) => uniquePreserveOrder([...current, trimmed]))
setNeighborhoods((current) => dedupeTrimmedStrings([...current, trimmed]))
setNeighborhoodDraft('')
setNeighborhoodError(undefined)
}
@ -214,6 +221,11 @@ export const NucleusTerritoryFields = ({
<Field data-invalid={Boolean(visibleCityError)} className="sm:col-span-2">
<FieldLabel htmlFor="nucleus-lookup-b">Municípios</FieldLabel>
<TerritorySuggestionChips
kind="city"
suggestions={citySuggestions}
onAccept={(suggestion) => addCity(suggestion.city)}
/>
{cities.length ? (
<ChipList
values={cities}
@ -300,6 +312,30 @@ export const NucleusTerritoryFields = ({
<FieldError id="neighborhoods-error">{visibleNeighborhoodError}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('tseZones'))} className="sm:col-span-2">
<FieldLabel htmlFor="tseZoneDraft">Zonas TSE</FieldLabel>
{zoneSuggestions.length > 0 ? (
<FieldDescription>
Zonas sugeridas pelo cadastro oficial do TSE — confira antes de salvar.
</FieldDescription>
) : null}
<TerritorySuggestionChips
kind="zone"
suggestions={zoneSuggestions}
onAccept={(suggestion) =>
setTseZones((current) => sortedUniqueZoneNumbers([...current, ...suggestion.zonesToAdd]))
}
/>
<TseZoneInput value={tseZones} onChange={setTseZones} error={errorFor('tseZones')} />
{outsideZones.length > 0 ? (
<FieldDescription className="text-amber-700 dark:text-amber-400">
{outsideZones.length === 1
? `Zona ${outsideZones[0]} não aparece no cadastro dos municípios/TIs selecionados.`
: `Zonas ${outsideZones.join(', ')} não aparecem no cadastro dos municípios/TIs selecionados.`}
</FieldDescription>
) : null}
</Field>
</div>
)
}

View file

@ -0,0 +1,86 @@
'use client'
import { useState } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { CitySuggestion, ZoneSuggestion } from '@/lib/territorySuggestions'
const MAX_VISIBLE_SUGGESTION_CHIPS = 8
type TerritorySuggestionChipsProps =
| {
kind: 'zone'
suggestions: ZoneSuggestion[]
onAccept: (suggestion: ZoneSuggestion) => void
}
| {
kind: 'city'
suggestions: CitySuggestion[]
onAccept: (suggestion: CitySuggestion) => void
}
type ChipView = {
key: string
label: string
ariaLabel: string
variant: 'tse' | 'outline'
onAccept: () => void
}
const toChipViews = (props: TerritorySuggestionChipsProps): ChipView[] => {
if (props.kind === 'zone') {
return props.suggestions.map((suggestion) => ({
key: `zone-${suggestion.kind}-${suggestion.label}`,
label: `${suggestion.label} +`,
ariaLabel: `Adicionar zonas TSE de ${suggestion.label}`,
variant: 'tse',
onAccept: () => props.onAccept(suggestion),
}))
}
return props.suggestions.map((suggestion) => ({
key: `city-${suggestion.kind}-${suggestion.city}`,
label: `${suggestion.city} +`,
ariaLabel: `Adicionar município ${suggestion.city}`,
variant: 'outline',
onAccept: () => props.onAccept(suggestion),
}))
}
export const TerritorySuggestionChips = (props: TerritorySuggestionChipsProps) => {
const [expanded, setExpanded] = useState(false)
if (props.suggestions.length === 0) return null
const chips = toChipViews(props)
const visible = expanded ? chips : chips.slice(0, MAX_VISIBLE_SUGGESTION_CHIPS)
const hiddenCount = chips.length - visible.length
return (
<div className="flex flex-wrap items-center gap-1" data-slot="territory-suggestion-chips">
{visible.map((chip) => (
<Badge
key={chip.key}
asChild
variant={chip.variant}
className="h-8 rounded-sm px-2.5 py-1 text-xs font-medium"
>
<button type="button" onClick={chip.onAccept} aria-label={chip.ariaLabel}>
{chip.label}
</button>
</Badge>
))}
{hiddenCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => setExpanded(true)}
>
+{hiddenCount} sugestões
</Button>
) : null}
</div>
)
}

View file

@ -6,31 +6,37 @@ import { XIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { FieldDescription, FieldError } from '@/components/ui/field'
import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
import { parseTseZoneNumbers, TseZoneParseError } from '@/utilities/tseZone'
const sortedUnique = (values: number[]): number[] =>
[...new Set(values)].sort((left, right) => left - right)
import {
parseTseZoneNumbers,
sortedUniqueZoneNumbers,
TseZoneParseError,
} from '@/utilities/tseZone'
export const TseZoneInput = ({
defaultValues = [],
value,
onChange,
error,
}: {
defaultValues?: number[]
value: number[]
onChange: (zones: number[]) => void
error?: string
}) => {
const [zones, setZones] = useState(() => sortedUnique(defaultValues))
const [draft, setDraft] = useState('')
const [localError, setLocalError] = useState<string>()
const visibleError = localError ?? error
const commit = (value: string) => {
if (!value.trim()) {
const setZones = (updater: number[] | ((current: number[]) => number[])) => {
onChange(sortedUniqueZoneNumbers(typeof updater === 'function' ? updater(value) : updater))
}
const commit = (raw: string) => {
if (!raw.trim()) {
setDraft('')
return
}
try {
const parsed = parseTseZoneNumbers(value)
setZones((current) => sortedUnique([...current, ...parsed]))
const parsed = parseTseZoneNumbers(raw)
setZones((current) => [...current, ...parsed])
setDraft('')
setLocalError(undefined)
} catch (parseError) {
@ -42,9 +48,9 @@ export const TseZoneInput = ({
return (
<>
<input type="hidden" name="tseZones" value={zones.join(',')} />
<input type="hidden" name="tseZones" value={value.join(',')} />
<InputGroup className="min-h-11 h-auto flex-wrap gap-1 p-1">
{zones.map((zoneNumber) => (
{value.map((zoneNumber) => (
<Badge
key={zoneNumber}
asChild
@ -84,7 +90,7 @@ export const TseZoneInput = ({
if (event.key === 'Enter') {
event.preventDefault()
commit(draft)
} else if (event.key === 'Backspace' && !draft && zones.length > 0) {
} else if (event.key === 'Backspace' && !draft && value.length > 0) {
setZones((current) => current.slice(0, -1))
}
}}

484
src/lib/bahiaTseZones.ts Normal file
View file

@ -0,0 +1,484 @@
/**
* Official Bahia municipality ↔ TSE electoral zone mapping.
*
* Source (TSE open data — Results 2024, detalhe da apuração por município e zona, UF BA):
* https://dadosabertos.tse.jus.br/dataset/resultados-2024
* https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2024.zip
* Entry: detalhe_votacao_munzona_2024_BA.csv
* Download SHA-256: 24e75fcf98a57b2362c33d46eb96b99a4b7070966cfc7f6398e12240e3f679f0
*
* Chosen over 2022 result files because TSE rezoning can change municipality↔zone
* assignments between cycles. 2024 municipal elections are the most recent
* election-validated BA coverage of all 417 municipalities (extracted 2026-07-18).
*
* Municipality spellings follow the canonical application names in bahiaTerritories.
* TSE variants (Camacan, Dias d'Ávila, Muquém de São Francisco, Santa Teresinha)
* were reconciled via canonicalizeMunicipalityName. Coverage is independently
* checked by the official test evidence fixture.
*/
import {
bahiaIdentityTerritories,
bahiaMunicipalities,
citiesForTerritory,
isBahiaMunicipality,
type BahiaIdentityTerritory,
} from '@/lib/bahiaTerritories'
export const bahiaMunicipalityTseZones: Readonly<Record<string, readonly number[]>> = {
'Abaíra': [105],
'Abaré': [158],
'Acajutiba': [21],
'Adustina': [52],
'Água Fria': [74],
'Aiquara': [147],
'Alagoinhas': [163],
'Alcobaça': [112],
'Almadina': [135],
'Amargosa': [36],
'Amélia Rodrigues': [192],
'América Dourada': [199],
'Anagé': [161],
'Andaraí': [119],
'Andorinha': [45],
'Angical': [126],
'Anguera': [157],
'Antas': [82],
'Antônio Cardoso': [143],
'Antônio Gonçalves': [53],
'Aporá': [44],
'Apuarema': [23],
'Araças': [200],
'Aracatu': [90],
'Araci': [123],
'Aramari': [163],
'Arataca': [166],
'Aratuípe': [30],
'Aurelino Leal': [73],
'Baianópolis': [126],
'Baixa Grande': [86],
'Banzaê': [110],
'Barra': [77],
'Barra da Estiva': [169],
'Barra do Choça': [139],
'Barra do Mendes': [176],
'Barra do Rocha': [134],
'Barreiras': [70, 75],
'Barro Alto': [174],
'Barro Preto': [136],
'Barrocas': [150],
'Belmonte': [34],
'Belo Campo': [177],
'Biritinga': [150],
'Boa Nova': [59],
'Boa Vista do Tupim': [42],
'Bom Jesus da Lapa': [71],
'Bom Jesus da Serra': [59],
'Boninal': [105],
'Bonito': [69],
'Boquira': [65],
'Botuporã': [168],
'Brejões': [36],
'Brejolândia': [190],
'Brotas de Macaúbas': [94],
'Brumado': [90],
'Buerarema': [166],
'Buritirama': [77],
'Caatiba': [201],
'Cabaceiras do Paraguaçu': [131],
'Cachoeira': [118],
'Caculé': [93],
'Caém': [167],
'Caetanos': [58],
'Caetité': [63],
'Cafarnaum': [55],
'Cairu': [31],
'Caldeirão Grande': [115],
'Camacan': [133],
'Camaçari': [170, 171],
'Camamu': [78],
'Campo Alegre de Lourdes': [67],
'Campo Formoso': [53],
'Canápolis': [99],
'Canarana': [174],
'Canavieiras': [116],
'Candeal': [114],
'Candeias': [127],
'Candiba': [64],
'Cândido Sales': [40],
'Cansanção': [50],
'Canudos': [83],
'Capela do Alto Alegre': [196],
'Capim Grosso': [191],
'Caraíbas': [161],
'Caravelas': [112],
'Cardeal da Silva': [144],
'Carinhanha': [125],
'Casa Nova': [66],
'Castro Alves': [43],
'Catolândia': [100],
'Catu': [129],
'Caturama': [111],
'Central': [159],
'Chorrochó': [158],
'Cícero Dantas': [82],
'Cipó': [79],
'Coaraci': [135],
'Cocos': [61],
'Conceição da Feira': [108],
'Conceição do Almeida': [184],
'Conceição do Coité': [132],
'Conceição do Jacuípe': [192],
'Conde': [21],
'Condeúba': [60],
'Contendas do Sincorá': [58],
'Coração de Maria': [130],
'Cordeiros': [60],
'Coribe': [61],
'Coronel João Sá': [52],
'Correntina': [124],
'Cotegipe': [98],
'Cravolândia': [38],
'Crisópolis': [81],
'Cristópolis': [126],
'Cruz das Almas': [142],
'Curaçá': [85],
'Dário Meira': [147],
"Dias d'Ávila": [186],
'Dom Basílio': [101],
'Dom Macedo Costa': [202],
'Elísio Medrado': [107],
'Encruzilhada': [152],
'Entre Rios': [144],
'Érico Cardoso': [111],
'Esplanada': [21],
'Euclides da Cunha': [102],
'Eunápolis': [188, 203],
'Fátima': [82],
'Feira da Mata': [125],
'Feira de Santana': [154, 155, 156, 157],
'Filadélfia': [149],
'Firmino Alves': [137],
'Floresta Azul': [29],
'Formosa do Rio Preto': [187],
'Gandu': [151],
'Gavião': [191],
'Gentio do Ouro': [68],
'Glória': [84],
'Gongogi': [73],
'Governador Mangabeira': [131],
'Guajeru': [93],
'Guanambi': [64],
'Guaratinga': [189],
'Heliópolis': [110],
'Iaçu': [193],
'Ibiassucê': [93],
'Ibicaraí': [29],
'Ibicoara': [169],
'Ibicuí': [146],
'Ibipeba': [176],
'Ibipitanga': [65],
'Ibiquera': [42],
'Ibirapitanga': [134],
'Ibirapuã': [153],
'Ibirataia': [24],
'Ibitiara': [88],
'Ibititá': [104],
'Ibotirama': [173],
'Ichu': [132],
'Igaporã': [168],
'Igrapiúna': [32],
'Iguaí': [146],
'Ilhéus': [25, 26],
'Inhambupe': [44],
'Ipecaetá': [143],
'Ipiaú': [24],
'Ipirá': [62],
'Ipupiara': [94],
'Irajuba': [76],
'Iramaia': [169],
'Iraquara': [89],
'Irará': [74],
'Irecê': [95],
'Itabela': [189],
'Itaberaba': [42],
'Itabuna': [27, 28],
'Itacaré': [198],
'Itaeté': [119],
'Itagi': [147],
'Itagibá': [147],
'Itagimirim': [188],
'Itaguaçu da Bahia': [68],
'Itaju do Colônia': [137],
'Itajuípe': [136],
'Itamaraju': [172],
'Itamari': [151],
'Itambé': [201],
'Itanagra': [185],
'Itanhém': [148],
'Itaparica': [141],
'Itapé': [27],
'Itapebi': [188],
'Itapetinga': [140],
'Itapicuru': [81],
'Itapitanga': [135],
'Itaquara': [76],
'Itarantim': [138],
'Itatim': [107],
'Itiruçu': [37],
'Itiúba': [149],
'Itororó': [137],
'Ituaçu': [58],
'Ituberá': [32],
'Iuiú': [175],
'Jaborandi': [61],
'Jacaraci': [92],
'Jacobina': [46],
'Jaguaquara': [76],
'Jaguarari': [179],
'Jaguaripe': [30],
'Jandaíra': [49],
'Jequié': [22, 23],
'Jeremoabo': [51],
'Jiquiriçá': [109],
'Jitaúna': [24],
'João Dourado': [199],
'Juazeiro': [47, 48],
'Jucuruçu': [172],
'Jussara': [159],
'Jussari': [28],
'Jussiape': [101],
'Lafaiete Coutinho': [23],
'Lagoa Real': [63],
'Laje': [109],
'Lajedão': [153],
'Lajedinho': [87],
'Lajedo do Tabocal': [37],
'Lamarão': [160],
'Lapão': [104],
'Lauro de Freitas': [180],
'Lençóis': [89],
'Licínio de Almeida': [117],
'Livramento de Nossa Senhora': [101],
'Luís Eduardo Magalhães': [205],
'Macajuba': [87],
'Macarani': [91],
'Macaúbas': [65],
'Macururé': [158],
'Madre de Deus': [162],
'Maetinga': [161],
'Maiquinique': [91],
'Mairi': [86],
'Malhada': [125],
'Malhada de Pedras': [90],
'Manoel Vitorino': [22],
'Mansidão': [97],
'Maracás': [37],
'Maragogipe': [118],
'Maraú': [78],
'Marcionílio Souza': [193],
'Mascote': [133],
'Mata de São João': [185],
'Matina': [113],
'Medeiros Neto': [153],
'Miguel Calmon': [103],
'Milagres': [193],
'Mirangaba': [167],
'Mirante': [59],
'Monte Santo': [50],
'Morpará': [173],
'Morro do Chapéu': [55],
'Mortugaba': [92],
'Mucugê': [119],
'Mucuri': [35],
'Mulungu do Morro': [55],
'Mundo Novo': [54],
'Muniz Ferreira': [202],
'Muquém de São Francisco': [98],
'Muritiba': [131],
'Mutuípe': [109],
'Nazaré': [30],
'Nilo Peçanha': [32],
'Nordestina': [106],
'Nova Canaã': [146],
'Nova Fátima': [196],
'Nova Ibiá': [151],
'Nova Itarana': [36],
'Nova Redenção': [119],
'Nova Soure': [79],
'Nova Viçosa': [35],
'Novo Horizonte': [88],
'Novo Triunfo': [82],
'Olindina': [81],
'Oliveira dos Brejinhos': [94],
'Ouriçangas': [74],
'Ourolândia': [167],
'Palmas de Monte Alto': [175],
'Palmeiras': [89],
'Paramirim': [111],
'Paratinga': [173],
'Paripiranga': [52],
'Pau Brasil': [133],
'Paulo Afonso': [84, 181],
'Pé de Serra': [114],
'Pedrão': [130],
'Pedro Alexandre': [51],
'Piatã': [105],
'Pilão Arcado': [195],
'Pindaí': [117],
'Pindobaçu': [115],
'Pintadas': [62],
'Piraí do Norte': [151],
'Piripá': [177],
'Piritiba': [54],
'Planaltino': [37],
'Planalto': [139],
'Poções': [59],
'Pojuca': [200],
'Ponto Novo': [149],
'Porto Seguro': [121, 122],
'Potiraguá': [138],
'Prado': [112],
'Presidente Dutra': [95],
'Presidente Jânio Quadros': [60],
'Presidente Tancredo Neves': [197],
'Queimadas': [106],
'Quijingue': [102],
'Quixabeira': [191],
'Rafael Jambeiro': [43],
'Remanso': [67],
'Retirolândia': [196],
'Riachão das Neves': [182],
'Riachão do Jacuípe': [114],
'Riacho de Santana': [113],
'Ribeira do Amparo': [79],
'Ribeira do Pombal': [110],
'Ribeirão do Largo': [152],
'Rio de Contas': [101],
'Rio do Antônio': [93],
'Rio do Pires': [111],
'Rio Real': [49],
'Rodelas': [158],
'Ruy Barbosa': [87],
'Salinas da Margarida': [30],
'Salvador': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
'Santa Bárbara': [160],
'Santa Brígida': [181],
'Santa Cruz Cabrália': [121],
'Santa Cruz da Vitória': [29],
'Santa Inês': [38],
'Santa Luzia': [133],
'Santa Maria da Vitória': [72],
'Santa Rita de Cássia': [97],
'Santa Teresinha': [107],
'Santaluz': [145],
'Santana': [99],
'Santanópolis': [74],
'Santo Amaro': [178],
'Santo Antônio de Jesus': [56, 202],
'Santo Estêvão': [143],
'São Desidério': [100],
'São Domingos': [120],
'São Felipe': [184],
'São Félix': [118],
'São Félix do Coribe': [72],
'São Francisco do Conde': [162],
'São Gabriel': [95],
'São Gonçalo dos Campos': [108],
'São José da Vitória': [166],
'São José do Jacuípe': [191],
'São Miguel das Matas': [36],
'São Sebastião do Passé': [128],
'Sapeaçu': [142],
'Sátiro Dias': [44],
'Saubara': [178],
'Saúde': [115],
'Seabra': [88],
'Sebastião Laranjeiras': [175],
'Senhor do Bonfim': [45],
'Sento Sé': [96],
'Serra do Ramalho': [71],
'Serra Dourada': [190],
'Serra Preta': [155],
'Serrinha': [150],
'Serrolândia': [46],
'Simões Filho': [33],
'Sítio do Mato': [71],
'Sítio do Quinto': [51],
'Sobradinho': [66],
'Souto Soares': [174],
'Tabocas do Brejo Velho': [190],
'Tanhaçu': [58],
'Tanque Novo': [168],
'Tanquinho': [160],
'Taperoá': [32],
'Tapiramutá': [54],
'Teixeira de Freitas': [183],
'Teodoro Sampaio': [130],
'Teofilândia': [123],
'Teolândia': [197],
'Terra Nova': [128],
'Tremedal': [177],
'Tucano': [80],
'Uauá': [83],
'Ubaíra': [38],
'Ubaitaba': [73],
'Ubatã': [134],
'Uibaí': [159],
'Umburanas': [167],
'Una': [116],
'Urandi': [117],
'Uruçuca': [198],
'Utinga': [69],
'Valença': [31],
'Valente': [120],
'Várzea da Roça': [86],
'Várzea do Poço': [103],
'Várzea Nova': [55],
'Varzedo': [56],
'Vera Cruz': [141],
'Vereda': [148],
'Vitória da Conquista': [39, 40, 41],
'Wagner': [69],
'Wanderley': [98],
'Wenceslau Guimarães': [197],
'Xique-Xique': [68],
} as const
const emptyZones: readonly number[] = []
export const tseZonesForCity = (city: string): readonly number[] => {
if (!isBahiaMunicipality(city)) return emptyZones
return bahiaMunicipalityTseZones[city] ?? emptyZones
}
const citiesByZone = new Map<number, string[]>()
for (const city of bahiaMunicipalities) {
for (const zone of bahiaMunicipalityTseZones[city] ?? []) {
const list = citiesByZone.get(zone)
if (list) list.push(city)
else citiesByZone.set(zone, [city])
}
}
for (const list of citiesByZone.values()) {
list.sort((left, right) => left.localeCompare(right, 'pt-BR'))
}
const zonesByTerritory = new Map<string, readonly number[]>()
for (const territory of bahiaIdentityTerritories) {
const zones = new Set<number>()
for (const city of citiesForTerritory(territory)) {
for (const zone of tseZonesForCity(city)) zones.add(zone)
}
zonesByTerritory.set(
territory,
[...zones].sort((left, right) => left - right),
)
}
export const tseZonesForTerritory = (
territory: BahiaIdentityTerritory,
): readonly number[] => zonesByTerritory.get(territory) ?? emptyZones
export const citiesForTseZone = (zoneNumber: number): readonly string[] =>
citiesByZone.get(zoneNumber) ?? []

View file

@ -37,7 +37,7 @@ export const MAX_NUCLEUS_REGIONS = 27
export const MAX_NUCLEUS_CITIES = 27
export const MAX_NUCLEUS_NEIGHBORHOODS = 30
const dedupeTrimmedStrings = (values: string[]): string[] => {
export const dedupeTrimmedStrings = (values: readonly string[]): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const value of values) {

View file

@ -0,0 +1,106 @@
import {
citiesForTerritory,
isBahiaIdentityTerritory,
} from '@/lib/bahiaTerritories'
import {
citiesForTseZone,
tseZonesForCity,
tseZonesForTerritory,
} from '@/lib/bahiaTseZones'
import { MAX_NUCLEUS_CITIES } from '@/lib/schemas/nucleus'
import { sortedUniqueZoneNumbers } from '@/utilities/tseZone'
export type ZoneSuggestion = {
kind: 'city' | 'territory'
label: string
zonesToAdd: number[]
}
export type CitySuggestion = {
kind: 'sibling' | 'zone'
city: string
}
export type TerritorySuggestionInput = {
cities: readonly string[]
regions: readonly string[]
tseZones: readonly number[]
}
export type TerritorySuggestions = {
zoneSuggestions: ZoneSuggestion[]
citySuggestions: CitySuggestion[]
/** Zones present in the form that are outside the official city/TI set. Empty when no geography. */
outsideZones: number[]
}
const missingZones = (official: readonly number[], current: ReadonlySet<number>): number[] =>
official.filter((zone) => !current.has(zone))
const comparePtBr = (left: string, right: string) => left.localeCompare(right, 'pt-BR')
/**
* Pure suggestion engine for opt-in territory ↔ TSE zone chips.
* Returns only what is still missing; never auto-applies.
*/
export const buildTerritorySuggestions = ({
cities,
regions,
tseZones,
}: TerritorySuggestionInput): TerritorySuggestions => {
const citySet = new Set(cities)
const zoneSet = new Set(tseZones)
const displayRegions = regions.filter(isBahiaIdentityTerritory)
const official = new Set<number>()
const zoneSuggestions: ZoneSuggestion[] = []
for (const city of cities) {
const cityZones = tseZonesForCity(city)
for (const zone of cityZones) official.add(zone)
const zonesToAdd = missingZones(cityZones, zoneSet)
if (zonesToAdd.length === 0) continue
zoneSuggestions.push({ kind: 'city', label: city, zonesToAdd })
}
for (const territory of displayRegions) {
const territoryZones = tseZonesForTerritory(territory)
for (const zone of territoryZones) official.add(zone)
const zonesToAdd = missingZones(territoryZones, zoneSet)
if (zonesToAdd.length === 0) continue
zoneSuggestions.push({ kind: 'territory', label: territory, zonesToAdd })
}
const cityByName = new Map<string, CitySuggestion>()
if (cities.length < MAX_NUCLEUS_CITIES) {
for (const territory of displayRegions) {
for (const city of citiesForTerritory(territory)) {
if (citySet.has(city) || cityByName.has(city)) continue
cityByName.set(city, { kind: 'sibling', city })
}
}
for (const zoneNumber of tseZones) {
for (const city of citiesForTseZone(zoneNumber)) {
if (citySet.has(city) || cityByName.has(city)) continue
// Prefer sibling when both TI and zone sources suggest the same city
// (siblings are inserted first above).
cityByName.set(city, { kind: 'zone', city })
}
}
}
return {
zoneSuggestions: [...zoneSuggestions].sort((left, right) =>
comparePtBr(left.label, right.label),
),
citySuggestions: [...cityByName.values()].sort((left, right) =>
comparePtBr(left.city, right.city),
),
outsideZones:
official.size === 0
? []
: sortedUniqueZoneNumbers(tseZones.filter((zone) => !official.has(zone))),
}
}

View file

@ -15,7 +15,10 @@ const toOptions = (values: readonly string[]): StrictComboboxOption[] =>
export const territoryComboboxOptions = toOptions(bahiaIdentityTerritories)
/** Full Bahia municipality list — stable module reference for client filters. */
export const allMunicipalityComboboxOptions = toOptions(bahiaMunicipalities)
export const municipalityComboboxOptions = (region?: string | null): StrictComboboxOption[] =>
toOptions(
region && isBahiaIdentityTerritory(region) ? citiesForTerritory(region) : bahiaMunicipalities,
)
region && isBahiaIdentityTerritory(region)
? toOptions(citiesForTerritory(region))
: allMunicipalityComboboxOptions

View file

@ -1,24 +1,25 @@
export class TseZoneParseError extends Error {}
export const sortedUniqueZoneNumbers = (values: readonly number[]): number[] =>
[...new Set(values)].sort((left, right) => left - right)
export const parseTseZoneNumbers = (value: string): number[] => {
if (!/^[\d,\s]*$/.test(value)) {
throw new TseZoneParseError('Use apenas números, vírgulas e espaços nas Zonas TSE.')
}
return [
...new Set(
value
.split(/[,\s]+/)
.filter(Boolean)
.map((token) => {
const zoneNumber = Number(token)
if (!Number.isInteger(zoneNumber) || zoneNumber < 1 || zoneNumber > 999) {
throw new TseZoneParseError(
`Zona TSE inválida: "${token}". Use números de 1 a 999.`,
)
}
return zoneNumber
}),
),
].sort((left, right) => left - right)
return sortedUniqueZoneNumbers(
value
.split(/[,\s]+/)
.filter(Boolean)
.map((token) => {
const zoneNumber = Number(token)
if (!Number.isInteger(zoneNumber) || zoneNumber < 1 || zoneNumber > 999) {
throw new TseZoneParseError(
`Zona TSE inválida: "${token}". Use números de 1 a 999.`,
)
}
return zoneNumber
}),
)
}

View file

@ -61,6 +61,82 @@ test.describe('Campaign nucleus journeys', () => {
expect(created.docs[0]?.tseZones?.map(({ zoneNumber }) => zoneNumber)).toEqual([12, 58])
})
test('accepts opt-in territory suggestion chips for cities and TSE zones', async ({
campaign,
page,
}) => {
test.setTimeout(240_000)
const generalEmail = `${campaign.fixtures.value('suggestions')}@example.com`
const password = campaign.fixtures.value('SuggestionsPassword')
const nucleusName = campaign.fixtures.value('Núcleo sugestões')
await campaign.transaction(async (payload, req) => {
await payload.create({
collection: 'campaignUser',
data: {
name: campaign.fixtures.value('Coordenação Geral'),
email: generalEmail,
password,
role: 'geral',
},
depth: 0,
req,
})
})
await campaign.login(page, generalEmail, password)
await page.goto(`${campaign.baseURL}/campanha/nucleos/novo`)
await page.getByLabel('Nome do núcleo *').fill(nucleusName)
const cityInput = page.getByLabel('Municípios')
await cityInput.fill('Itiruçu')
await page.getByRole('option', { name: 'Itiruçu', exact: true }).click()
await expect(
page.getByRole('button', { name: 'Remover território Vale do Jiquiriçá' }),
).toBeVisible()
// Wait for both suggestion strips to settle (city siblings remount the strip).
const zoneChip = page.getByRole('button', { name: 'Adicionar zonas TSE de Itiruçu' })
await expect(zoneChip).toBeVisible()
await expect(page.getByRole('button', { name: /\+\d+ sugestões|Adicionar município / }).first()).toBeVisible()
// Native DOM click — Playwright's pointer click can race the strip remount.
await zoneChip.evaluate((el: HTMLElement) => el.click())
await expect(page.getByRole('button', { name: 'Remover Zona TSE 37' })).toBeVisible()
const expandCities = page.getByRole('button', { name: /\+\d+ sugestões/ })
if (await expandCities.count()) {
await expandCities.evaluate((el: HTMLElement) => el.click())
}
const cityChip = page.getByRole('button', { name: 'Adicionar município Maracás' })
await expect(cityChip).toBeVisible()
await cityChip.evaluate((el: HTMLElement) => el.click())
await expect(page.getByRole('button', { name: 'Remover município Maracás' })).toBeVisible()
await page.getByRole('button', { name: 'Criar núcleo' }).click()
await expect
.poll(
async () =>
(
await campaign.payload.find({
collection: 'electoralNucleus',
where: { name: { equals: nucleusName } },
depth: 0,
limit: 1,
})
).docs[0],
{ timeout: 30_000 },
)
.toBeTruthy()
const created = await campaign.payload.find({
collection: 'electoralNucleus',
where: { name: { equals: nucleusName } },
depth: 0,
limit: 1,
})
expect(created.docs[0]?.cities).toEqual(expect.arrayContaining(['Itiruçu', 'Maracás']))
expect(created.docs[0]?.tseZones?.map(({ zoneNumber }) => zoneNumber)).toEqual([37])
})
test('updates coordinator assignment from the nucleus detail', async ({ campaign, page }) => {
test.slow()
const email = `${campaign.fixtures.value('assignment')}@example.com`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
// @vitest-environment node
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
bahiaMunicipalities,
citiesForTerritory,
isBahiaIdentityTerritory,
} from '@/lib/bahiaTerritories'
import {
bahiaMunicipalityTseZones,
citiesForTseZone,
tseZonesForCity,
tseZonesForTerritory,
} from '@/lib/bahiaTseZones'
type OfficialEvidence = {
municipalityCount: number
assignments: Array<{ municipality: string; zones: number[] }>
evidenceSha256: string
}
const officialEvidence = JSON.parse(
readFileSync(new URL('../fixtures/bahia-tse-zones.official.json', import.meta.url), 'utf8'),
) as OfficialEvidence
describe('Bahia municipality ↔ TSE zone mapping', () => {
it('covers all 417 Bahia municipalities with zones in 1–999', () => {
expect(Object.keys(bahiaMunicipalityTseZones)).toHaveLength(417)
expect(bahiaMunicipalities).toHaveLength(417)
for (const city of bahiaMunicipalities) {
const zones = bahiaMunicipalityTseZones[city]
expect(zones, city).toBeDefined()
expect(zones.length, city).toBeGreaterThan(0)
for (const zone of zones) {
expect(zone).toBeGreaterThanOrEqual(1)
expect(zone).toBeLessThanOrEqual(999)
}
expect(zones).toEqual([...zones].sort((left, right) => left - right))
expect(new Set(zones).size).toBe(zones.length)
}
})
it('matches the independently downloaded official municipality↔zone evidence', () => {
expect(officialEvidence.municipalityCount).toBe(417)
expect(
officialEvidence.assignments.map(({ municipality, zones }) => ({ municipality, zones })),
).toEqual(
Object.entries(bahiaMunicipalityTseZones)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, zones]) => ({ municipality, zones: [...zones] })),
)
})
it('matches every official assignment by a fixed evidence checksum', () => {
const assignmentRows = Object.entries(bahiaMunicipalityTseZones)
.sort(([left], [right]) => left.localeCompare(right, 'pt-BR'))
.map(([municipality, zones]) => `M\t${municipality}\t${zones.join(',')}\n`)
.join('')
expect(createHash('sha256').update(assignmentRows).digest('hex')).toBe(
officialEvidence.evidenceSha256,
)
})
it('supports bidirectional lookups and territory unions', () => {
expect(tseZonesForCity('Salvador')).toEqual([
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
])
expect(tseZonesForCity('Itiruçu')).toEqual([37])
expect(tseZonesForCity('Maracás')).toEqual([37])
expect(tseZonesForCity('Município inexistente')).toEqual([])
expect(tseZonesForCity('')).toEqual([])
expect(tseZonesForCity('Salvador').includes(1)).toBe(true)
expect(tseZonesForCity('Salvador').includes(999)).toBe(false)
expect(tseZonesForCity('Município inexistente').includes(1)).toBe(false)
expect(citiesForTseZone(37)).toEqual(
expect.arrayContaining(['Itiruçu', 'Maracás']),
)
expect([...citiesForTseZone(37)]).toEqual(
[...citiesForTseZone(37)].sort((left, right) => left.localeCompare(right, 'pt-BR')),
)
expect(citiesForTseZone(9999)).toEqual([])
expect(isBahiaIdentityTerritory('Vale do Jiquiriçá')).toBe(true)
const valeZones = tseZonesForTerritory('Vale do Jiquiriçá')
const expectedUnion = [
...new Set(citiesForTerritory('Vale do Jiquiriçá').flatMap((city) => [...tseZonesForCity(city)])),
].sort((left, right) => left - right)
expect(valeZones).toEqual(expectedUnion)
expect(valeZones.includes(37)).toBe(true)
})
})

View file

@ -1,4 +1,4 @@
import { createElement } from 'react'
import { createElement, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
@ -8,7 +8,7 @@ import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog
import { NucleusFormFields } from '@/components/campaign/NucleusForm'
import { NucleusList } from '@/components/campaign/NucleusList'
import { getNucleusPaginationPages } from '@/components/campaign/NucleusPagination'
import { NucleusTerritoryFields } from '@/components/campaign/NucleusTerritoryFields'
import { NucleusTerritoryAndZonesFields } from '@/components/campaign/NucleusTerritoryAndZonesFields'
import { TseZoneInput } from '@/components/campaign/TseZoneInput'
import { campaignNav, getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import {
@ -79,7 +79,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('adds a municipality as a chip via the strict combobox and derives its territory chip', async () => {
render(createElement(NucleusTerritoryFields))
render(createElement(NucleusTerritoryAndZonesFields))
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
@ -95,7 +95,7 @@ describe('campaign nucleus UI contracts', () => {
it('uses neutral editable lookup identifiers and exposes only the canonical hidden fields', () => {
const { container } = render(
createElement(NucleusTerritoryFields, {
createElement(NucleusTerritoryAndZonesFields, {
fieldErrors: { regions: ['Território inválido.'], cities: ['Município inválido.'] },
}),
)
@ -131,7 +131,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('shows only word-start municipality matches', () => {
render(createElement(NucleusTerritoryFields))
render(createElement(NucleusTerritoryAndZonesFields))
const city = screen.getByLabelText('Municípios')
fireEvent.focus(city)
@ -144,7 +144,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('clears an invalid region or city draft on blur and exposes an error', () => {
render(createElement(NucleusTerritoryFields))
render(createElement(NucleusTerritoryAndZonesFields))
const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
@ -162,7 +162,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('allows manually adding regions only until a city is selected, after which they become derived', async () => {
render(createElement(NucleusTerritoryFields))
render(createElement(NucleusTerritoryAndZonesFields))
const region = screen.getByLabelText('Territórios de identidade') as HTMLInputElement
const city = screen.getByLabelText('Municípios') as HTMLInputElement
@ -183,7 +183,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('derives regions from multiple cities across territories', () => {
render(createElement(NucleusTerritoryFields))
render(createElement(NucleusTerritoryAndZonesFields))
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
@ -204,7 +204,7 @@ describe('campaign nucleus UI contracts', () => {
it('enables neighborhoods only when exactly one city is set, resetting them otherwise', async () => {
render(
createElement(NucleusTerritoryFields, {
createElement(NucleusTerritoryAndZonesFields, {
values: { cities: ['Seabra'], neighborhoods: ['Centro'] },
}),
)
@ -223,7 +223,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('removes a city chip and re-derives the territory from the remaining cities', async () => {
render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra', 'Salvador'] } }))
render(createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra', 'Salvador'] } }))
expect(screen.getByText('Chapada Diamantina')).toBeTruthy()
expect(screen.getByText('Metropolitano de Salvador')).toBeTruthy()
@ -235,7 +235,7 @@ describe('campaign nucleus UI contracts', () => {
})
it('adds multiple neighborhoods via Enter and removes the last one via Backspace', () => {
render(createElement(NucleusTerritoryFields, { values: { cities: ['Seabra'] } }))
render(createElement(NucleusTerritoryAndZonesFields, { values: { cities: ['Seabra'] } }))
const neighborhood = screen.getByLabelText('Bairros') as HTMLInputElement
fireEvent.change(neighborhood, { target: { value: 'Centro' } })
@ -369,6 +369,38 @@ describe('campaign nucleus UI contracts', () => {
expect(parseNucleusUpdateFormData(formData).neighborhoods).toEqual([])
})
it('offers opt-in TSE zone and sibling city suggestion chips and unions on accept', async () => {
render(createElement(NucleusTerritoryAndZonesFields))
const city = screen.getByLabelText('Municípios') as HTMLInputElement
fireEvent.focus(city)
fireEvent.change(city, { target: { value: 'Itiruçu' } })
fireEvent.keyDown(city, { key: 'ArrowDown' })
fireEvent.keyDown(city, { key: 'Enter' })
await waitFor(() => expect(screen.getByText('Itiruçu')).toBeTruthy())
expect(screen.getByText('Vale do Jiquiriçá')).toBeTruthy()
const zoneChip = await screen.findByRole('button', {
name: 'Adicionar zonas TSE de Itiruçu',
})
expect(
screen.getByRole('button', { name: 'Adicionar zonas TSE de Vale do Jiquiriçá' }),
).toBeTruthy()
const expandCities = screen.queryByRole('button', { name: /\+\d+ sugestões/ })
if (expandCities) fireEvent.click(expandCities)
const cityChip = await screen.findByRole('button', { name: 'Adicionar município Maracás' })
fireEvent.click(zoneChip)
await waitFor(() => expect(screen.getByLabelText('Remover Zona TSE 37')).toBeTruthy())
expect(screen.queryByRole('button', { name: 'Adicionar zonas TSE de Itiruçu' })).toBeNull()
fireEvent.click(cityChip)
await waitFor(() => expect(screen.getByLabelText('Remover município Maracás')).toBeTruthy())
expect(screen.queryByRole('button', { name: 'Adicionar município Maracás' })).toBeNull()
})
it('parses Zona TSE tags as sorted unique bounded numbers', () => {
expect(parseTseZoneNumbers('58, 12 58\n5')).toEqual([5, 12, 58])
expect(() => parseTseZoneNumbers('58;59')).toThrow('apenas números')
@ -376,7 +408,11 @@ describe('campaign nucleus UI contracts', () => {
})
it('renders sorted unique Zona TSE tags inside the editable input group', () => {
render(createElement(TseZoneInput, { defaultValues: [58] }))
const ControlledTseZoneInput = () => {
const [zones, setZones] = useState([58])
return createElement(TseZoneInput, { value: zones, onChange: setZones })
}
render(createElement(ControlledTseZoneInput))
const input = screen.getByLabelText('Adicionar Zona TSE')
fireEvent.change(input, { target: { value: '12' } })

View file

@ -0,0 +1,114 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { citiesForTerritory } from '@/lib/bahiaTerritories'
import { tseZonesForCity, tseZonesForTerritory } from '@/lib/bahiaTseZones'
import { MAX_NUCLEUS_CITIES } from '@/lib/schemas/nucleus'
import { buildTerritorySuggestions } from '@/lib/territorySuggestions'
describe('buildTerritorySuggestions', () => {
it('suggests missing city and territory zones for the Vale do Jiquiriçá example', () => {
const result = buildTerritorySuggestions({
cities: ['Itiruçu'],
regions: ['Vale do Jiquiriçá'],
tseZones: [],
})
expect(result.zoneSuggestions).toEqual(
expect.arrayContaining([
{
kind: 'city',
label: 'Itiruçu',
zonesToAdd: [...tseZonesForCity('Itiruçu')],
},
{
kind: 'territory',
label: 'Vale do Jiquiriçá',
zonesToAdd: [...tseZonesForTerritory('Vale do Jiquiriçá')],
},
]),
)
expect(result.zoneSuggestions.map((s) => s.label)).toEqual(
[...result.zoneSuggestions.map((s) => s.label)].sort((a, b) => a.localeCompare(b, 'pt-BR')),
)
expect(result.citySuggestions.map((s) => s.city)).toContain('Maracás')
expect(result.citySuggestions.find((s) => s.city === 'Maracás')?.kind).toBe('sibling')
expect(result.citySuggestions.map((s) => s.city)).not.toContain('Itiruçu')
})
it('hides city zone chip when that city set is complete but keeps territory chip', () => {
const itirucuZones = [...tseZonesForCity('Itiruçu')]
const result = buildTerritorySuggestions({
cities: ['Itiruçu'],
regions: ['Vale do Jiquiriçá'],
tseZones: itirucuZones,
})
expect(result.zoneSuggestions.find((s) => s.kind === 'city' && s.label === 'Itiruçu')).toBeUndefined()
const territoryChip = result.zoneSuggestions.find(
(s) => s.kind === 'territory' && s.label === 'Vale do Jiquiriçá',
)
expect(territoryChip).toBeDefined()
expect(territoryChip?.zonesToAdd.length).toBeGreaterThan(0)
for (const zone of itirucuZones) {
expect(territoryChip?.zonesToAdd).not.toContain(zone)
}
})
it('returns no zone chips when the territory union is already present', () => {
const valeZones = [...tseZonesForTerritory('Vale do Jiquiriçá')]
const valeCities = [...citiesForTerritory('Vale do Jiquiriçá')]
const result = buildTerritorySuggestions({
cities: valeCities,
regions: ['Vale do Jiquiriçá'],
tseZones: valeZones,
})
expect(result.zoneSuggestions).toEqual([])
// Shared zones may still suggest municipalities outside the TI — that is expected.
expect(result.citySuggestions.every((s) => !valeCities.includes(s.city))).toBe(true)
})
it('suggests municipalities from a selected shared zone and dedupes with siblings', () => {
const result = buildTerritorySuggestions({
cities: ['Itiruçu'],
regions: ['Vale do Jiquiriçá'],
tseZones: [37],
})
const maracas = result.citySuggestions.filter((s) => s.city === 'Maracás')
expect(maracas).toHaveLength(1)
expect(maracas[0]?.kind).toBe('sibling')
})
it('suggests municipalities from zone alone when no region is selected', () => {
const result = buildTerritorySuggestions({
cities: [],
regions: [],
tseZones: [37],
})
expect(result.citySuggestions.map((s) => s.city)).toEqual(
expect.arrayContaining(['Itiruçu', 'Maracás']),
)
expect(result.citySuggestions.every((s) => s.kind === 'zone')).toBe(true)
})
it('suppresses city suggestions when the municipality quota is reached', () => {
const cities = [
...citiesForTerritory('Vale do Jiquiriçá'),
...citiesForTerritory('Metropolitano de Salvador'),
].slice(0, MAX_NUCLEUS_CITIES)
expect(cities).toHaveLength(MAX_NUCLEUS_CITIES)
const result = buildTerritorySuggestions({
cities,
regions: ['Vale do Jiquiriçá', 'Metropolitano de Salvador'],
tseZones: [1],
})
expect(result.citySuggestions).toEqual([])
})
})