-
+
Localidade
@@ -206,10 +205,6 @@ export const NucleusFormFields = ({
{errorFor('territoryNotes')}
) : null}
-
- Zonas TSE
-
-
diff --git a/src/components/campaign/NucleusTerritoryFields.tsx b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx
similarity index 76%
rename from src/components/campaign/NucleusTerritoryFields.tsx
rename to src/components/campaign/NucleusTerritoryAndZonesFields.tsx
index 8926807b..ca1295bd 100644
--- a/src/components/campaign/NucleusTerritoryFields.tsx
+++ b/src/components/campaign/NucleusTerritoryAndZonesFields.tsx
@@ -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()
- 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 = ({
)
-export const NucleusTerritoryFields = ({
+export const NucleusTerritoryAndZonesFields = ({
values,
fieldErrors = {},
}: {
- values?: TerritoryValues
+ values?: TerritoryAndZonesValues
fieldErrors?: Record
}) => {
- 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 = ({
Municípios
+ addCity(suggestion.city)}
+ />
{cities.length ? (
{visibleNeighborhoodError}
) : null}
+
+
+ Zonas TSE
+ {zoneSuggestions.length > 0 ? (
+
+ Zonas sugeridas pelo cadastro oficial do TSE — confira antes de salvar.
+
+ ) : null}
+
+ setTseZones((current) => sortedUniqueZoneNumbers([...current, ...suggestion.zonesToAdd]))
+ }
+ />
+
+ {outsideZones.length > 0 ? (
+
+ {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.`}
+
+ ) : null}
+
)
}
diff --git a/src/components/campaign/TerritorySuggestionChips.tsx b/src/components/campaign/TerritorySuggestionChips.tsx
new file mode 100644
index 00000000..efe22f55
--- /dev/null
+++ b/src/components/campaign/TerritorySuggestionChips.tsx
@@ -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 (
+
+ {visible.map((chip) => (
+
+
+ {chip.label}
+
+
+ ))}
+ {hiddenCount > 0 ? (
+ setExpanded(true)}
+ >
+ +{hiddenCount} sugestões
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/campaign/TseZoneInput.tsx b/src/components/campaign/TseZoneInput.tsx
index f39ab889..efd5fd3e 100644
--- a/src/components/campaign/TseZoneInput.tsx
+++ b/src/components/campaign/TseZoneInput.tsx
@@ -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()
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 (
<>
-
+
- {zones.map((zoneNumber) => (
+ {value.map((zoneNumber) => (
0) {
+ } else if (event.key === 'Backspace' && !draft && value.length > 0) {
setZones((current) => current.slice(0, -1))
}
}}
diff --git a/src/lib/bahiaTseZones.ts b/src/lib/bahiaTseZones.ts
new file mode 100644
index 00000000..407c2895
--- /dev/null
+++ b/src/lib/bahiaTseZones.ts
@@ -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> = {
+ '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()
+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()
+for (const territory of bahiaIdentityTerritories) {
+ const zones = new Set()
+ 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) ?? []
diff --git a/src/lib/schemas/nucleus.ts b/src/lib/schemas/nucleus.ts
index 33cf066a..b76cce7c 100644
--- a/src/lib/schemas/nucleus.ts
+++ b/src/lib/schemas/nucleus.ts
@@ -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()
const result: string[] = []
for (const value of values) {
diff --git a/src/lib/territorySuggestions.ts b/src/lib/territorySuggestions.ts
new file mode 100644
index 00000000..22968cd5
--- /dev/null
+++ b/src/lib/territorySuggestions.ts
@@ -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[] =>
+ 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()
+
+ 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()
+
+ 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))),
+ }
+}
diff --git a/src/utilities/territoryComboboxOptions.ts b/src/utilities/territoryComboboxOptions.ts
index dcba9cf8..a1a7d6b0 100644
--- a/src/utilities/territoryComboboxOptions.ts
+++ b/src/utilities/territoryComboboxOptions.ts
@@ -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
diff --git a/src/utilities/tseZone.ts b/src/utilities/tseZone.ts
index 9d8a61d4..a5234a13 100644
--- a/src/utilities/tseZone.ts
+++ b/src/utilities/tseZone.ts
@@ -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
+ }),
+ )
}
diff --git a/tests/e2e/campaign-nuclei.e2e.spec.ts b/tests/e2e/campaign-nuclei.e2e.spec.ts
index 5ce3533b..d58d2d29 100644
--- a/tests/e2e/campaign-nuclei.e2e.spec.ts
+++ b/tests/e2e/campaign-nuclei.e2e.spec.ts
@@ -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`
diff --git a/tests/fixtures/bahia-tse-zones.official.json b/tests/fixtures/bahia-tse-zones.official.json
new file mode 100644
index 00000000..ea9a5ee5
--- /dev/null
+++ b/tests/fixtures/bahia-tse-zones.official.json
@@ -0,0 +1,2553 @@
+{
+ "provenance": {
+ "description": "Test-only evidence of Bahia municipality↔TSE zone pairs derived independently from the TSE 2024 detalhe_votacao_munzona BA CSV. It was not generated from src/lib/bahiaTseZones.ts.",
+ "source": {
+ "portal": "https://dadosabertos.tse.jus.br/dataset/resultados-2024",
+ "resource": "Detalhe da apuração por município e zona",
+ "url": "https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2024.zip",
+ "entry": "detalhe_votacao_munzona_2024_BA.csv",
+ "electionYear": 2024,
+ "downloadSha256": "24e75fcf98a57b2362c33d46eb96b99a4b7070966cfc7f6398e12240e3f679f0"
+ },
+ "normalization": "TSE NM_MUNICIPIO spellings were reconciled to canonical bahiaTerritories names via accent/case folding plus known aliases (Camacan, Dias d'Ávila, Muquém de São Francisco, Santa Teresinha). The evidence SHA-256 covers every canonical municipality and its sorted unique zone numbers."
+ },
+ "municipalityCount": 417,
+ "assignments": [
+ {
+ "municipality": "Abaíra",
+ "zones": [
+ 105
+ ]
+ },
+ {
+ "municipality": "Abaré",
+ "zones": [
+ 158
+ ]
+ },
+ {
+ "municipality": "Acajutiba",
+ "zones": [
+ 21
+ ]
+ },
+ {
+ "municipality": "Adustina",
+ "zones": [
+ 52
+ ]
+ },
+ {
+ "municipality": "Água Fria",
+ "zones": [
+ 74
+ ]
+ },
+ {
+ "municipality": "Aiquara",
+ "zones": [
+ 147
+ ]
+ },
+ {
+ "municipality": "Alagoinhas",
+ "zones": [
+ 163
+ ]
+ },
+ {
+ "municipality": "Alcobaça",
+ "zones": [
+ 112
+ ]
+ },
+ {
+ "municipality": "Almadina",
+ "zones": [
+ 135
+ ]
+ },
+ {
+ "municipality": "Amargosa",
+ "zones": [
+ 36
+ ]
+ },
+ {
+ "municipality": "Amélia Rodrigues",
+ "zones": [
+ 192
+ ]
+ },
+ {
+ "municipality": "América Dourada",
+ "zones": [
+ 199
+ ]
+ },
+ {
+ "municipality": "Anagé",
+ "zones": [
+ 161
+ ]
+ },
+ {
+ "municipality": "Andaraí",
+ "zones": [
+ 119
+ ]
+ },
+ {
+ "municipality": "Andorinha",
+ "zones": [
+ 45
+ ]
+ },
+ {
+ "municipality": "Angical",
+ "zones": [
+ 126
+ ]
+ },
+ {
+ "municipality": "Anguera",
+ "zones": [
+ 157
+ ]
+ },
+ {
+ "municipality": "Antas",
+ "zones": [
+ 82
+ ]
+ },
+ {
+ "municipality": "Antônio Cardoso",
+ "zones": [
+ 143
+ ]
+ },
+ {
+ "municipality": "Antônio Gonçalves",
+ "zones": [
+ 53
+ ]
+ },
+ {
+ "municipality": "Aporá",
+ "zones": [
+ 44
+ ]
+ },
+ {
+ "municipality": "Apuarema",
+ "zones": [
+ 23
+ ]
+ },
+ {
+ "municipality": "Araças",
+ "zones": [
+ 200
+ ]
+ },
+ {
+ "municipality": "Aracatu",
+ "zones": [
+ 90
+ ]
+ },
+ {
+ "municipality": "Araci",
+ "zones": [
+ 123
+ ]
+ },
+ {
+ "municipality": "Aramari",
+ "zones": [
+ 163
+ ]
+ },
+ {
+ "municipality": "Arataca",
+ "zones": [
+ 166
+ ]
+ },
+ {
+ "municipality": "Aratuípe",
+ "zones": [
+ 30
+ ]
+ },
+ {
+ "municipality": "Aurelino Leal",
+ "zones": [
+ 73
+ ]
+ },
+ {
+ "municipality": "Baianópolis",
+ "zones": [
+ 126
+ ]
+ },
+ {
+ "municipality": "Baixa Grande",
+ "zones": [
+ 86
+ ]
+ },
+ {
+ "municipality": "Banzaê",
+ "zones": [
+ 110
+ ]
+ },
+ {
+ "municipality": "Barra",
+ "zones": [
+ 77
+ ]
+ },
+ {
+ "municipality": "Barra da Estiva",
+ "zones": [
+ 169
+ ]
+ },
+ {
+ "municipality": "Barra do Choça",
+ "zones": [
+ 139
+ ]
+ },
+ {
+ "municipality": "Barra do Mendes",
+ "zones": [
+ 176
+ ]
+ },
+ {
+ "municipality": "Barra do Rocha",
+ "zones": [
+ 134
+ ]
+ },
+ {
+ "municipality": "Barreiras",
+ "zones": [
+ 70,
+ 75
+ ]
+ },
+ {
+ "municipality": "Barro Alto",
+ "zones": [
+ 174
+ ]
+ },
+ {
+ "municipality": "Barro Preto",
+ "zones": [
+ 136
+ ]
+ },
+ {
+ "municipality": "Barrocas",
+ "zones": [
+ 150
+ ]
+ },
+ {
+ "municipality": "Belmonte",
+ "zones": [
+ 34
+ ]
+ },
+ {
+ "municipality": "Belo Campo",
+ "zones": [
+ 177
+ ]
+ },
+ {
+ "municipality": "Biritinga",
+ "zones": [
+ 150
+ ]
+ },
+ {
+ "municipality": "Boa Nova",
+ "zones": [
+ 59
+ ]
+ },
+ {
+ "municipality": "Boa Vista do Tupim",
+ "zones": [
+ 42
+ ]
+ },
+ {
+ "municipality": "Bom Jesus da Lapa",
+ "zones": [
+ 71
+ ]
+ },
+ {
+ "municipality": "Bom Jesus da Serra",
+ "zones": [
+ 59
+ ]
+ },
+ {
+ "municipality": "Boninal",
+ "zones": [
+ 105
+ ]
+ },
+ {
+ "municipality": "Bonito",
+ "zones": [
+ 69
+ ]
+ },
+ {
+ "municipality": "Boquira",
+ "zones": [
+ 65
+ ]
+ },
+ {
+ "municipality": "Botuporã",
+ "zones": [
+ 168
+ ]
+ },
+ {
+ "municipality": "Brejões",
+ "zones": [
+ 36
+ ]
+ },
+ {
+ "municipality": "Brejolândia",
+ "zones": [
+ 190
+ ]
+ },
+ {
+ "municipality": "Brotas de Macaúbas",
+ "zones": [
+ 94
+ ]
+ },
+ {
+ "municipality": "Brumado",
+ "zones": [
+ 90
+ ]
+ },
+ {
+ "municipality": "Buerarema",
+ "zones": [
+ 166
+ ]
+ },
+ {
+ "municipality": "Buritirama",
+ "zones": [
+ 77
+ ]
+ },
+ {
+ "municipality": "Caatiba",
+ "zones": [
+ 201
+ ]
+ },
+ {
+ "municipality": "Cabaceiras do Paraguaçu",
+ "zones": [
+ 131
+ ]
+ },
+ {
+ "municipality": "Cachoeira",
+ "zones": [
+ 118
+ ]
+ },
+ {
+ "municipality": "Caculé",
+ "zones": [
+ 93
+ ]
+ },
+ {
+ "municipality": "Caém",
+ "zones": [
+ 167
+ ]
+ },
+ {
+ "municipality": "Caetanos",
+ "zones": [
+ 58
+ ]
+ },
+ {
+ "municipality": "Caetité",
+ "zones": [
+ 63
+ ]
+ },
+ {
+ "municipality": "Cafarnaum",
+ "zones": [
+ 55
+ ]
+ },
+ {
+ "municipality": "Cairu",
+ "zones": [
+ 31
+ ]
+ },
+ {
+ "municipality": "Caldeirão Grande",
+ "zones": [
+ 115
+ ]
+ },
+ {
+ "municipality": "Camacan",
+ "zones": [
+ 133
+ ]
+ },
+ {
+ "municipality": "Camaçari",
+ "zones": [
+ 170,
+ 171
+ ]
+ },
+ {
+ "municipality": "Camamu",
+ "zones": [
+ 78
+ ]
+ },
+ {
+ "municipality": "Campo Alegre de Lourdes",
+ "zones": [
+ 67
+ ]
+ },
+ {
+ "municipality": "Campo Formoso",
+ "zones": [
+ 53
+ ]
+ },
+ {
+ "municipality": "Canápolis",
+ "zones": [
+ 99
+ ]
+ },
+ {
+ "municipality": "Canarana",
+ "zones": [
+ 174
+ ]
+ },
+ {
+ "municipality": "Canavieiras",
+ "zones": [
+ 116
+ ]
+ },
+ {
+ "municipality": "Candeal",
+ "zones": [
+ 114
+ ]
+ },
+ {
+ "municipality": "Candeias",
+ "zones": [
+ 127
+ ]
+ },
+ {
+ "municipality": "Candiba",
+ "zones": [
+ 64
+ ]
+ },
+ {
+ "municipality": "Cândido Sales",
+ "zones": [
+ 40
+ ]
+ },
+ {
+ "municipality": "Cansanção",
+ "zones": [
+ 50
+ ]
+ },
+ {
+ "municipality": "Canudos",
+ "zones": [
+ 83
+ ]
+ },
+ {
+ "municipality": "Capela do Alto Alegre",
+ "zones": [
+ 196
+ ]
+ },
+ {
+ "municipality": "Capim Grosso",
+ "zones": [
+ 191
+ ]
+ },
+ {
+ "municipality": "Caraíbas",
+ "zones": [
+ 161
+ ]
+ },
+ {
+ "municipality": "Caravelas",
+ "zones": [
+ 112
+ ]
+ },
+ {
+ "municipality": "Cardeal da Silva",
+ "zones": [
+ 144
+ ]
+ },
+ {
+ "municipality": "Carinhanha",
+ "zones": [
+ 125
+ ]
+ },
+ {
+ "municipality": "Casa Nova",
+ "zones": [
+ 66
+ ]
+ },
+ {
+ "municipality": "Castro Alves",
+ "zones": [
+ 43
+ ]
+ },
+ {
+ "municipality": "Catolândia",
+ "zones": [
+ 100
+ ]
+ },
+ {
+ "municipality": "Catu",
+ "zones": [
+ 129
+ ]
+ },
+ {
+ "municipality": "Caturama",
+ "zones": [
+ 111
+ ]
+ },
+ {
+ "municipality": "Central",
+ "zones": [
+ 159
+ ]
+ },
+ {
+ "municipality": "Chorrochó",
+ "zones": [
+ 158
+ ]
+ },
+ {
+ "municipality": "Cícero Dantas",
+ "zones": [
+ 82
+ ]
+ },
+ {
+ "municipality": "Cipó",
+ "zones": [
+ 79
+ ]
+ },
+ {
+ "municipality": "Coaraci",
+ "zones": [
+ 135
+ ]
+ },
+ {
+ "municipality": "Cocos",
+ "zones": [
+ 61
+ ]
+ },
+ {
+ "municipality": "Conceição da Feira",
+ "zones": [
+ 108
+ ]
+ },
+ {
+ "municipality": "Conceição do Almeida",
+ "zones": [
+ 184
+ ]
+ },
+ {
+ "municipality": "Conceição do Coité",
+ "zones": [
+ 132
+ ]
+ },
+ {
+ "municipality": "Conceição do Jacuípe",
+ "zones": [
+ 192
+ ]
+ },
+ {
+ "municipality": "Conde",
+ "zones": [
+ 21
+ ]
+ },
+ {
+ "municipality": "Condeúba",
+ "zones": [
+ 60
+ ]
+ },
+ {
+ "municipality": "Contendas do Sincorá",
+ "zones": [
+ 58
+ ]
+ },
+ {
+ "municipality": "Coração de Maria",
+ "zones": [
+ 130
+ ]
+ },
+ {
+ "municipality": "Cordeiros",
+ "zones": [
+ 60
+ ]
+ },
+ {
+ "municipality": "Coribe",
+ "zones": [
+ 61
+ ]
+ },
+ {
+ "municipality": "Coronel João Sá",
+ "zones": [
+ 52
+ ]
+ },
+ {
+ "municipality": "Correntina",
+ "zones": [
+ 124
+ ]
+ },
+ {
+ "municipality": "Cotegipe",
+ "zones": [
+ 98
+ ]
+ },
+ {
+ "municipality": "Cravolândia",
+ "zones": [
+ 38
+ ]
+ },
+ {
+ "municipality": "Crisópolis",
+ "zones": [
+ 81
+ ]
+ },
+ {
+ "municipality": "Cristópolis",
+ "zones": [
+ 126
+ ]
+ },
+ {
+ "municipality": "Cruz das Almas",
+ "zones": [
+ 142
+ ]
+ },
+ {
+ "municipality": "Curaçá",
+ "zones": [
+ 85
+ ]
+ },
+ {
+ "municipality": "Dário Meira",
+ "zones": [
+ 147
+ ]
+ },
+ {
+ "municipality": "Dias d'Ávila",
+ "zones": [
+ 186
+ ]
+ },
+ {
+ "municipality": "Dom Basílio",
+ "zones": [
+ 101
+ ]
+ },
+ {
+ "municipality": "Dom Macedo Costa",
+ "zones": [
+ 202
+ ]
+ },
+ {
+ "municipality": "Elísio Medrado",
+ "zones": [
+ 107
+ ]
+ },
+ {
+ "municipality": "Encruzilhada",
+ "zones": [
+ 152
+ ]
+ },
+ {
+ "municipality": "Entre Rios",
+ "zones": [
+ 144
+ ]
+ },
+ {
+ "municipality": "Érico Cardoso",
+ "zones": [
+ 111
+ ]
+ },
+ {
+ "municipality": "Esplanada",
+ "zones": [
+ 21
+ ]
+ },
+ {
+ "municipality": "Euclides da Cunha",
+ "zones": [
+ 102
+ ]
+ },
+ {
+ "municipality": "Eunápolis",
+ "zones": [
+ 188,
+ 203
+ ]
+ },
+ {
+ "municipality": "Fátima",
+ "zones": [
+ 82
+ ]
+ },
+ {
+ "municipality": "Feira da Mata",
+ "zones": [
+ 125
+ ]
+ },
+ {
+ "municipality": "Feira de Santana",
+ "zones": [
+ 154,
+ 155,
+ 156,
+ 157
+ ]
+ },
+ {
+ "municipality": "Filadélfia",
+ "zones": [
+ 149
+ ]
+ },
+ {
+ "municipality": "Firmino Alves",
+ "zones": [
+ 137
+ ]
+ },
+ {
+ "municipality": "Floresta Azul",
+ "zones": [
+ 29
+ ]
+ },
+ {
+ "municipality": "Formosa do Rio Preto",
+ "zones": [
+ 187
+ ]
+ },
+ {
+ "municipality": "Gandu",
+ "zones": [
+ 151
+ ]
+ },
+ {
+ "municipality": "Gavião",
+ "zones": [
+ 191
+ ]
+ },
+ {
+ "municipality": "Gentio do Ouro",
+ "zones": [
+ 68
+ ]
+ },
+ {
+ "municipality": "Glória",
+ "zones": [
+ 84
+ ]
+ },
+ {
+ "municipality": "Gongogi",
+ "zones": [
+ 73
+ ]
+ },
+ {
+ "municipality": "Governador Mangabeira",
+ "zones": [
+ 131
+ ]
+ },
+ {
+ "municipality": "Guajeru",
+ "zones": [
+ 93
+ ]
+ },
+ {
+ "municipality": "Guanambi",
+ "zones": [
+ 64
+ ]
+ },
+ {
+ "municipality": "Guaratinga",
+ "zones": [
+ 189
+ ]
+ },
+ {
+ "municipality": "Heliópolis",
+ "zones": [
+ 110
+ ]
+ },
+ {
+ "municipality": "Iaçu",
+ "zones": [
+ 193
+ ]
+ },
+ {
+ "municipality": "Ibiassucê",
+ "zones": [
+ 93
+ ]
+ },
+ {
+ "municipality": "Ibicaraí",
+ "zones": [
+ 29
+ ]
+ },
+ {
+ "municipality": "Ibicoara",
+ "zones": [
+ 169
+ ]
+ },
+ {
+ "municipality": "Ibicuí",
+ "zones": [
+ 146
+ ]
+ },
+ {
+ "municipality": "Ibipeba",
+ "zones": [
+ 176
+ ]
+ },
+ {
+ "municipality": "Ibipitanga",
+ "zones": [
+ 65
+ ]
+ },
+ {
+ "municipality": "Ibiquera",
+ "zones": [
+ 42
+ ]
+ },
+ {
+ "municipality": "Ibirapitanga",
+ "zones": [
+ 134
+ ]
+ },
+ {
+ "municipality": "Ibirapuã",
+ "zones": [
+ 153
+ ]
+ },
+ {
+ "municipality": "Ibirataia",
+ "zones": [
+ 24
+ ]
+ },
+ {
+ "municipality": "Ibitiara",
+ "zones": [
+ 88
+ ]
+ },
+ {
+ "municipality": "Ibititá",
+ "zones": [
+ 104
+ ]
+ },
+ {
+ "municipality": "Ibotirama",
+ "zones": [
+ 173
+ ]
+ },
+ {
+ "municipality": "Ichu",
+ "zones": [
+ 132
+ ]
+ },
+ {
+ "municipality": "Igaporã",
+ "zones": [
+ 168
+ ]
+ },
+ {
+ "municipality": "Igrapiúna",
+ "zones": [
+ 32
+ ]
+ },
+ {
+ "municipality": "Iguaí",
+ "zones": [
+ 146
+ ]
+ },
+ {
+ "municipality": "Ilhéus",
+ "zones": [
+ 25,
+ 26
+ ]
+ },
+ {
+ "municipality": "Inhambupe",
+ "zones": [
+ 44
+ ]
+ },
+ {
+ "municipality": "Ipecaetá",
+ "zones": [
+ 143
+ ]
+ },
+ {
+ "municipality": "Ipiaú",
+ "zones": [
+ 24
+ ]
+ },
+ {
+ "municipality": "Ipirá",
+ "zones": [
+ 62
+ ]
+ },
+ {
+ "municipality": "Ipupiara",
+ "zones": [
+ 94
+ ]
+ },
+ {
+ "municipality": "Irajuba",
+ "zones": [
+ 76
+ ]
+ },
+ {
+ "municipality": "Iramaia",
+ "zones": [
+ 169
+ ]
+ },
+ {
+ "municipality": "Iraquara",
+ "zones": [
+ 89
+ ]
+ },
+ {
+ "municipality": "Irará",
+ "zones": [
+ 74
+ ]
+ },
+ {
+ "municipality": "Irecê",
+ "zones": [
+ 95
+ ]
+ },
+ {
+ "municipality": "Itabela",
+ "zones": [
+ 189
+ ]
+ },
+ {
+ "municipality": "Itaberaba",
+ "zones": [
+ 42
+ ]
+ },
+ {
+ "municipality": "Itabuna",
+ "zones": [
+ 27,
+ 28
+ ]
+ },
+ {
+ "municipality": "Itacaré",
+ "zones": [
+ 198
+ ]
+ },
+ {
+ "municipality": "Itaeté",
+ "zones": [
+ 119
+ ]
+ },
+ {
+ "municipality": "Itagi",
+ "zones": [
+ 147
+ ]
+ },
+ {
+ "municipality": "Itagibá",
+ "zones": [
+ 147
+ ]
+ },
+ {
+ "municipality": "Itagimirim",
+ "zones": [
+ 188
+ ]
+ },
+ {
+ "municipality": "Itaguaçu da Bahia",
+ "zones": [
+ 68
+ ]
+ },
+ {
+ "municipality": "Itaju do Colônia",
+ "zones": [
+ 137
+ ]
+ },
+ {
+ "municipality": "Itajuípe",
+ "zones": [
+ 136
+ ]
+ },
+ {
+ "municipality": "Itamaraju",
+ "zones": [
+ 172
+ ]
+ },
+ {
+ "municipality": "Itamari",
+ "zones": [
+ 151
+ ]
+ },
+ {
+ "municipality": "Itambé",
+ "zones": [
+ 201
+ ]
+ },
+ {
+ "municipality": "Itanagra",
+ "zones": [
+ 185
+ ]
+ },
+ {
+ "municipality": "Itanhém",
+ "zones": [
+ 148
+ ]
+ },
+ {
+ "municipality": "Itaparica",
+ "zones": [
+ 141
+ ]
+ },
+ {
+ "municipality": "Itapé",
+ "zones": [
+ 27
+ ]
+ },
+ {
+ "municipality": "Itapebi",
+ "zones": [
+ 188
+ ]
+ },
+ {
+ "municipality": "Itapetinga",
+ "zones": [
+ 140
+ ]
+ },
+ {
+ "municipality": "Itapicuru",
+ "zones": [
+ 81
+ ]
+ },
+ {
+ "municipality": "Itapitanga",
+ "zones": [
+ 135
+ ]
+ },
+ {
+ "municipality": "Itaquara",
+ "zones": [
+ 76
+ ]
+ },
+ {
+ "municipality": "Itarantim",
+ "zones": [
+ 138
+ ]
+ },
+ {
+ "municipality": "Itatim",
+ "zones": [
+ 107
+ ]
+ },
+ {
+ "municipality": "Itiruçu",
+ "zones": [
+ 37
+ ]
+ },
+ {
+ "municipality": "Itiúba",
+ "zones": [
+ 149
+ ]
+ },
+ {
+ "municipality": "Itororó",
+ "zones": [
+ 137
+ ]
+ },
+ {
+ "municipality": "Ituaçu",
+ "zones": [
+ 58
+ ]
+ },
+ {
+ "municipality": "Ituberá",
+ "zones": [
+ 32
+ ]
+ },
+ {
+ "municipality": "Iuiú",
+ "zones": [
+ 175
+ ]
+ },
+ {
+ "municipality": "Jaborandi",
+ "zones": [
+ 61
+ ]
+ },
+ {
+ "municipality": "Jacaraci",
+ "zones": [
+ 92
+ ]
+ },
+ {
+ "municipality": "Jacobina",
+ "zones": [
+ 46
+ ]
+ },
+ {
+ "municipality": "Jaguaquara",
+ "zones": [
+ 76
+ ]
+ },
+ {
+ "municipality": "Jaguarari",
+ "zones": [
+ 179
+ ]
+ },
+ {
+ "municipality": "Jaguaripe",
+ "zones": [
+ 30
+ ]
+ },
+ {
+ "municipality": "Jandaíra",
+ "zones": [
+ 49
+ ]
+ },
+ {
+ "municipality": "Jequié",
+ "zones": [
+ 22,
+ 23
+ ]
+ },
+ {
+ "municipality": "Jeremoabo",
+ "zones": [
+ 51
+ ]
+ },
+ {
+ "municipality": "Jiquiriçá",
+ "zones": [
+ 109
+ ]
+ },
+ {
+ "municipality": "Jitaúna",
+ "zones": [
+ 24
+ ]
+ },
+ {
+ "municipality": "João Dourado",
+ "zones": [
+ 199
+ ]
+ },
+ {
+ "municipality": "Juazeiro",
+ "zones": [
+ 47,
+ 48
+ ]
+ },
+ {
+ "municipality": "Jucuruçu",
+ "zones": [
+ 172
+ ]
+ },
+ {
+ "municipality": "Jussara",
+ "zones": [
+ 159
+ ]
+ },
+ {
+ "municipality": "Jussari",
+ "zones": [
+ 28
+ ]
+ },
+ {
+ "municipality": "Jussiape",
+ "zones": [
+ 101
+ ]
+ },
+ {
+ "municipality": "Lafaiete Coutinho",
+ "zones": [
+ 23
+ ]
+ },
+ {
+ "municipality": "Lagoa Real",
+ "zones": [
+ 63
+ ]
+ },
+ {
+ "municipality": "Laje",
+ "zones": [
+ 109
+ ]
+ },
+ {
+ "municipality": "Lajedão",
+ "zones": [
+ 153
+ ]
+ },
+ {
+ "municipality": "Lajedinho",
+ "zones": [
+ 87
+ ]
+ },
+ {
+ "municipality": "Lajedo do Tabocal",
+ "zones": [
+ 37
+ ]
+ },
+ {
+ "municipality": "Lamarão",
+ "zones": [
+ 160
+ ]
+ },
+ {
+ "municipality": "Lapão",
+ "zones": [
+ 104
+ ]
+ },
+ {
+ "municipality": "Lauro de Freitas",
+ "zones": [
+ 180
+ ]
+ },
+ {
+ "municipality": "Lençóis",
+ "zones": [
+ 89
+ ]
+ },
+ {
+ "municipality": "Licínio de Almeida",
+ "zones": [
+ 117
+ ]
+ },
+ {
+ "municipality": "Livramento de Nossa Senhora",
+ "zones": [
+ 101
+ ]
+ },
+ {
+ "municipality": "Luís Eduardo Magalhães",
+ "zones": [
+ 205
+ ]
+ },
+ {
+ "municipality": "Macajuba",
+ "zones": [
+ 87
+ ]
+ },
+ {
+ "municipality": "Macarani",
+ "zones": [
+ 91
+ ]
+ },
+ {
+ "municipality": "Macaúbas",
+ "zones": [
+ 65
+ ]
+ },
+ {
+ "municipality": "Macururé",
+ "zones": [
+ 158
+ ]
+ },
+ {
+ "municipality": "Madre de Deus",
+ "zones": [
+ 162
+ ]
+ },
+ {
+ "municipality": "Maetinga",
+ "zones": [
+ 161
+ ]
+ },
+ {
+ "municipality": "Maiquinique",
+ "zones": [
+ 91
+ ]
+ },
+ {
+ "municipality": "Mairi",
+ "zones": [
+ 86
+ ]
+ },
+ {
+ "municipality": "Malhada",
+ "zones": [
+ 125
+ ]
+ },
+ {
+ "municipality": "Malhada de Pedras",
+ "zones": [
+ 90
+ ]
+ },
+ {
+ "municipality": "Manoel Vitorino",
+ "zones": [
+ 22
+ ]
+ },
+ {
+ "municipality": "Mansidão",
+ "zones": [
+ 97
+ ]
+ },
+ {
+ "municipality": "Maracás",
+ "zones": [
+ 37
+ ]
+ },
+ {
+ "municipality": "Maragogipe",
+ "zones": [
+ 118
+ ]
+ },
+ {
+ "municipality": "Maraú",
+ "zones": [
+ 78
+ ]
+ },
+ {
+ "municipality": "Marcionílio Souza",
+ "zones": [
+ 193
+ ]
+ },
+ {
+ "municipality": "Mascote",
+ "zones": [
+ 133
+ ]
+ },
+ {
+ "municipality": "Mata de São João",
+ "zones": [
+ 185
+ ]
+ },
+ {
+ "municipality": "Matina",
+ "zones": [
+ 113
+ ]
+ },
+ {
+ "municipality": "Medeiros Neto",
+ "zones": [
+ 153
+ ]
+ },
+ {
+ "municipality": "Miguel Calmon",
+ "zones": [
+ 103
+ ]
+ },
+ {
+ "municipality": "Milagres",
+ "zones": [
+ 193
+ ]
+ },
+ {
+ "municipality": "Mirangaba",
+ "zones": [
+ 167
+ ]
+ },
+ {
+ "municipality": "Mirante",
+ "zones": [
+ 59
+ ]
+ },
+ {
+ "municipality": "Monte Santo",
+ "zones": [
+ 50
+ ]
+ },
+ {
+ "municipality": "Morpará",
+ "zones": [
+ 173
+ ]
+ },
+ {
+ "municipality": "Morro do Chapéu",
+ "zones": [
+ 55
+ ]
+ },
+ {
+ "municipality": "Mortugaba",
+ "zones": [
+ 92
+ ]
+ },
+ {
+ "municipality": "Mucugê",
+ "zones": [
+ 119
+ ]
+ },
+ {
+ "municipality": "Mucuri",
+ "zones": [
+ 35
+ ]
+ },
+ {
+ "municipality": "Mulungu do Morro",
+ "zones": [
+ 55
+ ]
+ },
+ {
+ "municipality": "Mundo Novo",
+ "zones": [
+ 54
+ ]
+ },
+ {
+ "municipality": "Muniz Ferreira",
+ "zones": [
+ 202
+ ]
+ },
+ {
+ "municipality": "Muquém de São Francisco",
+ "zones": [
+ 98
+ ]
+ },
+ {
+ "municipality": "Muritiba",
+ "zones": [
+ 131
+ ]
+ },
+ {
+ "municipality": "Mutuípe",
+ "zones": [
+ 109
+ ]
+ },
+ {
+ "municipality": "Nazaré",
+ "zones": [
+ 30
+ ]
+ },
+ {
+ "municipality": "Nilo Peçanha",
+ "zones": [
+ 32
+ ]
+ },
+ {
+ "municipality": "Nordestina",
+ "zones": [
+ 106
+ ]
+ },
+ {
+ "municipality": "Nova Canaã",
+ "zones": [
+ 146
+ ]
+ },
+ {
+ "municipality": "Nova Fátima",
+ "zones": [
+ 196
+ ]
+ },
+ {
+ "municipality": "Nova Ibiá",
+ "zones": [
+ 151
+ ]
+ },
+ {
+ "municipality": "Nova Itarana",
+ "zones": [
+ 36
+ ]
+ },
+ {
+ "municipality": "Nova Redenção",
+ "zones": [
+ 119
+ ]
+ },
+ {
+ "municipality": "Nova Soure",
+ "zones": [
+ 79
+ ]
+ },
+ {
+ "municipality": "Nova Viçosa",
+ "zones": [
+ 35
+ ]
+ },
+ {
+ "municipality": "Novo Horizonte",
+ "zones": [
+ 88
+ ]
+ },
+ {
+ "municipality": "Novo Triunfo",
+ "zones": [
+ 82
+ ]
+ },
+ {
+ "municipality": "Olindina",
+ "zones": [
+ 81
+ ]
+ },
+ {
+ "municipality": "Oliveira dos Brejinhos",
+ "zones": [
+ 94
+ ]
+ },
+ {
+ "municipality": "Ouriçangas",
+ "zones": [
+ 74
+ ]
+ },
+ {
+ "municipality": "Ourolândia",
+ "zones": [
+ 167
+ ]
+ },
+ {
+ "municipality": "Palmas de Monte Alto",
+ "zones": [
+ 175
+ ]
+ },
+ {
+ "municipality": "Palmeiras",
+ "zones": [
+ 89
+ ]
+ },
+ {
+ "municipality": "Paramirim",
+ "zones": [
+ 111
+ ]
+ },
+ {
+ "municipality": "Paratinga",
+ "zones": [
+ 173
+ ]
+ },
+ {
+ "municipality": "Paripiranga",
+ "zones": [
+ 52
+ ]
+ },
+ {
+ "municipality": "Pau Brasil",
+ "zones": [
+ 133
+ ]
+ },
+ {
+ "municipality": "Paulo Afonso",
+ "zones": [
+ 84,
+ 181
+ ]
+ },
+ {
+ "municipality": "Pé de Serra",
+ "zones": [
+ 114
+ ]
+ },
+ {
+ "municipality": "Pedrão",
+ "zones": [
+ 130
+ ]
+ },
+ {
+ "municipality": "Pedro Alexandre",
+ "zones": [
+ 51
+ ]
+ },
+ {
+ "municipality": "Piatã",
+ "zones": [
+ 105
+ ]
+ },
+ {
+ "municipality": "Pilão Arcado",
+ "zones": [
+ 195
+ ]
+ },
+ {
+ "municipality": "Pindaí",
+ "zones": [
+ 117
+ ]
+ },
+ {
+ "municipality": "Pindobaçu",
+ "zones": [
+ 115
+ ]
+ },
+ {
+ "municipality": "Pintadas",
+ "zones": [
+ 62
+ ]
+ },
+ {
+ "municipality": "Piraí do Norte",
+ "zones": [
+ 151
+ ]
+ },
+ {
+ "municipality": "Piripá",
+ "zones": [
+ 177
+ ]
+ },
+ {
+ "municipality": "Piritiba",
+ "zones": [
+ 54
+ ]
+ },
+ {
+ "municipality": "Planaltino",
+ "zones": [
+ 37
+ ]
+ },
+ {
+ "municipality": "Planalto",
+ "zones": [
+ 139
+ ]
+ },
+ {
+ "municipality": "Poções",
+ "zones": [
+ 59
+ ]
+ },
+ {
+ "municipality": "Pojuca",
+ "zones": [
+ 200
+ ]
+ },
+ {
+ "municipality": "Ponto Novo",
+ "zones": [
+ 149
+ ]
+ },
+ {
+ "municipality": "Porto Seguro",
+ "zones": [
+ 121,
+ 122
+ ]
+ },
+ {
+ "municipality": "Potiraguá",
+ "zones": [
+ 138
+ ]
+ },
+ {
+ "municipality": "Prado",
+ "zones": [
+ 112
+ ]
+ },
+ {
+ "municipality": "Presidente Dutra",
+ "zones": [
+ 95
+ ]
+ },
+ {
+ "municipality": "Presidente Jânio Quadros",
+ "zones": [
+ 60
+ ]
+ },
+ {
+ "municipality": "Presidente Tancredo Neves",
+ "zones": [
+ 197
+ ]
+ },
+ {
+ "municipality": "Queimadas",
+ "zones": [
+ 106
+ ]
+ },
+ {
+ "municipality": "Quijingue",
+ "zones": [
+ 102
+ ]
+ },
+ {
+ "municipality": "Quixabeira",
+ "zones": [
+ 191
+ ]
+ },
+ {
+ "municipality": "Rafael Jambeiro",
+ "zones": [
+ 43
+ ]
+ },
+ {
+ "municipality": "Remanso",
+ "zones": [
+ 67
+ ]
+ },
+ {
+ "municipality": "Retirolândia",
+ "zones": [
+ 196
+ ]
+ },
+ {
+ "municipality": "Riachão das Neves",
+ "zones": [
+ 182
+ ]
+ },
+ {
+ "municipality": "Riachão do Jacuípe",
+ "zones": [
+ 114
+ ]
+ },
+ {
+ "municipality": "Riacho de Santana",
+ "zones": [
+ 113
+ ]
+ },
+ {
+ "municipality": "Ribeira do Amparo",
+ "zones": [
+ 79
+ ]
+ },
+ {
+ "municipality": "Ribeira do Pombal",
+ "zones": [
+ 110
+ ]
+ },
+ {
+ "municipality": "Ribeirão do Largo",
+ "zones": [
+ 152
+ ]
+ },
+ {
+ "municipality": "Rio de Contas",
+ "zones": [
+ 101
+ ]
+ },
+ {
+ "municipality": "Rio do Antônio",
+ "zones": [
+ 93
+ ]
+ },
+ {
+ "municipality": "Rio do Pires",
+ "zones": [
+ 111
+ ]
+ },
+ {
+ "municipality": "Rio Real",
+ "zones": [
+ 49
+ ]
+ },
+ {
+ "municipality": "Rodelas",
+ "zones": [
+ 158
+ ]
+ },
+ {
+ "municipality": "Ruy Barbosa",
+ "zones": [
+ 87
+ ]
+ },
+ {
+ "municipality": "Salinas da Margarida",
+ "zones": [
+ 30
+ ]
+ },
+ {
+ "municipality": "Salvador",
+ "zones": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 17,
+ 18,
+ 19
+ ]
+ },
+ {
+ "municipality": "Santa Bárbara",
+ "zones": [
+ 160
+ ]
+ },
+ {
+ "municipality": "Santa Brígida",
+ "zones": [
+ 181
+ ]
+ },
+ {
+ "municipality": "Santa Cruz Cabrália",
+ "zones": [
+ 121
+ ]
+ },
+ {
+ "municipality": "Santa Cruz da Vitória",
+ "zones": [
+ 29
+ ]
+ },
+ {
+ "municipality": "Santa Inês",
+ "zones": [
+ 38
+ ]
+ },
+ {
+ "municipality": "Santa Luzia",
+ "zones": [
+ 133
+ ]
+ },
+ {
+ "municipality": "Santa Maria da Vitória",
+ "zones": [
+ 72
+ ]
+ },
+ {
+ "municipality": "Santa Rita de Cássia",
+ "zones": [
+ 97
+ ]
+ },
+ {
+ "municipality": "Santa Teresinha",
+ "zones": [
+ 107
+ ]
+ },
+ {
+ "municipality": "Santaluz",
+ "zones": [
+ 145
+ ]
+ },
+ {
+ "municipality": "Santana",
+ "zones": [
+ 99
+ ]
+ },
+ {
+ "municipality": "Santanópolis",
+ "zones": [
+ 74
+ ]
+ },
+ {
+ "municipality": "Santo Amaro",
+ "zones": [
+ 178
+ ]
+ },
+ {
+ "municipality": "Santo Antônio de Jesus",
+ "zones": [
+ 56,
+ 202
+ ]
+ },
+ {
+ "municipality": "Santo Estêvão",
+ "zones": [
+ 143
+ ]
+ },
+ {
+ "municipality": "São Desidério",
+ "zones": [
+ 100
+ ]
+ },
+ {
+ "municipality": "São Domingos",
+ "zones": [
+ 120
+ ]
+ },
+ {
+ "municipality": "São Felipe",
+ "zones": [
+ 184
+ ]
+ },
+ {
+ "municipality": "São Félix",
+ "zones": [
+ 118
+ ]
+ },
+ {
+ "municipality": "São Félix do Coribe",
+ "zones": [
+ 72
+ ]
+ },
+ {
+ "municipality": "São Francisco do Conde",
+ "zones": [
+ 162
+ ]
+ },
+ {
+ "municipality": "São Gabriel",
+ "zones": [
+ 95
+ ]
+ },
+ {
+ "municipality": "São Gonçalo dos Campos",
+ "zones": [
+ 108
+ ]
+ },
+ {
+ "municipality": "São José da Vitória",
+ "zones": [
+ 166
+ ]
+ },
+ {
+ "municipality": "São José do Jacuípe",
+ "zones": [
+ 191
+ ]
+ },
+ {
+ "municipality": "São Miguel das Matas",
+ "zones": [
+ 36
+ ]
+ },
+ {
+ "municipality": "São Sebastião do Passé",
+ "zones": [
+ 128
+ ]
+ },
+ {
+ "municipality": "Sapeaçu",
+ "zones": [
+ 142
+ ]
+ },
+ {
+ "municipality": "Sátiro Dias",
+ "zones": [
+ 44
+ ]
+ },
+ {
+ "municipality": "Saubara",
+ "zones": [
+ 178
+ ]
+ },
+ {
+ "municipality": "Saúde",
+ "zones": [
+ 115
+ ]
+ },
+ {
+ "municipality": "Seabra",
+ "zones": [
+ 88
+ ]
+ },
+ {
+ "municipality": "Sebastião Laranjeiras",
+ "zones": [
+ 175
+ ]
+ },
+ {
+ "municipality": "Senhor do Bonfim",
+ "zones": [
+ 45
+ ]
+ },
+ {
+ "municipality": "Sento Sé",
+ "zones": [
+ 96
+ ]
+ },
+ {
+ "municipality": "Serra do Ramalho",
+ "zones": [
+ 71
+ ]
+ },
+ {
+ "municipality": "Serra Dourada",
+ "zones": [
+ 190
+ ]
+ },
+ {
+ "municipality": "Serra Preta",
+ "zones": [
+ 155
+ ]
+ },
+ {
+ "municipality": "Serrinha",
+ "zones": [
+ 150
+ ]
+ },
+ {
+ "municipality": "Serrolândia",
+ "zones": [
+ 46
+ ]
+ },
+ {
+ "municipality": "Simões Filho",
+ "zones": [
+ 33
+ ]
+ },
+ {
+ "municipality": "Sítio do Mato",
+ "zones": [
+ 71
+ ]
+ },
+ {
+ "municipality": "Sítio do Quinto",
+ "zones": [
+ 51
+ ]
+ },
+ {
+ "municipality": "Sobradinho",
+ "zones": [
+ 66
+ ]
+ },
+ {
+ "municipality": "Souto Soares",
+ "zones": [
+ 174
+ ]
+ },
+ {
+ "municipality": "Tabocas do Brejo Velho",
+ "zones": [
+ 190
+ ]
+ },
+ {
+ "municipality": "Tanhaçu",
+ "zones": [
+ 58
+ ]
+ },
+ {
+ "municipality": "Tanque Novo",
+ "zones": [
+ 168
+ ]
+ },
+ {
+ "municipality": "Tanquinho",
+ "zones": [
+ 160
+ ]
+ },
+ {
+ "municipality": "Taperoá",
+ "zones": [
+ 32
+ ]
+ },
+ {
+ "municipality": "Tapiramutá",
+ "zones": [
+ 54
+ ]
+ },
+ {
+ "municipality": "Teixeira de Freitas",
+ "zones": [
+ 183
+ ]
+ },
+ {
+ "municipality": "Teodoro Sampaio",
+ "zones": [
+ 130
+ ]
+ },
+ {
+ "municipality": "Teofilândia",
+ "zones": [
+ 123
+ ]
+ },
+ {
+ "municipality": "Teolândia",
+ "zones": [
+ 197
+ ]
+ },
+ {
+ "municipality": "Terra Nova",
+ "zones": [
+ 128
+ ]
+ },
+ {
+ "municipality": "Tremedal",
+ "zones": [
+ 177
+ ]
+ },
+ {
+ "municipality": "Tucano",
+ "zones": [
+ 80
+ ]
+ },
+ {
+ "municipality": "Uauá",
+ "zones": [
+ 83
+ ]
+ },
+ {
+ "municipality": "Ubaíra",
+ "zones": [
+ 38
+ ]
+ },
+ {
+ "municipality": "Ubaitaba",
+ "zones": [
+ 73
+ ]
+ },
+ {
+ "municipality": "Ubatã",
+ "zones": [
+ 134
+ ]
+ },
+ {
+ "municipality": "Uibaí",
+ "zones": [
+ 159
+ ]
+ },
+ {
+ "municipality": "Umburanas",
+ "zones": [
+ 167
+ ]
+ },
+ {
+ "municipality": "Una",
+ "zones": [
+ 116
+ ]
+ },
+ {
+ "municipality": "Urandi",
+ "zones": [
+ 117
+ ]
+ },
+ {
+ "municipality": "Uruçuca",
+ "zones": [
+ 198
+ ]
+ },
+ {
+ "municipality": "Utinga",
+ "zones": [
+ 69
+ ]
+ },
+ {
+ "municipality": "Valença",
+ "zones": [
+ 31
+ ]
+ },
+ {
+ "municipality": "Valente",
+ "zones": [
+ 120
+ ]
+ },
+ {
+ "municipality": "Várzea da Roça",
+ "zones": [
+ 86
+ ]
+ },
+ {
+ "municipality": "Várzea do Poço",
+ "zones": [
+ 103
+ ]
+ },
+ {
+ "municipality": "Várzea Nova",
+ "zones": [
+ 55
+ ]
+ },
+ {
+ "municipality": "Varzedo",
+ "zones": [
+ 56
+ ]
+ },
+ {
+ "municipality": "Vera Cruz",
+ "zones": [
+ 141
+ ]
+ },
+ {
+ "municipality": "Vereda",
+ "zones": [
+ 148
+ ]
+ },
+ {
+ "municipality": "Vitória da Conquista",
+ "zones": [
+ 39,
+ 40,
+ 41
+ ]
+ },
+ {
+ "municipality": "Wagner",
+ "zones": [
+ 69
+ ]
+ },
+ {
+ "municipality": "Wanderley",
+ "zones": [
+ 98
+ ]
+ },
+ {
+ "municipality": "Wenceslau Guimarães",
+ "zones": [
+ 197
+ ]
+ },
+ {
+ "municipality": "Xique-Xique",
+ "zones": [
+ 68
+ ]
+ }
+ ],
+ "evidenceSha256": "4f23bbc46c87891b66ca9aeee453bc9e513618edc3e0dea67ef0ae63ed61d624"
+}
diff --git a/tests/int/bahiaTseZones.int.spec.ts b/tests/int/bahiaTseZones.int.spec.ts
new file mode 100644
index 00000000..2ac1a0e3
--- /dev/null
+++ b/tests/int/bahiaTseZones.int.spec.ts
@@ -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)
+ })
+})
diff --git a/tests/unit/campaignNucleusUi.unit.spec.ts b/tests/unit/campaignNucleusUi.unit.spec.ts
index 270c464c..c976baa0 100644
--- a/tests/unit/campaignNucleusUi.unit.spec.ts
+++ b/tests/unit/campaignNucleusUi.unit.spec.ts
@@ -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' } })
diff --git a/tests/unit/territorySuggestions.unit.spec.ts b/tests/unit/territorySuggestions.unit.spec.ts
new file mode 100644
index 00000000..1f7985fb
--- /dev/null
+++ b/tests/unit/territorySuggestions.unit.spec.ts
@@ -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([])
+ })
+})