From 596085c97743f027c421d7590f28de915a3f5ead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 4 Aug 2026 01:43:45 -0300 Subject: [PATCH] =?UTF-8?q?feat(campanha):=20B155=20=E2=80=94=20coluna=20d?= =?UTF-8?q?e=20lideran=C3=A7as=20na=20lista=20de=20munic=C3=ADpios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Espelho da coluna de Assessores: chips com nome do contato, popover/sheet com Command + delta otimista e criação inline (Nome + Celular). Sem migration — leitura reversa de leadership.municipalities no bundle e escrita reusando as actions B34/wizard; rota POST /campanha/municipios/leaderships via campaignJsonMutationRoute. --- docs/CHANGELOG-AGENTS.md | 2 + .../liderancas-coluna-municipios-impl.md | 88 +++ .../(app)/municipios/leaderships/route.ts | 92 +++ .../(app)/municipios/leaderships/types.ts | 13 + .../campanha/(app)/municipios/page.tsx | 16 +- .../(campaign)/campanha/actions/leadership.ts | 85 +++ .../MunicipalityLeadershipCreateProvider.tsx | 49 ++ .../municipality/MunicipalityList.tsx | 110 +++- .../MunicipalityListLeadershipsControl.tsx | 530 ++++++++++++++++++ .../MunicipalityListMobileCards.tsx | 22 + src/lib/schemas/leadership.ts | 13 + .../municipality/municipalityLabels.ts | 2 + .../municipality/municipalityListUrl.ts | 2 + .../municipality/municipalityPageData.ts | 20 + .../municipality/municipalityViewModels.ts | 109 +++- tests/e2e/setup.e2e.spec.ts | 1 + ...aignMunicipalityLeadershipList.int.spec.ts | 153 +++++ .../unit/campaignCellEditOverlay.unit.spec.ts | 15 + tests/unit/campaignComponents.unit.spec.ts | 20 +- tests/unit/codebaseConventions.unit.spec.ts | 3 + 20 files changed, 1309 insertions(+), 36 deletions(-) create mode 100644 docs/plans/liderancas-coluna-municipios-impl.md create mode 100644 src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts create mode 100644 src/app/(campaign)/campanha/(app)/municipios/leaderships/types.ts create mode 100644 src/components/campaign/municipality/MunicipalityLeadershipCreateProvider.tsx create mode 100644 src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx create mode 100644 tests/int/campaignMunicipalityLeadershipList.int.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 8c166a59..2697d587 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 ''" quando a busca não casa ninguém; cria `campaignUser` advisor (e-mail stub `@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). diff --git a/docs/plans/liderancas-coluna-municipios-impl.md b/docs/plans/liderancas-coluna-municipios-impl.md new file mode 100644 index 00000000..38c89419 --- /dev/null +++ b/docs/plans/liderancas-coluna-municipios-impl.md @@ -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
municipalities in pageIDs, depth 1"] + C --> D["rows ganham leadershipIDs
+ retorna leadershipNamesById"] + A --> E["getEligibleLeadershipOptions (staff)"] + D --> F["MunicipalityList — coluna Lideranças"] + E --> F + F --> G["MunicipalityListLeadershipsControl
(popover desktop / sheet mobile)"] + G --> I["POST /campanha/municipios/leaderships"] + I --> J["toggle → setLeadershipMunicipalitiesMembership (B34, reuso)"] + I --> K["create → createMunicipalityLeadership (wrapper fino
→ 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/`), 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. diff --git a/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts b/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts new file mode 100644 index 00000000..dc55c386 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/leaderships/route.ts @@ -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({ + 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({ + status: 'success', + message: body.assigned ? 'Liderança vinculada.' : 'Liderança removida.', + leadershipIDs, + }) + }, +) diff --git a/src/app/(campaign)/campanha/(app)/municipios/leaderships/types.ts b/src/app/(campaign)/campanha/(app)/municipios/leaderships/types.ts new file mode 100644 index 00000000..1d7a687a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/municipios/leaderships/types.ts @@ -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 + } diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index 78312330..06ddb335 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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 { + 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. diff --git a/src/components/campaign/municipality/MunicipalityLeadershipCreateProvider.tsx b/src/components/campaign/municipality/MunicipalityLeadershipCreateProvider.tsx new file mode 100644 index 00000000..73eec747 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityLeadershipCreateProvider.tsx @@ -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(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([]) + + 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 ( + + {children} + + ) +} diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index 86b263ca..6677f319 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -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 + /** B155 — contact-name lookup for the Lideranças column chips. */ + leadershipNamesById: ReadonlyMap 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 => [ { @@ -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: ( + + {municipalityColumnLabels.leaderships} + + ), + cell: (municipality) => ( + + ), + }, { 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 ( - } - /> + + } + /> - {/* A sticky can't paint the row border, hence the inset shadow. */} - - {sortSummary} - {showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null} - - } - columns={columns} - columnVisibility={props.columnVisibility} - rows={municipalities} - rowKey={(municipality) => municipality.id} - empty={} - /> + {/* A sticky can't paint the row border, hence the inset shadow. */} + + {sortSummary} + {showsVoteColumn ? `. Coluna 2022: ${municipalityColumnDescriptions.votos}` : null} + + } + columns={columns} + columnVisibility={props.columnVisibility} + rows={municipalities} + rowKey={(municipality) => municipality.id} + empty={} + /> + ) } diff --git a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx new file mode 100644 index 00000000..48d15e17 --- /dev/null +++ b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx @@ -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 = new Map() + +/** Resolves leadership ids to summaries, dropping any without a name (deleted/unknown). */ +const leadershipEntriesFromIds = ( + leadershipIDs: readonly number[], + namesById: ReadonlyMap, +): 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 + options: EligibleLeadershipOption[] + variant: CampaignCellEditOverlayVariant +} + +export const MunicipalityListLeadershipsControl = ({ + municipalityID, + municipalityName, + currentLeadershipIDs, + leadershipNamesById, + options, + variant, +}: MunicipalityListLeadershipsControlProps) => { + const [open, setOpen] = useState(false) + const [selectedIDs, setSelectedIDs] = useState(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(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>(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() + 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(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( + 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( + 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) => { + 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 ( + Nenhuma + ) : ( + + {chips.map((chip) => ( + + {chip.name} + + ))} + + ) + } + > +
+ {/* The Drawer's own header already titles it. */} + {isSheet ? null :

Gerenciar lideranças

} +

+ A liderança passa a aparecer também na ficha dela, na área Lideranças. +

+ {isPending ? ( + + ) : null} +
+ {chips.length > 0 ? ( +
+ {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) ? ( + + {chip.name} + + ) : ( + + ), + )} +
+ ) : null} + {createFormOpen ? ( +
+
+ + setCreateName(event.currentTarget.value)} + placeholder="Nome completo" + autoComplete="name" + maxLength={120} + autoFocus + /> +
+
+ + handlePhoneChange(event.currentTarget.value)} + placeholder="(71) 99999-9999" + /> +
+
+ + +
+
+ ) : ( + + + + {filteredOptions.length === 0 ? ( + trimmedQuery.length >= 2 && trimmedQuery.length <= 120 ? ( + { + setCreateFormOpen(true) + setCreateName(trimmedQuery) + }} + > + + ) : ( +

+ Nenhum resultado. +

+ ) + ) : ( + filteredOptions.map((option) => { + const assigned = selectedSet.has(option.id) + return ( + toggle(option.id, !assigned)} + > + {option.name} + + ) + }) + )} +
+
+ )} + {errorMessage ? ( +
+ + {errorMessage} + +
+ ) : null} +
+ ) +} diff --git a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx index 36206150..bb0aa3e6 100644 --- a/src/components/campaign/municipality/MunicipalityListMobileCards.tsx +++ b/src/components/campaign/municipality/MunicipalityListMobileCards.tsx @@ -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 + /** B155 — contact-name lookup for the Lideranças sheet chips. */ + leadershipNamesById: ReadonlyMap 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 = ({ )} +
+
Lideranças
+
+ +
+
) : null} diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index f8333226..a0086092 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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 + export const leadershipWizardUpdateSchema = leadershipWizardFieldsSchema.extend({ id: positiveRelationshipId, }) diff --git a/src/utilities/municipality/municipalityLabels.ts b/src/utilities/municipality/municipalityLabels.ts index 92be249d..4b21e57d 100644 --- a/src/utilities/municipality/municipalityLabels.ts +++ b/src/utilities/municipality/municipalityLabels.ts @@ -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 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. */ diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts index 48a8367e..17c7e53e 100644 --- a/src/utilities/municipality/municipalityPageData.ts +++ b/src/utilities/municipality/municipalityPageData.ts @@ -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 } +const EMPTY_LEADERSHIP_NAMES: ReadonlyMap = 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, } } diff --git a/src/utilities/municipality/municipalityViewModels.ts b/src/utilities/municipality/municipalityViewModels.ts index 65a8b661..0aaed438 100644 --- a/src/utilities/municipality/municipalityViewModels.ts +++ b/src/utilities/municipality/municipalityViewModels.ts @@ -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, + /** 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(), + summariesById: new Map(), +} 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 + summariesById: ReadonlyMap +}> => { + 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() + const summariesById = new Map() + + 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 => { + 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')) +} diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index f81eef5c..889d32a2 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -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', diff --git a/tests/int/campaignMunicipalityLeadershipList.int.spec.ts b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts new file mode 100644 index 00000000..1b21b2f0 --- /dev/null +++ b/tests/int/campaignMunicipalityLeadershipList.int.spec.ts @@ -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') + }) +}) diff --git a/tests/unit/campaignCellEditOverlay.unit.spec.ts b/tests/unit/campaignCellEditOverlay.unit.spec.ts index b9455b4c..75fbadfe 100644 --- a/tests/unit/campaignCellEditOverlay.unit.spec.ts +++ b/tests/unit/campaignCellEditOverlay.unit.spec.ts @@ -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`, diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index 7c2fcbbf..402b8615 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -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 `` itself, since `cellTooltip` (e.g. "Classe") and // the mobile card's own class tooltip (B42) add triggers of their own. const [, tbodyHtml = ''] = html.split('')) - 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 `` 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, diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 453425c9..01396464 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -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', ])