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:
parent
7e9660ef8f
commit
ac7f2775cd
17 changed files with 3691 additions and 88 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
86
src/components/campaign/TerritorySuggestionChips.tsx
Normal file
86
src/components/campaign/TerritorySuggestionChips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
484
src/lib/bahiaTseZones.ts
Normal 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) ?? []
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
106
src/lib/territorySuggestions.ts
Normal file
106
src/lib/territorySuggestions.ts
Normal 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))),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
2553
tests/fixtures/bahia-tse-zones.official.json
vendored
Normal file
2553
tests/fixtures/bahia-tse-zones.official.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
99
tests/int/bahiaTseZones.int.spec.ts
Normal file
99
tests/int/bahiaTseZones.int.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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' } })
|
||||
|
|
|
|||
114
tests/unit/territorySuggestions.unit.spec.ts
Normal file
114
tests/unit/territorySuggestions.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue