mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #367 from fsolla/agent/359-liderancas-coluna-municipios
B155 — Coluna de lideranças na tabela de municípios (espelho de Assessores)
This commit is contained in:
commit
4cccc4608c
20 changed files with 1309 additions and 36 deletions
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-04):** **B155** (Issue #359) — coluna de lideranças na lista de municípios (espelho de Assessores): coluna "Lideranças" após "Assessores" (staff; leader fora), chips com nome do contato, popover/sheet com Command + delta otimista (seq/pending/rollback) e criação inline (mini-form Nome + Celular). **Sem migration**: leitura por query reversa de `leadership.municipalities` (batch no bundle, `loadMunicipalityLeadershipSummaries`) e escrita reusando B34 (`setLeadershipMunicipalitiesMembershipRecord`) + wizard create (`createLeadershipWizardRecord`) — fonte única de verdade, consistência automática com `/campanha/liderancas`. Rota nova `POST /campanha/municipios/leaderships` (`campaignJsonMutationRoute`, union toggle|create, safe-messages allowlist); provider bridge espelho do B154. Regra travada com o humano: **célula → rota, form → server action** (débito de migração das 3 anomalias — sinal da lista e colunas B31/B34 de `/campanha/liderancas`). Intenção [`liderancas-coluna-municipios.md`](docs/plans/liderancas-coluna-municipios.md); impl [`liderancas-coluna-municipios-impl.md`](docs/plans/liderancas-coluna-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-03):** **B154** (Issue #352) — criar assessor inline no popover da lista de municípios: opção "+ Criar assessor '<nome>'" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `<slug>@criado.invalid`, senha aleatória) e atribui ao município na MESMA transação (cap 10 com rollback, lock `municipality-advisors:{id}`); provider client compartilha o assessor criado entre linhas; chip otimista com id temporário negativo; `advisorNameSchema` unificado em `primitives.ts`; predicado de placeholder estende a `@criado.invalid`. Bônus pré-existentes destravando o e2e do spec municipios: key warning do fragment `trailing` de `MunicipalityFilters` (array com keys) e settle-wait da animação do Drawer no teste B136. Intenção [`criar-assessor-inline-popover-municipios.md`](docs/plans/criar-assessor-inline-popover-municipios.md); impl [`criar-assessor-inline-popover-municipios-impl.md`](docs/plans/criar-assessor-inline-popover-municipios-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-03):** **B86** (Issue #22) — ações rápidas de apoiadores (staff), último catálogo do lote B80–B90: módulo `supporterQuickActions.ts` (espelha B87) com `register-supporter` → `/apoiadores/novo` (todo staff) e `import-supporters` → `/apoiadores/importar` (coordinator-only, espelha `isCampaignCoordinator`); superfície lista+detalhe numérico; `novo`/`importar` só-busca; leader fora. Sem migration/Consent. Impl [`acoes-rapidas-apoiadores-impl.md`](docs/plans/acoes-rapidas-apoiadores-impl.md); débito S1 (normalização de pathname de lista, 6+ call sites) → #353 [`escala-dry-pos-acoes-rapidas.md`](docs/plans/escala-dry-pos-acoes-rapidas.md).
|
||||
|
|
|
|||
88
docs/plans/liderancas-coluna-municipios-impl.md
Normal file
88
docs/plans/liderancas-coluna-municipios-impl.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Impl: Coluna de lideranças na tabela de municípios
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-04
|
||||
Issue: #359
|
||||
Intenção: docs/plans/liderancas-coluna-municipios.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** staff vê e edita, direto na lista `/campanha/municipios`, as lideranças vinculadas a cada município — chips com nome do contato, popover com busca, adição/remoção com auto-save otimista, e criação inline (nome + telefone) sem sair da tabela. Coluna ao lado de "Assessores".
|
||||
- **O que NÃO negociar:** líder (`leader`) não vê a coluna (nem acessa a página — `gate: noLeader` já cobre); assessor só vê/edita lideranças dos municípios que administra (`canReadLeadership` + `assertMunicipalitiesWithinScope`); sem collection nova; sem migration fora do padrão; `Consent` permanece fail-closed (a criação inline reusa `createValidatedLeadershipRecord`, que não introduz chave nova — mesmo comportamento do form completo).
|
||||
- **O que reavaliar:** a hipótese da intenção ("novo field `leadership` no `Municipality`, mirror de `advisors`", com migração de reconciliação) é **rejeitada** neste impl plan. A relação canônica já existe (`leadership.municipalities`) e o B34 já escreve esse lado. Ler por query reversa + reusar as actions B34/B154 elimina migration, join table nova e sync bidirecional.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["page.tsx /campanha/municipios"] --> B["loadMunicipalityListPageBundle"]
|
||||
B --> C["query reversa (staff): leaderships<br/>municipalities in pageIDs, depth 1"]
|
||||
C --> D["rows ganham leadershipIDs<br/>+ retorna leadershipNamesById"]
|
||||
A --> E["getEligibleLeadershipOptions (staff)"]
|
||||
D --> F["MunicipalityList — coluna Lideranças"]
|
||||
E --> F
|
||||
F --> G["MunicipalityListLeadershipsControl<br/>(popover desktop / sheet mobile)"]
|
||||
G --> I["POST /campanha/municipios/leaderships"]
|
||||
I --> J["toggle → setLeadershipMunicipalitiesMembership (B34, reuso)"]
|
||||
I --> K["create → createMunicipalityLeadership (wrapper fino<br/>→ createLeadershipWizardRecord existente)"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** **B** — leitura reversa de `leadership.municipalities` + reuso das actions B34/B154/wizard. Zero migration, fonte única de verdade (uma ponta canônica), consistência automática com a coluna "Municípios" de `/campanha/liderancas` (B34) e com a ficha da liderança — sem hooks de sync que possam divergir. Depth check: reusa actions, schemas, `CampaignCellEditOverlay`, padrão de provider do B154.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **A** — novo campo `municipality.leaderships` (hasMany) + migration + migração de reconciliação + sync bidirecional com `leadership.municipalities` por hooks nas duas collections. Custo de reverter alto (join table nova numa collection "system" de 435 rows + hooks de sync que divergem silenciosamente — o classic footgun do Payload); não traz aceite adicional.
|
||||
- **C** — coluna read-only para assessor (só coordenação edita). Viola o aceite: "Assessor só vê e edita lideranças dos municípios que administra".
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`MunicipalityListColumnId`** (`src/utilities/municipality/municipalityLabels.ts`): ganha `'leaderships'` (closed union). `municipalityColumnDescriptions` ganha entrada. **`municipalityColumnLabels`** (`src/utilities/municipality/municipalityListUrl.ts`): `leaderships: 'Lideranças'`.
|
||||
- **`MunicipalityListViewModel`** (`src/utilities/municipality/municipalityViewModels.ts`): ganha `leadershipIDs: number[]` (default `[]`, populado pelo bundle só na staff view). Novos tipos `MunicipalityLeadershipSummary` (`{ id, name }`) e `EligibleLeadershipOption` (`{ id, name }`), mais os helpers `loadMunicipalityLeadershipSummaries(payload, user, municipalityIDs)` (uma query reversa batch) e `getEligibleLeadershipOptions(payload, user)` (espelho de `getEligibleAdvisorOptions`; escopo via `overrideAccess: false` + user).
|
||||
- **`loadMunicipalityListPageBundle`** (`src/utilities/municipality/municipalityPageData.ts`): para staff, uma query `leadership where { municipalities: { in: pageIDs } }` com `depth: 1` (`contact`), `user`/`overrideAccess: false` → injeta `leadershipIDs` nos rows e devolve `leadershipNamesById` no bundle.
|
||||
- **`page.tsx`** (`(app)/municipios`): staff carrega `getEligibleLeadershipOptions` e repassa `leadershipNamesById` + `leadershipOptions` ao `MunicipalityList`.
|
||||
- **`MunicipalityList.tsx`** + **`MunicipalityListMobileCards.tsx`** (+ pass-through de props em `MunicipalityListMobileSection`): coluna `'leaderships'` no bloco staff **após `advisors`** (sem `MunicipalitySortableHead` — header default via `column.label`; não ordena nem filtra). Picker: entrada em `municipalityListPickerColumns`. Célula = controle novo para **todo** staff (advisors também gerenciam); card mobile ganha a linha "Lideranças" com `variant="sheet"`. Envolve a lista no novo provider.
|
||||
- **`MunicipalityListLeadershipsControl.tsx`** (novo, client): espelho de `MunicipalityListAdvisorsControl` — chips de nome (sort pt-BR), trigger com `aria-label` por extenso, `CampaignCellEditOverlay` (popover/sheet), `Command` com busca `matchesAtWordStart`, delta otimista (seq/pending/revert/`latestConfirmedRef`), `Alert` de erro. **Branch de criação** (diferente do B154 que cria em um clique só porque `name` bastava): `CommandItem` "+ Criar liderança" → mini-form inline (Nome pré-preenchido + Telefone `type="tel"`) → POST → chip otimista com temp id negativo → swap pelo id real. Chips de temp-create sem × (mesmo contrato do B154).
|
||||
- **`MunicipalityLeadershipCreateProvider.tsx`** (novo, client): bridge de opções criadas inline para todo o surface (espelho de `MunicipalityAdvisorCreateProvider`; 2 call sites → mirror, não abstrair).
|
||||
- **`src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts`** (+ `types.ts`, novos): `POST` via `campaignJsonMutationRoute`, body `z.union` (toggle `{ municipalityId, leadershipId, assigned }` | create `{ municipalityId, name, phone }`). Resposta `{ status: 'success', leadershipIDs, createdLeadership?: { id, name } }` — `leadershipIDs` = ids das lideranças do município após o write (query reversa id-only, `overrideAccess: true` — o write acabou de ser access-checkado; precedente B34).
|
||||
- **`src/app/(campaign)/campanha/actions/leadership.ts`**: **toggle** reusa `setLeadershipMunicipalitiesMembership` tal qual (o B34 já revalida ficha da liderança + detalhe do município; a lista onde estamos NÃO é revalidada — o chip é otimista, mesmo contrato do B34). **create**: wrapper fino `createMunicipalityLeadership({ municipalityId, name, phone })` → `createLeadershipWizardRecord` + slug lookup + revalidate (`/campanha/liderancas` lista + `/campanha/municipios/<slug>`), espelhando `createMunicipalityAdvisor`.
|
||||
- **Schemas / safe messages:** union parseado no route (padrão advisors, zod inline); allowlist = `LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE`, `LEADERSHIP_MUNICIPALITY_CAP_MESSAGE`, `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE`, `LEADERSHIP_STAFF_MESSAGE`, `LEADERSHIP_DUPLICATE_MESSAGE`, `CONTACT_PHONE_AMBIGUOUS_MESSAGE`, `BRAZILIAN_PHONE_INVALID_MESSAGE`, `POSTGRES_DEDUP_LOCK_MESSAGE` — todas lançadas pelas actions reusadas.
|
||||
- **Migration:** **nenhuma**.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma escolhida: **chips de nome do contato** na célula (espelho de assessores; sem avatar — liderança não tem `user` necessariamente). Popover: chips + Command. Vazio: "Nenhuma" (texto muted) — o `MissingAdvisorBadge` é sobre responsabilidade e não se aplica. Rejeitadas: avatar stack (presume conta); contagem/ranking (intenção é qualitativa — `Dados: N/A`).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Schema/server** — view model + bundle + helpers + route + wrapper `createMunicipalityLeadership` + int tests (toda a lógica de domínio é reuso: B34 toggle, wizard create; o que é novo é a superfície município + reconcile).
|
||||
2. **UI** — coluna + picker + `MunicipalityListLeadershipsControl` (shape → craft → critique → polish, Impeccable B) + provider + cards mobile.
|
||||
3. **Gates** — unit tests (controle + colunas), `pnpm gate:fast` na iteração, `pnpm push` no fechamento; entrada curta em `docs/CHANGELOG-AGENTS.md`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **Generalizar** `MunicipalityAdvisorCreateProvider` em um bridge genérico — 2 call sites, não abstrair (mirror).
|
||||
- **Sync bidirecional / campo novo** — cortado pela Opção B (sem migration).
|
||||
- **Filtro/ordenação por liderança no header** — fora de escopo (a intenção não pede; o gatilho análogo B29 é da dobradinha).
|
||||
- **Server-side search das opções** (catálogo grande) — v1 carrega o conjunto visível como `getEligibleAdvisorOptions`; se ficar pesado, trocar para busca server-side tipo `searchActivityLeadershipOptions` (gatilho de revisitação, não fazer agora).
|
||||
- **e2e do fluxo completo** — opcional na fase 3 (o e2e de municípios existe; decidir na execução se o custo cabe no appetite).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Assessor adiciona liderança fora da carteira** → `assertMunicipalitiesWithinScope` (B34/create) rejeita com `LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE` (allowlisted) + rollback otimista.
|
||||
- **Remover o último município de uma liderança** → floor de 1 do `nextMunicipalityIdsAfterLeadershipMembership` → `LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE` (allowlisted); chip volta (rollback).
|
||||
- **Cap `MAX_LEADERSHIP_MUNICIPALITIES` (30)** → `LEADERSHIP_MUNICIPALITY_CAP_MESSAGE` (allowlisted).
|
||||
- **Concorrência no toggle** → lock advisory `leadership-municipalities:{id}` já presente no B34.
|
||||
- **Criação de pessoa duplicada** → `Contact` dedup por telefone + `LEADERSHIP_DUPLICATE_MESSAGE` (allowlisted); toast no popover.
|
||||
- **`MunicipalityListViewModel` consumido por outros surfaces** → campo novo opcional com default `[]`; nenhum consumidor quebra.
|
||||
- **Branch/worktree**: o worktree está na branch `plan/b157-…` com um edit não commitado do plano B157. Ao executar, criar `agent/359-liderancas-coluna-municipios` a partir de `origin/main`; o edit do B157 fica preservado (não commitar) — confirmar com o humano como tratar.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (coluna staff, chips, popover, criação inline, scopes)
|
||||
- [x] Invariantes AGENTS/engineering-standards: sem migration, sem collection nova, access reusado (`overrideAccess: false` onde o ator importa), transação nas escritas multi-collection (já nas actions reusadas), copy pt-BR / identificadores em inglês
|
||||
- [x] Testes de domínio: int spec novo (`campaignMunicipalityLeadershipList.int.spec.ts` — toggle e create pela superfície município, scopes advisor/coordinator/leader, floor/cap, duplicata) + unit do controle
|
||||
|
||||
## Pós-revisão (/simplify, 3 revisores paralelos — read-only)
|
||||
|
||||
Aplicados: reconcile **scoped** reusando `loadMunicipalityLeadershipSummaries` nas duas wrappers (elimina o único bypass novo e o gêmeo da query reversa; o create devolve o **nome real do contato** — `contactReused` — para o chip otimista); toggle passa a revalidar `/campanha/liderancas` (a rationale do B34 não transfere: o chamador NÃO está nessa lista); validação cliente espelha o schema (nome `maxLength 120`, telefone via `normalizeBrazilianPhone`); copy "staff" → "a equipe"; ids do mini-form via `useId` (tabela + cards mobile são árvores irmãs); refoco no CommandInput ao fechar o form (foco não cai no body). Deferidos com gatilho: query reversa do bundle fora do `Promise.all` (1 RTT serial no caminho crítico — otimizar se a lista ficar lenta); slug lookup defensivo pós-commit do create.
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
createMunicipalityLeadership,
|
||||
setMunicipalityLeadershipMembership,
|
||||
} from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import { BRAZILIAN_PHONE_INVALID_MESSAGE } from '@/lib/phone'
|
||||
import {
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
municipalityLeadershipCreateSchema,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import { positiveRelationshipId } from '@/lib/schemas/primitives'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
import { CONTACT_PHONE_AMBIGUOUS_MESSAGE } from '@/utilities/contactPhoneInvariant'
|
||||
import { POSTGRES_DEDUP_LOCK_MESSAGE } from '@/utilities/postgresTransactionLocks'
|
||||
|
||||
import type { MunicipalityListLeadershipsResponse } from './types'
|
||||
|
||||
export type { MunicipalityListLeadershipsResponse } from './types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Mutually exclusive shapes (B155): the membership delta (`leadershipId` +
|
||||
* `assigned`) and the name+phone inline create can never combine — `name` is a
|
||||
* write affordance over `leadership.municipalities`, `leadershipId` a delta.
|
||||
*/
|
||||
const bodySchema = z.union([
|
||||
z.strictObject({
|
||||
municipalityId: positiveRelationshipId,
|
||||
leadershipId: positiveRelationshipId,
|
||||
assigned: z.boolean(),
|
||||
}),
|
||||
municipalityLeadershipCreateSchema,
|
||||
])
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema,
|
||||
safeMessages: [
|
||||
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
|
||||
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
|
||||
LEADERSHIP_STAFF_MESSAGE,
|
||||
LEADERSHIP_DUPLICATE_MESSAGE,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
BRAZILIAN_PHONE_INVALID_MESSAGE,
|
||||
POSTGRES_DEDUP_LOCK_MESSAGE,
|
||||
],
|
||||
genericMessage:
|
||||
'Não foi possível atualizar as lideranças. Verifique seu acesso e tente novamente.',
|
||||
},
|
||||
async (body) => {
|
||||
if ('name' in body) {
|
||||
const { leadership, leadershipIDs, createdLeadershipName } =
|
||||
await createMunicipalityLeadership({
|
||||
municipalityId: body.municipalityId,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
})
|
||||
|
||||
return NextResponse.json<MunicipalityListLeadershipsResponse>({
|
||||
status: 'success',
|
||||
message: 'Liderança criada e vinculada.',
|
||||
leadershipIDs,
|
||||
// The real contact name, not the typed one: a phone-matched create
|
||||
// (`contactReused`) keeps the stored Contact's name.
|
||||
createdLeadership: {
|
||||
id: leadership.id,
|
||||
name: createdLeadershipName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { leadershipIDs } = await setMunicipalityLeadershipMembership({
|
||||
municipalityId: body.municipalityId,
|
||||
leadershipId: body.leadershipId,
|
||||
assigned: body.assigned,
|
||||
})
|
||||
|
||||
return NextResponse.json<MunicipalityListLeadershipsResponse>({
|
||||
status: 'success',
|
||||
message: body.assigned ? 'Liderança vinculada.' : 'Liderança removida.',
|
||||
leadershipIDs,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export type MunicipalityListLeadershipsResponse =
|
||||
| {
|
||||
status: 'success'
|
||||
message: string
|
||||
/** The município's resulting leadership id set, for the optimistic reconcile. */
|
||||
leadershipIDs: number[]
|
||||
/** B155 — present only on the inline-create path; the client registers it locally. */
|
||||
createdLeadership?: { id: number; name: string }
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
|
||||
import {
|
||||
getEligibleAdvisorOptions,
|
||||
getEligibleLeadershipOptions,
|
||||
loadAdvisorSummaries,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
|
||||
|
|
@ -60,7 +61,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
const canMoveEngagementLevel = isCampaignUnrestricted(user)
|
||||
|
||||
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
|
||||
const { municipalities: listMunicipalities, totalDocs, totalPages, filterFacets } = pageBundle
|
||||
const {
|
||||
municipalities: listMunicipalities,
|
||||
totalDocs,
|
||||
totalPages,
|
||||
filterFacets,
|
||||
leadershipNamesById,
|
||||
} = pageBundle
|
||||
const resolvedUrl = resolveMunicipalityListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
|
@ -75,10 +82,13 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
...filterFacets.advisorIDs,
|
||||
]),
|
||||
]
|
||||
const [advisorSummaries, advisorOptions] = await Promise.all([
|
||||
const [advisorSummaries, advisorOptions, leadershipOptions] = await Promise.all([
|
||||
isStaffView ? loadAdvisorSummaries(payload, user, advisorIDs) : [],
|
||||
// Only the coordinator-only assign-advisors control consumes these.
|
||||
isCoordinator ? getEligibleAdvisorOptions(payload, user) : [],
|
||||
// All staff edit the Lideranças column (scoped server-side); the leader
|
||||
// never reaches this page (`gate: noLeader`).
|
||||
isStaffView ? getEligibleLeadershipOptions(payload, user) : [],
|
||||
])
|
||||
const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor]))
|
||||
|
||||
|
|
@ -121,10 +131,12 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
<MunicipalityList
|
||||
municipalities={listMunicipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
canMoveEngagementLevel={canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
columnFilterOptions={columnFilterOptions}
|
||||
signalFormAction={createMunicipalityListSignalFormAction}
|
||||
state={state}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type LeadershipStateDeputyMembershipInput,
|
||||
type LeadershipWizardCreateInput,
|
||||
type LeadershipWizardUpdateInput,
|
||||
type MunicipalityLeadershipCreateInput,
|
||||
} from '@/lib/schemas/leadership'
|
||||
import type { CampaignUser, Contact } from '@/payload-types'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
|
|
@ -33,6 +34,7 @@ import {
|
|||
assertContactPhoneAvailable,
|
||||
CONTACT_PHONE_AMBIGUOUS_MESSAGE,
|
||||
} from '@/utilities/contactPhoneInvariant'
|
||||
import { loadMunicipalityLeadershipSummaries } from '@/utilities/municipality/municipalityViewModels'
|
||||
import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
import {
|
||||
|
|
@ -450,6 +452,89 @@ export const createLeadershipWizard = async (
|
|||
return leadership
|
||||
}
|
||||
|
||||
/**
|
||||
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`.
|
||||
* Thin municipality-surface entry point over the wizard create (Contact dedup
|
||||
* by phone, transaction, locks). The list we are ON is deliberately not
|
||||
* revalidated — the chip already shows optimistically (B34 contract); the
|
||||
* leadership list and the município detail are, so the new row appears there
|
||||
* without waiting for a refresh.
|
||||
*/
|
||||
export const createMunicipalityLeadership = async (input: MunicipalityLeadershipCreateInput) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const leadership = await createLeadershipWizardRecord(payload, actor, {
|
||||
name: input.name,
|
||||
phone: input.phone,
|
||||
municipalityId: input.municipalityId,
|
||||
})
|
||||
|
||||
// Intentional admin bypass: existence is enforced by the write's own
|
||||
// relationship validation; this read only resolves the revalidate target.
|
||||
const municipality = await payload.findByID({
|
||||
collection: 'municipality',
|
||||
id: input.municipalityId,
|
||||
depth: 0,
|
||||
select: { slug: true },
|
||||
overrideAccess: true,
|
||||
})
|
||||
revalidateLeadershipWizardPaths(leadership.id, municipality.slug)
|
||||
|
||||
// Scoped reverse read doubles as the reconcile payload and the chip name:
|
||||
// a phone-matched create (`contactReused`) keeps the Contact's own name,
|
||||
// which the optimistic chip must show from the start.
|
||||
const { leadershipIDsByMunicipality, summariesById } = await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
actor,
|
||||
[input.municipalityId],
|
||||
)
|
||||
return {
|
||||
leadership,
|
||||
leadershipIDs: leadershipIDsByMunicipality.get(input.municipalityId) ?? [],
|
||||
createdLeadershipName: summariesById.get(leadership.id)?.name ?? input.name,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B155 — one-chip toggle from the "Lideranças" column of `/campanha/municipios`:
|
||||
* the same B34 membership delta (lock, scope-check, floor/cap) keyed by the
|
||||
* município side, plus the resulting municipality-side id set. The list we are
|
||||
* on is skipped (optimistic chip); the leadership ficha, the município detail
|
||||
* AND `/campanha/liderancas` are busted — unlike B34, the caller is NOT on that
|
||||
* list, so its stale router cache must not survive.
|
||||
*/
|
||||
export const setMunicipalityLeadershipMembership = async (input: {
|
||||
municipalityId: number
|
||||
leadershipId: number
|
||||
assigned: boolean
|
||||
}) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const { leadership, municipalitySlugs } = await setLeadershipMunicipalitiesMembershipRecord(
|
||||
payload,
|
||||
actor,
|
||||
{
|
||||
leadershipId: input.leadershipId,
|
||||
municipalityIds: [input.municipalityId],
|
||||
assigned: input.assigned,
|
||||
},
|
||||
)
|
||||
|
||||
// No-op writes nothing, so there is nothing to revalidate.
|
||||
if (municipalitySlugs.length > 0) {
|
||||
revalidateLeadershipMunicipalityPaths(input.leadershipId, municipalitySlugs)
|
||||
revalidatePath('/campanha/liderancas', 'page')
|
||||
}
|
||||
|
||||
const { leadershipIDsByMunicipality } = await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
actor,
|
||||
[input.municipalityId],
|
||||
)
|
||||
return {
|
||||
leadership,
|
||||
leadershipIDs: leadershipIDsByMunicipality.get(input.municipalityId) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/campanha/liderancas` — the list the chip was toggled ON — is deliberately
|
||||
* NOT here: see `revalidateLeadershipMunicipalityPaths` below for the reason.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import type { EligibleLeadershipOption } from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
type MunicipalityLeadershipCreateContextValue = {
|
||||
/** Leaderships created inline (B155) since the page last rendered server-side. */
|
||||
createdOptions: EligibleLeadershipOption[]
|
||||
registerCreatedLeadership: (option: EligibleLeadershipOption) => void
|
||||
}
|
||||
|
||||
const MunicipalityLeadershipCreateContext =
|
||||
createContext<MunicipalityLeadershipCreateContextValue | null>(null)
|
||||
|
||||
export const useMunicipalityLeadershipCreate = () => useContext(MunicipalityLeadershipCreateContext)
|
||||
|
||||
/**
|
||||
* B155 — one shared bridge of inline-created leadership options for the whole
|
||||
* `/campanha/municipios` list surface (desktop table + mobile cards): a create
|
||||
* in one row's popover makes the new leadership selectable in every other row's
|
||||
* popover on the same page load, without waiting for an RSC refresh. The bridge
|
||||
* is short-lived by design — the page is dynamic, so the next navigation's
|
||||
* `getEligibleLeadershipOptions` already returns the record from the DB.
|
||||
*
|
||||
* Deliberately a mirror of `MunicipalityAdvisorCreateProvider` (B154), not a
|
||||
* generalized bridge: two call sites, and the option types differ (`isCurrent`
|
||||
* exists only for advisors).
|
||||
*/
|
||||
export const MunicipalityLeadershipCreateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [createdOptions, setCreatedOptions] = useState<EligibleLeadershipOption[]>([])
|
||||
|
||||
const registerCreatedLeadership = useCallback((option: EligibleLeadershipOption) => {
|
||||
setCreatedOptions((current) =>
|
||||
current.some((existing) => existing.id === option.id) ? current : [...current, option],
|
||||
)
|
||||
}, [])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ createdOptions, registerCreatedLeadership }),
|
||||
[createdOptions, registerCreatedLeadership],
|
||||
)
|
||||
|
||||
return (
|
||||
<MunicipalityLeadershipCreateContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</MunicipalityLeadershipCreateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,10 +7,12 @@ import {
|
|||
MunicipalityAdvisorAvatarStack,
|
||||
} from '@/components/campaign/municipality/MunicipalityAdvisorAvatarStack'
|
||||
import { MunicipalityAdvisorCreateProvider } from '@/components/campaign/municipality/MunicipalityAdvisorCreateProvider'
|
||||
import { MunicipalityLeadershipCreateProvider } from '@/components/campaign/municipality/MunicipalityLeadershipCreateProvider'
|
||||
import { MunicipalityLevelBadge } from '@/components/campaign/municipality/MunicipalityLevelBadge'
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import { MunicipalityListMobileSection } from '@/components/campaign/municipality/MunicipalityListMobileSection'
|
||||
import {
|
||||
|
|
@ -24,7 +26,11 @@ import { MunicipalitySortableHead } from '@/components/campaign/municipality/Mun
|
|||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -60,7 +66,9 @@ import {
|
|||
} from '@/utilities/municipality/municipalityListUrl'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
EligibleLeadershipOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
MunicipalityLeadershipSummary,
|
||||
MunicipalityListViewModel,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
|
||||
|
|
@ -82,6 +90,8 @@ type MunicipalityColumnFilterOptions = {
|
|||
export type MunicipalityListProps = {
|
||||
municipalities: MunicipalityListViewModel[]
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
/** B155 — contact-name lookup for the Lideranças column chips. */
|
||||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
/**
|
||||
|
|
@ -91,6 +101,8 @@ export type MunicipalityListProps = {
|
|||
*/
|
||||
canMoveEngagementLevel: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
/** B155 — every leadership the actor may add, for the Lideranças popover. */
|
||||
leadershipOptions: EligibleLeadershipOption[]
|
||||
columnFilterOptions: MunicipalityColumnFilterOptions
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
state: MunicipalityListState
|
||||
|
|
@ -144,6 +156,8 @@ const municipalityListColumns = ({
|
|||
columnFilterOptions,
|
||||
advisorNamesById,
|
||||
advisorOptions,
|
||||
leadershipNamesById,
|
||||
leadershipOptions,
|
||||
signalFormAction,
|
||||
}: MunicipalityListProps): Array<MunicipalityColumn> => [
|
||||
{
|
||||
|
|
@ -306,6 +320,29 @@ const municipalityListColumns = ({
|
|||
? null
|
||||
: formatAdvisorNamesTooltip(advisorEntries(municipality, advisorNamesById)),
|
||||
},
|
||||
{
|
||||
// Right after "Assessores": the two relations of "who" in the
|
||||
// município sit together, and both are editable by all staff (the
|
||||
// advisor only within their portfolio, enforced server-side).
|
||||
id: 'leaderships',
|
||||
label: municipalityColumnLabels.leaderships,
|
||||
// No sort, no filter — the B22 explanation rides on the plain head.
|
||||
head: (
|
||||
<CampaignTableHead description={municipalityColumnDescriptions.leaderships}>
|
||||
{municipalityColumnLabels.leaderships}
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cell: (municipality) => (
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="popover"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trend',
|
||||
label: municipalityColumnLabels.trend,
|
||||
|
|
@ -429,6 +466,7 @@ export const municipalityListPickerColumns = ({
|
|||
{ id: 'classe', label: municipalityColumnLabels.classe },
|
||||
{ id: 'level', label: municipalityColumnLabels.level },
|
||||
{ id: 'advisors', label: municipalityColumnLabels.advisors },
|
||||
{ id: 'leaderships', label: municipalityColumnLabels.leaderships },
|
||||
{ id: 'trend', label: municipalityColumnLabels.trend },
|
||||
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
|
||||
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
|
||||
|
|
@ -438,8 +476,16 @@ export const municipalityListPickerColumns = ({
|
|||
}
|
||||
|
||||
export const MunicipalityList = (props: MunicipalityListProps) => {
|
||||
const { municipalities, advisorNamesById, isStaffView, isCoordinator, advisorOptions, state } =
|
||||
props
|
||||
const {
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
leadershipNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
advisorOptions,
|
||||
leadershipOptions,
|
||||
state,
|
||||
} = props
|
||||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
|
||||
const columns = municipalityListColumns(props)
|
||||
|
|
@ -452,34 +498,38 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
|
||||
return (
|
||||
<MunicipalityAdvisorCreateProvider>
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
<MunicipalityLeadershipCreateProvider>
|
||||
<MunicipalityListMobileSection
|
||||
municipalities={municipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
canMoveEngagementLevel={props.canMoveEngagementLevel}
|
||||
advisorOptions={advisorOptions}
|
||||
leadershipOptions={leadershipOptions}
|
||||
signalFormAction={props.signalFormAction}
|
||||
emptySlot={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible md:block"
|
||||
containerClassName="overflow-x-auto"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
{/* A sticky <th> can't paint the row border, hence the inset shadow. */}
|
||||
<CampaignTable
|
||||
className="hidden overflow-visible md:block"
|
||||
containerClassName="overflow-x-auto"
|
||||
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
|
||||
caption={
|
||||
<>
|
||||
{sortSummary}
|
||||
{showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null}
|
||||
</>
|
||||
}
|
||||
columns={columns}
|
||||
columnVisibility={props.columnVisibility}
|
||||
rows={municipalities}
|
||||
rowKey={(municipality) => municipality.id}
|
||||
empty={<MunicipalityListEmptyState state={state} />}
|
||||
/>
|
||||
</MunicipalityLeadershipCreateProvider>
|
||||
</MunicipalityAdvisorCreateProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
'use client'
|
||||
|
||||
import { UserPlusIcon, XIcon } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type FormEvent } from 'react'
|
||||
|
||||
import type { MunicipalityListLeadershipsResponse } from '@/app/(campaign)/campanha/(app)/municipios/leaderships/types'
|
||||
import { useMunicipalityLeadershipCreate } from '@/components/campaign/municipality/MunicipalityLeadershipCreateProvider'
|
||||
import {
|
||||
CampaignCellEditOverlay,
|
||||
type CampaignCellEditOverlayVariant,
|
||||
} from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { useCampaignCellFailureChannel } from '@/components/campaign/shared/useCampaignCellFailureChannel'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/Command'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import {
|
||||
formatBrazilianPhoneInput,
|
||||
normalizeBrazilianPhone,
|
||||
sanitizeBrazilianPhoneInput,
|
||||
} from '@/lib/phone'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchesAtWordStart } from '@/lib/wordStartFilter'
|
||||
import type {
|
||||
EligibleLeadershipOption,
|
||||
MunicipalityLeadershipSummary,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships'
|
||||
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.'
|
||||
|
||||
/** B155 — sentinel temp id for an in-flight inline create; negative = never a real leadership. */
|
||||
const isPendingCreateId = (id: number): boolean => id < 0
|
||||
|
||||
// Shared empty values so the memos stay referentially stable when the bridge
|
||||
// is absent (standalone renders in unit tests) or nothing is pending.
|
||||
const EMPTY_CREATED_OPTIONS: EligibleLeadershipOption[] = []
|
||||
const EMPTY_PENDING_CREATES: ReadonlyMap<number, { name: string; phone: string }> = new Map()
|
||||
|
||||
/** Resolves leadership ids to summaries, dropping any without a name (deleted/unknown). */
|
||||
const leadershipEntriesFromIds = (
|
||||
leadershipIDs: readonly number[],
|
||||
namesById: ReadonlyMap<number, MunicipalityLeadershipSummary>,
|
||||
): MunicipalityLeadershipSummary[] =>
|
||||
leadershipIDs.flatMap((id) => {
|
||||
const leadership = namesById.get(id)
|
||||
return leadership ? [{ id: leadership.id, name: leadership.name }] : []
|
||||
})
|
||||
|
||||
type MunicipalityListLeadershipsControlProps = {
|
||||
municipalityID: number
|
||||
municipalityName: string
|
||||
currentLeadershipIDs: number[]
|
||||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
options: EligibleLeadershipOption[]
|
||||
variant: CampaignCellEditOverlayVariant
|
||||
}
|
||||
|
||||
export const MunicipalityListLeadershipsControl = ({
|
||||
municipalityID,
|
||||
municipalityName,
|
||||
currentLeadershipIDs,
|
||||
leadershipNamesById,
|
||||
options,
|
||||
variant,
|
||||
}: MunicipalityListLeadershipsControlProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>(currentLeadershipIDs)
|
||||
const [query, setQuery] = useState('')
|
||||
const { errorMessage, setErrorMessage, reportFailure, noteOpenChange } =
|
||||
useCampaignCellFailureChannel()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const pendingCountRef = useRef(0)
|
||||
const lastPropsIDsRef = useRef(currentLeadershipIDs)
|
||||
// Requests can settle out of send order (network jitter), so the send order
|
||||
// is tracked explicitly: only the response with the highest sequence number
|
||||
// seen so far is kept, and it is adopted — win or fail — once every
|
||||
// in-flight delta has settled (`pendingCountRef` back to 0). No response
|
||||
// ever writes `lastPropsIDsRef`: that ref exists solely to tell a genuine
|
||||
// external prop change (nav/refresh) apart from a no-op re-render carrying
|
||||
// the same pre-edit content.
|
||||
const requestSeqRef = useRef(0)
|
||||
const latestConfirmedRef = useRef<{ seq: number; leadershipIDs: number[] } | null>(null)
|
||||
|
||||
// B155 — the inline-create mini-form, hidden until the user picks
|
||||
// "+ Criar liderança" in the no-results branch.
|
||||
const [createFormOpen, setCreateFormOpen] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createPhone, setCreatePhone] = useState('')
|
||||
// Unique per mounted instance: the desktop table and the mobile cards are
|
||||
// sibling trees, so the same município renders two controls at once.
|
||||
const createFormId = useId()
|
||||
// Search box to return focus to once the create form closes (the form
|
||||
// unmounts, so focus would otherwise fall to the body).
|
||||
const commandInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
noteOpenChange(nextOpen)
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
// Adopt server props only when they change from outside (navigation / RSC
|
||||
// refresh) — an in-flight delta's optimistic state must not be clobbered by
|
||||
// a prop identical in content to what we already had.
|
||||
useEffect(() => {
|
||||
if (sameIdSet(currentLeadershipIDs, lastPropsIDsRef.current)) return
|
||||
lastPropsIDsRef.current = currentLeadershipIDs
|
||||
setSelectedIDs(currentLeadershipIDs)
|
||||
}, [currentLeadershipIDs])
|
||||
|
||||
// B155 — leaderships created inline since the page last rendered server-side,
|
||||
// shared across every row's popover through `MunicipalityLeadershipCreateProvider`
|
||||
// (null when the control renders standalone, e.g. in unit tests).
|
||||
const createBridge = useMunicipalityLeadershipCreate()
|
||||
const createdOptions = createBridge?.createdOptions ?? EMPTY_CREATED_OPTIONS
|
||||
const registerCreatedLeadership = createBridge?.registerCreatedLeadership
|
||||
// tempId → {name, phone} for in-flight creates: the chip renders optimistically
|
||||
// while the request is out, and the id is not known until the response arrives.
|
||||
const [pendingCreates, setPendingCreates] =
|
||||
useState<ReadonlyMap<number, { name: string; phone: string }>>(EMPTY_PENDING_CREATES)
|
||||
|
||||
// Every leadership the actor may add is listed here regardless of current
|
||||
// assignment, so it doubles as the name lookup for an id the popover just
|
||||
// optimistically added — no round trip needed to label it.
|
||||
// `leadershipNamesById` is the fallback for an id that fell out of
|
||||
// eligibility but is still assigned; `leadershipEntriesFromIds` drops the
|
||||
// rare id found in neither, same as every other relation cell in the list.
|
||||
const effectiveOptions = useMemo(() => {
|
||||
// The provider bridge can outlive a server re-render (sort/filter
|
||||
// navigation keeps client state, and `getEligibleLeadershipOptions` then
|
||||
// returns the created record again), so the same leadership can arrive
|
||||
// twice — dedupe by id to keep combobox entries and React keys unique.
|
||||
const byId = new Map<number, EligibleLeadershipOption>()
|
||||
for (const option of options) byId.set(option.id, option)
|
||||
for (const option of createdOptions) byId.set(option.id, option)
|
||||
return [...byId.values()]
|
||||
}, [options, createdOptions])
|
||||
|
||||
const leadershipLookup = useMemo(() => {
|
||||
const lookup = new Map<number, MunicipalityLeadershipSummary>(leadershipNamesById)
|
||||
for (const option of effectiveOptions)
|
||||
lookup.set(option.id, { id: option.id, name: option.name })
|
||||
for (const [tempId, pending] of pendingCreates)
|
||||
lookup.set(tempId, { id: tempId, name: pending.name })
|
||||
return lookup
|
||||
}, [leadershipNamesById, effectiveOptions, pendingCreates])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs])
|
||||
const chips = useMemo(
|
||||
() =>
|
||||
[...leadershipEntriesFromIds(selectedIDs, leadershipLookup)].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, 'pt-BR'),
|
||||
),
|
||||
[selectedIDs, leadershipLookup],
|
||||
)
|
||||
|
||||
// The trigger already reads the names by extenso — no hover tooltip needed.
|
||||
const assignedLabel = chips.length ? chips.map((chip) => chip.name).join(', ') : 'nenhuma'
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() => effectiveOptions.filter((option) => matchesAtWordStart(option.name, query)),
|
||||
[effectiveOptions, query],
|
||||
)
|
||||
|
||||
// Single settle point shared by the toggle and the inline create (success,
|
||||
// mapped error, network failure): decrements the pending count and, once it
|
||||
// reaches 0, stops the spinner and reconciles to the latest confirmed server
|
||||
// set — even on a request's own failure, an earlier delta in the same batch
|
||||
// may have already confirmed one.
|
||||
const finishRequest = () => {
|
||||
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1)
|
||||
if (pendingCountRef.current > 0) return
|
||||
setIsPending(false)
|
||||
if (latestConfirmedRef.current) setSelectedIDs(latestConfirmedRef.current.leadershipIDs)
|
||||
}
|
||||
|
||||
const toggle = (leadershipId: number, assigned: boolean) => {
|
||||
// A negative id is a B155 pending-create sentinel, never a real record —
|
||||
// the temp chip has no remove affordance, so this only guards the path.
|
||||
if (isPendingCreateId(leadershipId)) return
|
||||
setErrorMessage(null)
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.add(leadershipId)
|
||||
else next.delete(leadershipId)
|
||||
return [...next]
|
||||
})
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
|
||||
void (async () => {
|
||||
const revertDelta = () => {
|
||||
setSelectedIDs((current) => {
|
||||
const next = new Set(current)
|
||||
if (assigned) next.delete(leadershipId)
|
||||
else next.add(leadershipId)
|
||||
return [...next]
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, leadershipId, assigned },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertDelta()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertDelta()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* B155 — name+phone inline create, assigned to this município. The chip goes
|
||||
* up immediately under a sentinel temp id (the real id only exists once the
|
||||
* response lands); the request rides the same seq/pending machinery as the
|
||||
* toggle, so out-of-order settles and the floor/cap failure reconcile exactly
|
||||
* like a toggle — a failed create reverts the temp chip and shows the Alert.
|
||||
*/
|
||||
const createLeadership = (name: string, phone: string) => {
|
||||
// A create is not an idempotent delta: re-activating the same person while
|
||||
// a create is in flight would mint a second record. The temp chip already
|
||||
// signals the pending one — skip.
|
||||
if (
|
||||
[...pendingCreates.values()].some(
|
||||
(pending) => pending.name === name && pending.phone === phone,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setErrorMessage(null)
|
||||
setCreateFormOpen(false)
|
||||
setCreateName('')
|
||||
setCreatePhone('')
|
||||
setQuery('')
|
||||
|
||||
pendingCountRef.current += 1
|
||||
setIsPending(true)
|
||||
const requestSeq = (requestSeqRef.current += 1)
|
||||
const tempId = -requestSeq
|
||||
|
||||
setSelectedIDs((current) => [...current, tempId])
|
||||
setPendingCreates((current) => new Map(current).set(tempId, { name, phone }))
|
||||
|
||||
void (async () => {
|
||||
const revertCreate = () => {
|
||||
setSelectedIDs((current) => current.filter((id) => id !== tempId))
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { ok, payload } = await postCampaignJson<MunicipalityListLeadershipsResponse>(
|
||||
LEADERSHIPS_ENDPOINT,
|
||||
{ municipalityId: municipalityID, name, phone },
|
||||
)
|
||||
|
||||
if (!ok || payload.status !== 'success') {
|
||||
revertCreate()
|
||||
reportFailure(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestSeq > (latestConfirmedRef.current?.seq ?? 0)) {
|
||||
latestConfirmedRef.current = { seq: requestSeq, leadershipIDs: payload.leadershipIDs }
|
||||
}
|
||||
|
||||
const createdLeadership = payload.createdLeadership
|
||||
if (createdLeadership) {
|
||||
// Swap the sentinel for the real id right away: if another delta is
|
||||
// still in flight the reconcile is deferred, and the chip must not
|
||||
// vanish in the gap (the lookup drops a temp id the moment its
|
||||
// pending entry is removed).
|
||||
setSelectedIDs((current) => [
|
||||
...current.filter((id) => id !== tempId),
|
||||
createdLeadership.id,
|
||||
])
|
||||
setPendingCreates((current) => {
|
||||
const next = new Map(current)
|
||||
next.delete(tempId)
|
||||
return next
|
||||
})
|
||||
// Make the record selectable in every other row's popover on this
|
||||
// page load — `createdOptions` is the client-side bridge until the
|
||||
// next navigation's `getEligibleLeadershipOptions` picks it up.
|
||||
registerCreatedLeadership?.({ id: createdLeadership.id, name: createdLeadership.name })
|
||||
}
|
||||
finishRequest()
|
||||
} catch {
|
||||
revertCreate()
|
||||
reportFailure(SAVE_ERROR_MESSAGE)
|
||||
finishRequest()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const closeCreateForm = () => {
|
||||
setCreateFormOpen(false)
|
||||
// The form unmounts with it; return focus to the search box once the
|
||||
// Command remounts (otherwise keyboard/SR focus falls to the body).
|
||||
requestAnimationFrame(() => commandInputRef.current?.focus())
|
||||
}
|
||||
|
||||
const handleCreateSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const name = createName.trim()
|
||||
const phone = normalizeBrazilianPhone(createPhone)
|
||||
if (name.length < 2 || !phone) return
|
||||
createLeadership(name, phone)
|
||||
}
|
||||
|
||||
const handlePhoneChange = (value: string) => {
|
||||
setCreatePhone(formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(value)))
|
||||
}
|
||||
|
||||
// Mirrors the zod schema (`name 2–120`, `brazilianMobile`) so a malformed
|
||||
// submit never reaches the server's generic error.
|
||||
const canCreate =
|
||||
createName.trim().length >= 2 && createName.trim().length <= 120
|
||||
? normalizeBrazilianPhone(createPhone) !== null
|
||||
: false
|
||||
|
||||
const statusMessage = errorMessage ? errorMessage : isPending ? 'Salvando lideranças.' : ''
|
||||
// The body pads its own sections (`px-4`, aligned with the Drawer header) so
|
||||
// the `Command` can stay full-bleed in both containers — hence `px-0` on the
|
||||
// sheet body and `p-0` on the popover content.
|
||||
const isSheet = variant === 'sheet'
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
variant={variant}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
title="Gerenciar lideranças"
|
||||
description={municipalityName}
|
||||
triggerLabel={`Editar lideranças em ${municipalityName} — ${assignedLabel}`}
|
||||
triggerBusy={isPending}
|
||||
statusMessage={statusMessage}
|
||||
contentClassName="w-80 p-0"
|
||||
sheetBodyClassName="px-0 pt-2"
|
||||
trigger={
|
||||
chips.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">Nenhuma</span>
|
||||
) : (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{chips.map((chip) => (
|
||||
<Badge key={chip.id} variant="secondary" className="max-w-full font-normal">
|
||||
<span className="truncate">{chip.name}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={cn('relative flex shrink-0 flex-col gap-2 px-4 pb-0', !isSheet && 'pt-3')}>
|
||||
{/* The Drawer's own header already titles it. */}
|
||||
{isSheet ? null : <p className="text-sm font-medium">Gerenciar lideranças</p>}
|
||||
<p className={cn('text-xs text-muted-foreground', isSheet && 'pr-6')}>
|
||||
A liderança passa a aparecer também na ficha dela, na área Lideranças.
|
||||
</p>
|
||||
{isPending ? (
|
||||
<Spinner
|
||||
className={cn(
|
||||
'absolute size-3.5 text-muted-foreground',
|
||||
isSheet ? 'top-0 right-4' : 'top-3 right-3',
|
||||
)}
|
||||
aria-label="Salvando lideranças"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{chips.length > 0 ? (
|
||||
<div className="flex shrink-0 flex-wrap gap-1.5 px-4 pt-2">
|
||||
{chips.map((chip) =>
|
||||
// A pending-create chip has no remove affordance: the server-side
|
||||
// create cannot be cancelled mid-flight, so removal waits for the
|
||||
// real record (and the real toggle) once the response lands.
|
||||
isPendingCreateId(chip.id) ? (
|
||||
<Badge
|
||||
key={chip.id}
|
||||
variant="secondary"
|
||||
className={cn('max-w-full font-normal', isSheet && 'min-h-11')}
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
</Badge>
|
||||
) : (
|
||||
<button
|
||||
key={chip.id}
|
||||
type="button"
|
||||
// A 20px pill is a fine mouse target and a bad thumb one: the
|
||||
// Drawer pads it to the 44px minimum without growing the pill.
|
||||
className={cn('inline-flex items-center', isSheet && 'min-h-11')}
|
||||
aria-label={`Remover ${chip.name}`}
|
||||
onClick={() => toggle(chip.id, false)}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15"
|
||||
>
|
||||
<span className="truncate">{chip.name}</span>
|
||||
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</Badge>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{createFormOpen ? (
|
||||
<form className="flex shrink-0 flex-col gap-3 px-4 pb-3 pt-3" onSubmit={handleCreateSubmit}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`leadership-create-name-${createFormId}`}>Nome</Label>
|
||||
<Input
|
||||
id={`leadership-create-name-${createFormId}`}
|
||||
value={createName}
|
||||
onChange={(event) => setCreateName(event.currentTarget.value)}
|
||||
placeholder="Nome completo"
|
||||
autoComplete="name"
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={`leadership-create-phone-${createFormId}`}>Celular (com DDD)</Label>
|
||||
<Input
|
||||
id={`leadership-create-phone-${createFormId}`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
value={createPhone}
|
||||
onChange={(event) => handlePhoneChange(event.currentTarget.value)}
|
||||
placeholder="(71) 99999-9999"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit" size="sm" disabled={!canCreate || isPending}>
|
||||
Criar liderança
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={closeCreateForm}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Command shouldFilter={false} className="mt-1">
|
||||
<CommandInput
|
||||
ref={commandInputRef}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
placeholder="Buscar liderança…"
|
||||
aria-label="Buscar liderança"
|
||||
/>
|
||||
<CommandList>
|
||||
{filteredOptions.length === 0 ? (
|
||||
trimmedQuery.length >= 2 && trimmedQuery.length <= 120 ? (
|
||||
<CommandItem
|
||||
value={`create-${trimmedQuery}`}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => {
|
||||
setCreateFormOpen(true)
|
||||
setCreateName(trimmedQuery)
|
||||
}}
|
||||
>
|
||||
<UserPlusIcon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">Criar liderança “{trimmedQuery}”</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const assigned = selectedSet.has(option.id)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`leadership-${option.id}`}
|
||||
data-checked={assigned}
|
||||
className={isSheet ? 'min-h-11' : undefined}
|
||||
onSelect={() => toggle(option.id, !assigned)}
|
||||
>
|
||||
<span className="truncate">{option.name}</span>
|
||||
</CommandItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
)}
|
||||
{errorMessage ? (
|
||||
<div className="shrink-0 px-4 pt-2 pb-3">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : null}
|
||||
</CampaignCellEditOverlay>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { MunicipalityLevelBadge } from '@/components/campaign/municipality/Munic
|
|||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipality/MunicipalityListGoalCoverageCell'
|
||||
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import {
|
||||
SignalAgeReadout,
|
||||
|
|
@ -26,7 +27,9 @@ import { municipalityGeographyParts } from '@/utilities/municipality/municipalit
|
|||
import { municipalityColumnLabels } from '@/utilities/municipality/municipalityListUrl'
|
||||
import type {
|
||||
EligibleAdvisorOption,
|
||||
EligibleLeadershipOption,
|
||||
MunicipalityAdvisorSummary,
|
||||
MunicipalityLeadershipSummary,
|
||||
MunicipalityListViewModel,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
|
||||
|
|
@ -39,10 +42,14 @@ type MunicipalityStaffFormAction = (
|
|||
export type MunicipalityListMobileCardsProps = {
|
||||
municipalities: MunicipalityListViewModel[]
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
|
||||
/** B155 — contact-name lookup for the Lideranças sheet chips. */
|
||||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
isStaffView: boolean
|
||||
isCoordinator: boolean
|
||||
canMoveEngagementLevel: boolean
|
||||
advisorOptions: EligibleAdvisorOption[]
|
||||
/** B155 — every leadership the actor may add, for the Lideranças sheet. */
|
||||
leadershipOptions: EligibleLeadershipOption[]
|
||||
signalFormAction: MunicipalityStaffFormAction
|
||||
emptySlot: ReactNode
|
||||
}
|
||||
|
|
@ -54,10 +61,12 @@ export type MunicipalityListMobileCardsProps = {
|
|||
export const MunicipalityListMobileCards = ({
|
||||
municipalities,
|
||||
advisorNamesById,
|
||||
leadershipNamesById,
|
||||
isStaffView,
|
||||
isCoordinator,
|
||||
canMoveEngagementLevel,
|
||||
advisorOptions,
|
||||
leadershipOptions,
|
||||
signalFormAction,
|
||||
emptySlot,
|
||||
}: MunicipalityListMobileCardsProps) => (
|
||||
|
|
@ -195,6 +204,19 @@ export const MunicipalityListMobileCards = ({
|
|||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Lideranças</dt>
|
||||
<dd>
|
||||
<MunicipalityListLeadershipsControl
|
||||
municipalityID={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
currentLeadershipIDs={municipality.leadershipIDs}
|
||||
leadershipNamesById={leadershipNamesById}
|
||||
options={leadershipOptions}
|
||||
variant="sheet"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -102,6 +102,19 @@ export const leadershipWizardCreateSchema = leadershipWizardFieldsSchema.extend(
|
|||
municipalityId: positiveRelationshipId,
|
||||
})
|
||||
|
||||
/**
|
||||
* B155 — inline create from the "Lideranças" column of `/campanha/municipios`:
|
||||
* name + phone only, linked to the município whose popover created it. Strict
|
||||
* so the toggle shape (`leadershipId`/`assigned`) can never slip through.
|
||||
*/
|
||||
export const municipalityLeadershipCreateSchema = z.strictObject({
|
||||
municipalityId: positiveRelationshipId,
|
||||
name: z.string().trim().min(2).max(120),
|
||||
phone: brazilianMobile,
|
||||
})
|
||||
|
||||
export type MunicipalityLeadershipCreateInput = z.input<typeof municipalityLeadershipCreateSchema>
|
||||
|
||||
export const leadershipWizardUpdateSchema = leadershipWizardFieldsSchema.extend({
|
||||
id: positiveRelationshipId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ export type MunicipalityListColumnId =
|
|||
// for the same column is `nivel`, and both URL spellings are frozen (B18).
|
||||
| 'level'
|
||||
| 'advisors'
|
||||
| 'leaderships'
|
||||
| 'trend'
|
||||
| 'expectedVotes'
|
||||
| 'lastSignal'
|
||||
|
|
@ -218,6 +219,7 @@ export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, st
|
|||
classe: CLASS_COLUMN_DESCRIPTION,
|
||||
level: campaignConceptOneLiner('nivel-de-envolvimento'),
|
||||
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
|
||||
leaderships: 'Lideranças vinculadas ao município — a equipe adiciona e remove clicando aqui.',
|
||||
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
|
||||
expectedVotes: campaignConceptOneLiner('meta'),
|
||||
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export const municipalityColumnLabels: Record<MunicipalityListColumnId, string>
|
|||
classe: municipalityListSortLabels.classe,
|
||||
level: municipalityListSortLabels.nivel,
|
||||
advisors: municipalityListSortLabels.coverage,
|
||||
/** B155 — the column has no sort/filter; the label stands alone in the picker. */
|
||||
leaderships: 'Lideranças',
|
||||
trend: municipalityListSortLabels.trend,
|
||||
expectedVotes: municipalityListSortLabels.expectedVotes,
|
||||
/** The column shows the signal; `frescor` sorts by how old it is. */
|
||||
|
|
|
|||
|
|
@ -44,10 +44,12 @@ import {
|
|||
territorialClassSortWeight,
|
||||
} from '@/utilities/municipality/municipalityTerritorialClass'
|
||||
import {
|
||||
loadMunicipalityLeadershipSummaries,
|
||||
municipalityListSelect,
|
||||
toMunicipalityDetailViewModel,
|
||||
toMunicipalityListViewModel,
|
||||
type MunicipalityDetailViewModel,
|
||||
type MunicipalityLeadershipSummary,
|
||||
type MunicipalityListViewModel,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
|
||||
|
|
@ -72,8 +74,12 @@ export type MunicipalityListPageBundle = {
|
|||
totalPages: number
|
||||
scopeTotal: number
|
||||
filterFacets: MunicipalityListFilterFacets
|
||||
/** B155 — contact-name lookup for the chips of the município list rows. */
|
||||
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
}
|
||||
|
||||
const EMPTY_LEADERSHIP_NAMES: ReadonlyMap<number, MunicipalityLeadershipSummary> = new Map()
|
||||
|
||||
const emptyMunicipalityListFilterFacets: MunicipalityListFilterFacets = {
|
||||
slugs: [],
|
||||
regions: [],
|
||||
|
|
@ -299,6 +305,7 @@ export const loadMunicipalityListPageBundle = async (
|
|||
totalPages: 0,
|
||||
scopeTotal: 0,
|
||||
filterFacets: emptyMunicipalityListFilterFacets,
|
||||
leadershipNamesById: EMPTY_LEADERSHIP_NAMES,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -412,6 +419,17 @@ export const loadMunicipalityListPageBundle = async (
|
|||
pageDocs = allDocs.slice(start, start + municipalityPageSize)
|
||||
}
|
||||
|
||||
// B155 — the leaderships of the visible page only (staff surfaces; the
|
||||
// leader view already returned above). One reverse batch: ids per município
|
||||
// for the rows, contact names for the chips.
|
||||
const leadershipBundle = isStaff
|
||||
? await loadMunicipalityLeadershipSummaries(
|
||||
payload,
|
||||
user,
|
||||
pageDocs.map((municipality) => municipality.id),
|
||||
)
|
||||
: null
|
||||
|
||||
return {
|
||||
municipalities: pageDocs.map((municipality) =>
|
||||
toMunicipalityListViewModel(
|
||||
|
|
@ -419,12 +437,14 @@ export const loadMunicipalityListPageBundle = async (
|
|||
isStaff ? pledgeAggregates.get(municipality.id) : undefined,
|
||||
ranks.get(municipality.slug) ?? null,
|
||||
isStaff ? goalCoverageByMunicipalityID.get(municipality.id) : undefined,
|
||||
leadershipBundle?.leadershipIDsByMunicipality.get(municipality.id) ?? [],
|
||||
),
|
||||
),
|
||||
totalDocs,
|
||||
totalPages,
|
||||
scopeTotal: scopeCount.totalDocs,
|
||||
filterFacets,
|
||||
leadershipNamesById: leadershipBundle?.summariesById ?? EMPTY_LEADERSHIP_NAMES,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import type { Payload } from 'payload'
|
|||
|
||||
import type { EngagementLevel } from '@/lib/engagementLevel'
|
||||
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
toVoteEstimateScenarioViewModel,
|
||||
type VoteEstimateScenario,
|
||||
type VoteEstimateScenarioViewModel,
|
||||
} from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import type { CampaignUser, Leadership, Municipality } from '@/payload-types'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
import {
|
||||
createEmptyGoalCoverageByScenario,
|
||||
|
|
@ -65,6 +65,8 @@ export type MunicipalityListViewModel = {
|
|||
ibgeCode: string
|
||||
zoneNumber: number | null
|
||||
advisorIDs: number[]
|
||||
/** B155 — leadership ids linked to this município (reverse read of `leadership.municipalities`). */
|
||||
leadershipIDs: number[]
|
||||
priority: 'alta' | 'normal'
|
||||
lastUpdateAt: string | null
|
||||
/**
|
||||
|
|
@ -95,6 +97,8 @@ export const toMunicipalityListViewModel = (
|
|||
pledges: MunicipalityPledgeAggregate | undefined,
|
||||
votePosition2022: MunicipalityListViewModel['votePosition2022'],
|
||||
goalCoverageByScenario?: Record<VoteEstimateScenario, MunicipalityGoalCoverage>,
|
||||
/** B155 — empty for non-staff views; the leader never renders this column. */
|
||||
leadershipIDs: number[] = [],
|
||||
): MunicipalityListViewModel => {
|
||||
const territorialClass = computeMunicipalityTerritorialClass(municipality.slug)
|
||||
|
||||
|
|
@ -110,6 +114,7 @@ export const toMunicipalityListViewModel = (
|
|||
advisorIDs: (municipality.advisors ?? [])
|
||||
.map(relationshipId)
|
||||
.filter((id): id is number => id !== null),
|
||||
leadershipIDs,
|
||||
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
|
||||
lastUpdateAt: municipality.lastUpdateAt ?? null,
|
||||
lastSignalAt: resolveMunicipalityLastSignalAt(
|
||||
|
|
@ -278,3 +283,103 @@ export const getEligibleAdvisorOptions = async (
|
|||
}))
|
||||
.sort((left, right) => Number(right.isCurrent) - Number(left.isCurrent))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// B155 — Lideranças na lista de municípios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MunicipalityLeadershipSummary = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type EligibleLeadershipOption = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const emptyLeadershipSummaries = {
|
||||
leadershipIDsByMunicipality: new Map<number, number[]>(),
|
||||
summariesById: new Map<number, MunicipalityLeadershipSummary>(),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* B155 — one reverse batch over `leadership.municipalities` for the município
|
||||
* list page: which leaderships each listed município links to (ids) plus the
|
||||
* contact-name lookup for their chips. Names ship in the cell, so the read
|
||||
* honours `canReadLeadership` (`user` + `overrideAccess: false`) — an advisor
|
||||
* only ever sees the leaderships of the municípios they administer.
|
||||
*/
|
||||
export const loadMunicipalityLeadershipSummaries = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
municipalityIDs: readonly number[],
|
||||
): Promise<{
|
||||
leadershipIDsByMunicipality: ReadonlyMap<number, number[]>
|
||||
summariesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
|
||||
}> => {
|
||||
if (municipalityIDs.length === 0) return emptyLeadershipSummaries
|
||||
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
where: { municipalities: { in: municipalityIDs } },
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { municipalities: true, contact: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
const leadershipIDsByMunicipality = new Map<number, number[]>()
|
||||
const summariesById = new Map<number, MunicipalityLeadershipSummary>()
|
||||
|
||||
for (const doc of result.docs as Leadership[]) {
|
||||
const leadershipID = relationshipId(doc.id)
|
||||
if (leadershipID === null) continue
|
||||
|
||||
summariesById.set(leadershipID, { id: leadershipID, name: populatedContactName(doc.contact) })
|
||||
|
||||
for (const municipality of doc.municipalities ?? []) {
|
||||
const municipalityID = relationshipId(municipality)
|
||||
if (municipalityID === null) continue
|
||||
const list = leadershipIDsByMunicipality.get(municipalityID) ?? []
|
||||
list.push(leadershipID)
|
||||
leadershipIDsByMunicipality.set(municipalityID, list)
|
||||
}
|
||||
}
|
||||
|
||||
return { leadershipIDsByMunicipality, summariesById }
|
||||
}
|
||||
|
||||
/**
|
||||
* B155 — every leadership the actor may add to a município from the popover:
|
||||
* all of them for unrestricted staff, portfolio-scoped for an advisor (the
|
||||
* same `canReadLeadership` the list cells trust). Sorted by contact name for
|
||||
* the Command; `createdAt` keeps the underlying fetch deterministic.
|
||||
*/
|
||||
export const getEligibleLeadershipOptions = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
): Promise<EligibleLeadershipOption[]> => {
|
||||
const result = await payload.find({
|
||||
collection: 'leadership',
|
||||
depth: 1,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
sort: 'createdAt',
|
||||
select: { contact: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs
|
||||
.map((doc) => {
|
||||
const leadership = doc as Leadership
|
||||
const id = relationshipId(leadership.id)
|
||||
if (id === null) return null
|
||||
return { id, name: populatedContactName(leadership.contact) }
|
||||
})
|
||||
.filter((option): option is EligibleLeadershipOption => option !== null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/municipios/advisors',
|
||||
'/campanha/municipios/engagement-level',
|
||||
'/campanha/municipios/expected-votes',
|
||||
'/campanha/municipios/leaderships',
|
||||
'/campanha/municipios/pledge-declared-votes',
|
||||
'/campanha/municipios/pledge-estimated-votes',
|
||||
'/campanha/municipios/political-trend',
|
||||
|
|
|
|||
153
tests/int/campaignMunicipalityLeadershipList.int.spec.ts
Normal file
153
tests/int/campaignMunicipalityLeadershipList.int.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createLeadershipRecord } from '@/app/(campaign)/campanha/actions/leadership'
|
||||
import config from '@/payload.config'
|
||||
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
|
||||
import {
|
||||
getEligibleLeadershipOptions,
|
||||
loadMunicipalityLeadershipSummaries,
|
||||
} from '@/utilities/municipality/municipalityViewModels'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('B155 — lideranças na lista de municípios (leitura reversa + opções)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('summarizes the leaderships of the given municípios with contact names', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const first = await fixtures.getMunicipality()
|
||||
const second = await fixtures.getMunicipality()
|
||||
const contactA = await fixtures.createContact({ name: 'Maria de Jesus' })
|
||||
const leadershipA = await fixtures.createLeadership({
|
||||
contact: contactA,
|
||||
municipalities: [first],
|
||||
})
|
||||
const contactB = await fixtures.createContact({ name: 'João do Brejo' })
|
||||
const leadershipB = await fixtures.createLeadership({
|
||||
contact: contactB,
|
||||
municipalities: [first, second],
|
||||
})
|
||||
|
||||
const { leadershipIDsByMunicipality, summariesById } =
|
||||
await loadMunicipalityLeadershipSummaries(payload, coordinator, [first.id, second.id])
|
||||
|
||||
expect([...summariesById.keys()].sort()).toEqual([leadershipA.id, leadershipB.id].sort())
|
||||
expect(summariesById.get(leadershipA.id)?.name).toBe('Maria de Jesus')
|
||||
expect(summariesById.get(leadershipB.id)?.name).toBe('João do Brejo')
|
||||
expect(leadershipIDsByMunicipality.get(first.id)?.sort()).toEqual(
|
||||
[leadershipA.id, leadershipB.id].sort(),
|
||||
)
|
||||
expect(leadershipIDsByMunicipality.get(second.id)).toEqual([leadershipB.id])
|
||||
})
|
||||
|
||||
it('returns empty maps for an empty id list', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
|
||||
const { leadershipIDsByMunicipality, summariesById } =
|
||||
await loadMunicipalityLeadershipSummaries(payload, coordinator, [])
|
||||
|
||||
expect(leadershipIDsByMunicipality.size).toBe(0)
|
||||
expect(summariesById.size).toBe(0)
|
||||
})
|
||||
|
||||
it('scopes the summaries and options to an advisor portfolio', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
const administered = await fixtures.getMunicipality()
|
||||
const stranger = await fixtures.getMunicipality()
|
||||
await fixtures.assignMunicipalityAdvisors(administered, [advisor])
|
||||
|
||||
const ownContact = await fixtures.createContact({ name: 'Própria' })
|
||||
const own = await fixtures.createLeadership({
|
||||
contact: ownContact,
|
||||
municipalities: [administered],
|
||||
})
|
||||
const alienContact = await fixtures.createContact({ name: 'Alheia' })
|
||||
const alien = await fixtures.createLeadership({
|
||||
contact: alienContact,
|
||||
municipalities: [stranger],
|
||||
})
|
||||
|
||||
const { summariesById } = await loadMunicipalityLeadershipSummaries(payload, advisor, [
|
||||
administered.id,
|
||||
stranger.id,
|
||||
])
|
||||
expect(summariesById.has(own.id)).toBe(true)
|
||||
expect(summariesById.has(alien.id)).toBe(false)
|
||||
|
||||
const options = await getEligibleLeadershipOptions(payload, advisor)
|
||||
expect(options.map((option) => option.id)).toContain(own.id)
|
||||
expect(options.map((option) => option.id)).not.toContain(alien.id)
|
||||
})
|
||||
|
||||
it('lists every leadership for unrestricted staff and names them by contact', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Fulana dos Anjos' })
|
||||
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
|
||||
const options = await getEligibleLeadershipOptions(payload, coordinator)
|
||||
const found = options.find((option) => option.id === leadership.id)
|
||||
|
||||
expect(found).toMatchObject({ id: leadership.id, name: 'Fulana dos Anjos' })
|
||||
})
|
||||
|
||||
it('carries leadershipIDs and names in the list page bundle for staff only', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
const contact = await fixtures.createContact({ name: 'Chica da Roça' })
|
||||
const leadership = await fixtures.createLeadership({ contact, municipalities: [municipality] })
|
||||
|
||||
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
slug: municipality.slug,
|
||||
})
|
||||
const row = bundle.municipalities.find((item) => item.id === municipality.id)
|
||||
|
||||
expect(row?.leadershipIDs).toContain(leadership.id)
|
||||
expect(bundle.leadershipNamesById.get(leadership.id)?.name).toBe('Chica da Roça')
|
||||
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const leaderBundle = await loadMunicipalityListPageBundle(payload, leader, {})
|
||||
expect(leaderBundle.municipalities).toEqual([])
|
||||
expect(leaderBundle.leadershipNamesById.size).toBe(0)
|
||||
})
|
||||
|
||||
it('sees an inline-created leadership through the column read (create → summarize)', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
|
||||
// The route's create branch calls `createLeadershipWizardRecord`, which is
|
||||
// the same validated path as `createLeadershipRecord` with one município —
|
||||
// this pins the create → column-read integration.
|
||||
const created = await createLeadershipRecord(payload, coordinator, {
|
||||
municipalities: [municipality.id],
|
||||
name: 'Criada na Reunião',
|
||||
phone: fixtures.phone(),
|
||||
})
|
||||
|
||||
const { leadershipIDsByMunicipality, summariesById } =
|
||||
await loadMunicipalityLeadershipSummaries(payload, coordinator, [municipality.id])
|
||||
|
||||
expect(leadershipIDsByMunicipality.get(municipality.id)).toContain(created.id)
|
||||
expect(summariesById.get(created.id)?.name).toBe('Criada na Reunião')
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest
|
|||
|
||||
import { MunicipalityListAdvisorsControl } from '@/components/campaign/municipality/MunicipalityListAdvisorsControl'
|
||||
import { MunicipalityListExpectedVotesControl } from '@/components/campaign/municipality/MunicipalityListExpectedVotesControl'
|
||||
import { MunicipalityListLeadershipsControl } from '@/components/campaign/municipality/MunicipalityListLeadershipsControl'
|
||||
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
|
||||
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
|
||||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
|
|
@ -71,6 +72,20 @@ const overlayCases: OverlayCase[] = [
|
|||
variant,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'lideranças',
|
||||
triggerLabel: `Editar lideranças em ${MUNICIPALITY_NAME} — Maria de Jesus`,
|
||||
drawerTitle: 'Gerenciar lideranças',
|
||||
element: (variant) =>
|
||||
createElement(MunicipalityListLeadershipsControl, {
|
||||
municipalityID: 1,
|
||||
municipalityName: MUNICIPALITY_NAME,
|
||||
currentLeadershipIDs: [11],
|
||||
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
|
||||
options: [{ id: 11, name: 'Maria de Jesus' }],
|
||||
variant,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'votos estimados',
|
||||
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ const municipalityListDefaultProps = {
|
|||
} satisfies CampaignColumnVisibility,
|
||||
canMoveEngagementLevel: false,
|
||||
advisorOptions: [],
|
||||
leadershipNamesById: new Map(),
|
||||
leadershipOptions: [],
|
||||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
signalFormAction: noopListFormAction,
|
||||
state: { page: 1 },
|
||||
|
|
@ -177,6 +179,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [advisor.id],
|
||||
leadershipIDs: [11],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -220,6 +223,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2927408',
|
||||
zoneNumber: 3,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -243,6 +247,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorNamesById: new Map([[advisor.id, advisor]]),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
leadershipNamesById: new Map([[11, { id: 11, name: 'Maria de Jesus' }]]),
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -264,12 +269,15 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).not.toContain('Cobertura da meta')
|
||||
expect(html).toContain('80%')
|
||||
expect(html).toContain('Faltam 300 votos para a meta')
|
||||
// B155: the Lideranças column chips the linked leaderships by name.
|
||||
expect(html).toContain('Lideranças')
|
||||
expect(html).toContain('Maria de Jesus')
|
||||
// B22: every one of the 11 staff columns carries a header explanation —
|
||||
// pinned inside `<thead>` itself, since `cellTooltip` (e.g. "Classe") and
|
||||
// the mobile card's own class tooltip (B42) add triggers of their own.
|
||||
const [, tbodyHtml = ''] = html.split('<tbody')
|
||||
const theadHtml = html.slice(html.indexOf('<thead'), html.indexOf('</thead>'))
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(10)
|
||||
expect(theadHtml.match(/data-slot="tooltip-trigger"/g)).toHaveLength(11)
|
||||
|
||||
// B41: horizontal scroll table with a pinned Município column header.
|
||||
expect(html).toContain('data-slot="table-container"')
|
||||
|
|
@ -297,6 +305,7 @@ describe('campaign visual foundation', () => {
|
|||
city: 'Seabra',
|
||||
region: 'Chapada Diamantina',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal',
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
|
|
@ -316,6 +325,7 @@ describe('campaign visual foundation', () => {
|
|||
city: 'Sem série',
|
||||
region: 'Chapada Diamantina',
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal',
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
|
|
@ -360,6 +370,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2903201',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: staleSignal,
|
||||
lastSignalAt: staleSignal,
|
||||
|
|
@ -404,6 +415,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2932002',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -445,6 +457,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2914703',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -492,6 +505,7 @@ describe('campaign visual foundation', () => {
|
|||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
@ -555,7 +569,7 @@ describe('campaign visual foundation', () => {
|
|||
// The staff `<thead>` already carries 11 header-explanation tooltips
|
||||
// (B22), regardless of row data. This baseline isolates the advisors
|
||||
// CELL tooltip, the one this test actually exercises.
|
||||
const HEADER_TOOLTIP_COUNT = 10
|
||||
const HEADER_TOOLTIP_COUNT = 11
|
||||
const baseMunicipality = {
|
||||
id: 1,
|
||||
name: 'Seabra',
|
||||
|
|
@ -565,6 +579,8 @@ describe('campaign visual foundation', () => {
|
|||
region: 'Chapada Diamantina',
|
||||
ibgeCode: '2929800',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
leadershipIDs: [],
|
||||
priority: 'normal' as const,
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
|
|
|
|||
|
|
@ -533,6 +533,9 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
|
|||
'src/utilities/leadership/leadershipData.ts',
|
||||
'src/utilities/leadership/leadershipListUrl.ts',
|
||||
'src/utilities/municipality/municipalityTriggers.ts',
|
||||
// B155 — the municipality list surface reads `leadership.municipalities`
|
||||
// by reverse batch; same distinct form as `leadershipData.ts` above.
|
||||
'src/utilities/municipality/municipalityViewModels.ts',
|
||||
'src/utilities/visit/visitPlannerData.ts',
|
||||
'src/utilities/votePledgeData.ts',
|
||||
])
|
||||
|
|
|
|||
Loading…
Reference in a new issue