feat(campaign): C142 UI respects advisor permission profile somente_leitura

- Pure helpers: advisorEditingScope, rowEditingAllowed in campaignAdvisorProfile.ts
- gate: 'writable' in requireCampaignPageActor for route-level enforcement
- Shared cells accept readOnly prop: CampaignInlineEditableCell, MunicipalityRelationEditor, PhonesFieldEditor, DemandDescriptionEditor, etc.
- Municipality pages compute editingScope via resolveMunicipalityWriteScope and thread it to list/detail/edit surfaces
- Home actions, FAB mount rule, quick actions registry accept editingScope parameter
- Demands, activities, supporters, organizations create buttons gated
- E2E spec: campaignPermissionProfile.e2e.spec.ts covering 3 profiles
- Unit tests: campaignAdvisorProfile, accessShared, campaignComponents updated
- gate:fast passed (tsc, lint, format, knip, cycles, unit)
This commit is contained in:
Francisco Solla 2026-08-21 20:40:00 -03:00
parent 0822cca82c
commit 0d483a08db
57 changed files with 1155 additions and 257 deletions

View file

@ -0,0 +1,131 @@
# Impl: Interface respeita o perfil de permissão (somente-leitura sem controles de edição; visão Tudo no escopo completo)
Status: aprovado
Atualizado em: 2026-08-21
Issue: #770 (C142)
Intenção: docs/plans/ui-respeita-perfil-permissao.md
Appetite restante: herdado (~1–2 dias eng) — passada mecânica sobre superfícies existentes; cortes no "Rabbit holes / Não escopo"
## Leitura da intenção
- **Outcome:** as superfícies staff do `/campanha` refletem o perfil C141 do ator: assessor `somente_leitura` não encontra NENHUM controle de escrita (células autosave, chips, "Registrar atualização", FAB/ações rápidas, wizards, sheets, status de demandas); assessor `tudo` navega listas/detalhes/mapa no escopo completo com contagens coerentes; `carteira · edita carteira` = comportamento de hoje; nada muda para coordinator/candidate/leader.
- **O que NÃO negociar:**
- Presença/ausência dos controles é a única linguagem — sem banner de "modo leitura", sem cinza-fantasma/disabled em massa (decisões do gate B/A).
- O enforcement do servidor (C141) não é relaxado nem substituído — a apresentação acompanha, nunca antecede.
- Sem redesenho de layout; sem mudança de modelo/permissão (fora de escopo do item irmão).
- `editing === 'tudo'` não libera controles de coordenação (contas, `advisors`, nível E14, escaladas) — já role-gated; a passada não os abre.
- **O que reavaliar:** a hipótese da intenção dizia "predicados client-safe de perfil em `src/lib/campaignRoles.ts` (ou o helper do item irmão)". A realidade pós-C141: `campaignRoles.ts` é só papel (role); o helper do item irmão (`advisorEditingAccess`) é **server-only** (`access/shared.ts`); **nenhum componente conhece o perfil do próprio ator** (zero ocorrências de `editing`/`visibility` no front além da gestão de assessores). O front só recebe `role` + dados já escopados pelos loaders. Precisamos de um canal de apresentação do perfil — decisão D1 abaixo. Confirmou-se o precedente C116 (props `readOnly` derivadas na página server) como padrão a generalizar; confirmou-se que `tudo` já flui por dados (access-driven) — o mapa/contagens não precisam de mudança de dados, só de um texto de readout honesto.
## Abordagem recomendada
```mermaid
flowchart LR
P["Páginas server (requireCampaignPageActor) — user fresco"]
L["campaignAdvisorProfile.ts (client-safe)<br/>advisorEditingScope + rowEditingAllowed<br/>(spell único do eixo — server delega)"]
E["editingScope 'none|carteira|tudo' + portfolioIDs<br/>→ props (padrão C116/isStaffView)"]
C["Células/controles compartilhados ganham readOnly<br/>(estender dono — precedente B156)"]
B["Botões/forms/FAB/wizards:<br/>ocultar no call site (none) + gate 'writable' nas rotas"]
S["Server: advisorEditingAccess delega ao puro;<br/>requireCampaignPageActor ganha gate 'writable'"]
P --> E --> C
E --> B
S --> B
```
**Opções consideradas:**
- **D1 — canal do perfil ao front:** A (páginas server calculam `editingScope` + `portfolioIDs` e passam como props — padrão C116/`isStaffView`) | B (contexto/provider client serializando o perfil no layout) | C (boolean por controle, ex. `canEditVotes`/`canEditTrend`).
- **D2 — semântica do eixo no cliente:** A (funções puras client-safe em `campaignAdvisorProfile.ts`; `advisorEditingAccess` server delega para elas no ramo advisor) | B (duplicar a regra no front) | C (expor `visibility`/`editing` crus e cada call site interpretar).
- **D3 — células sem `readOnly`:** A (estender o dono com prop `readOnly`, precedente `RelationChipCell` B156) | B (não montar o controle e renderizar célula de leitura no call site) | C (wrapper novo).
- **D4 — gates de destino de escrita:** A (novo `gate: 'writable'` em `requireCampaignPageActor` + filtro dos municípios do wizard pelo writable scope) | B (gate inline por rota) | C (só esconder botões, rota continua acessível).
**Recomendação:**
- **D1 → A.** Páginas já têm o user fresco (`requireCampaignPageActor`) e o padrão de props de permissão é o do repo (C116, `municipios/page.tsx`). B cria abstração nova (provider+serialização) onde o canal props já chega a cada call site; C explode em N×M.
- **D2 → A.** O vocabulário do perfil já vive em `campaignAdvisorProfile.ts` (client-safe, C141). As funções puras `advisorEditingScope(v, e)` e `rowEditingAllowed(scope, portfolio, rowIds)` são o spell único; o server `advisorEditingAccess` delega (DRY de conhecimento, invariante P3-D — o fragmento de escopo continua vivendo uma vez).
- **D3 → A.** A célula sabe a apresentação de leitura dela; `readOnly` no dono é o precedente exato (B156/C116). Botões/forms (que não têm "célula") são ocultados no call site.
- **D4 → A.** O prologue centralizado (P3-I) ganha o gate; o wizard filtra os municípios oferecidos pelo `getWritableMunicipalityIds` (sem ele, um assessor `tudo · edita carteira` escolheria município fora da carteira → 403 — exatamente o anti-goal "nenhum caminho leva a 403").
**Rejeitadas:** D1-B (nova abstração de permissão no cliente onde o padrão props já domina; a convenção P3-D prefere o fragmento server-side com flags de página); D1-C (booleans por controle); D2-B (duplicação de regra com risco de deriva fail-open); D2-C (interpretação crua em N call sites); D3-B (célula duplicada por call site); D3-C (wrapper inútil); D4-B (gate inline reabre o espalhamento que o P3-I consolidou); D4-C (rota viva = caminho de escrita visível, viola a decisão A do gate).
### Componentes / mudanças
**Núcleo — `src/lib/campaignAdvisorProfile.ts`** (estender o dono do perfil)
- `advisorEditingScope(visibility: AdvisorVisibility | null | undefined, editing: AdvisorEditing | null | undefined): 'none' | 'carteira' | 'tudo'` — client-safe, espelha o ramo advisor do server `advisorEditingAccess` (undefined → `'carteira'`).
- `rowEditingAllowed(editingScope, portfolioIDs: readonly number[] | null, rowMunicipalityIDs: readonly number[] | null): boolean` — a regra por linha: `none` → false; `tudo` → true; `carteira` → interseção linha∩carteira (portfolio `null` = tudo editável). Usada por listas com visão ampla + edição de carteira (municípios, lideranças, pessoas, dobradinhas).
- **`src/utilities/access/shared.ts`**: `advisorEditingAccess` delega ao puro no ramo advisor (comportamento idêntico, teste pina).
**Prologues — `src/utilities/campaignPageActor.ts`**
- `CampaignPageGate` ganha `'writable'`: `advisorEditingAccess(user) === 'none'` → `redirect('/campanha')` (o mesmo denyRedirect default do P3-I). Aplicado em: `acoes/[slug]/page.tsx` (5 slugs — hoje 4 deles nem têm gate de staff), `municipios/[slug]/editar`, `liderancas/nova`, `demandas/nova`, `dobradinhas/nova`, `organizacoes/nova`, `atividades/giros`. (Register-demand mantém o gate `staff` existente; o gate `writable` é aditivo.)
- Rota do wizard (`acoes/[slug]`): para advisor, os municípios oferecidos no passo de busca passam por `getWritableMunicipalityIds` (null → todos; `[]` → redirect; ids → filtro) — o destino de escrita só oferece o que é gravável.
**Atalhos de escrita — FAB, home actions, busca**
- `src/lib/campaignHomeActions.ts`: `homeActionsForRole(role, editingScope)` e `resolveStaffHomeQuickActions(role, editingScope, returnPath)` — advisor `none` mantém só `uncovered-municipalities` (navegação); os 4 wizards somem. `(app)/page.tsx` e o registry passam o scope.
- `src/lib/campaignQuickActionMount.ts` + `CampaignQuickActionsHost`/`useQuickActionsChromeActive`: `shouldMountQuickActionsFab(pathname, role, editingScope)` — advisor `none` → `false` em superfícies staff (todas as ações do drawer são destinos de escrita; a superfície de leader não é afetada). O layout `(app)/layout.tsx` calcula `editingScope` uma vez (user fresco que ele já tem) e passa ao host/scroll chrome.
- Busca global: os modos `wizard-*` só são consumidos pelos passos de wizard (rotas gateadas) — sem mudança; verificado no sweep.
**Células/controles compartilhados — prop `readOnly` (D3-A)**
- `CampaignInlineEditableCell`: `readOnly` → renderiza a apresentação de leitura (valor + copy se `readBehavior='copy'`), sem lápis/input/permanent.
- `MunicipalityListTrendControl` / `MunicipalityListExpectedVotesControl` / `MunicipalityListLeadershipsControl` / `MunicipalityListUpdateControl` / `LeadershipListSupportStatusControl` / `MunicipalityV2DeclaredVotesCell` / `MunicipalityV2EstimatedVotesCell` / `MunicipalityV2LocalAccountSection` / `MunicipalityGoalAccountCard` (conta da cadeira): `readOnly` → badge/valor sem overlay/autosave/trigger.
- `MunicipalityStateDeputyRelationCell` / `LeadershipStateDeputyRelationCell` / `MunicipalityPortfolioCell` (call sites que não passam `readOnly` hoje): passar `readOnly` derivado.
- `PhonesFieldEditor`: `readOnly` (lista sem remover/↑↓/salvar).
- `DemandDescriptionEditor` / `DemandResponsiblesCard` / `DemandWorkflowCard`: `readOnly` → texto/badges sem "Editar", sem multi-select, sem botões de transição/custo/comprovante (o branch escalado segue role-gated).
- `SuggestionCard` / `SuggestionsPanel`: sem ações de decisão para `none`.
- `MunicipalityV2StatusStrip`: nível/tendência/atualização embutidos — `readOnly` (sem endpoints, sem overlay). (Nível continua role-gated para unrestricted.)
- `ActivityTaskChecklist`: checkboxes/inputs sem edição para `none`.
**Superfícies — páginas server calculam e passam (D1-A)**
- Padrão por página: `const editingScope = user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'`; `portfolioIDs = editingScope === 'carteira' ? await getAdvisorMunicipalityIds(payload, user.id) : null`.
- **Municípios** (`municipios/page.tsx`, `MunicipalityList`, `MunicipalityListMobileCards`/`MunicipalityMobileCard`): colunas tendência/votos/lideranças/dobradinhas/atualização por-linha (`rowEditingAllowed(scope, portfolio, [row.id])`); `MunicipalityListUpdateControl` oculto para `none`; detail (`MunicipalityDetailTabs`): `canEdit` deixa de ser `true` hardcoded (linha 91) → flag da página (botão "Editar" + `MunicipalityUpdateForm` + `PledgeEstimateForm` + `DeclareVotesForm` + sugestões); página `[slug]/editar` com gate `writable` + checagem do município (`getWritableMunicipalityIds` contém o id, senão redirect).
- **v2** (`municipio/[slug]/v2`): `StatusStrip`/`LocalAccountSection`/`NetworkSection` com `readOnly`/ocultos conforme scope.
- **Lideranças** (lista + `[id]`): células (`CampaignInlineEditableCell`), status, municípios, dobradinhas, convites (ocultar para `none`), "Nova liderança" (ocultar para `none`); `[id]` — contato/ficha interna `readOnly`, convite oculto.
- **Pessoas/Contatos**: matriz `buildPeopleEditability` (C116) AND `editingScope !== 'none'`; células text `readOnly` para `none`; convites ocultos; `ContactCreateRow`/`ContactCreateFab` ocultos para `none` (contatos).
- **Dobradinhas**: células text/`MunicipalityPortfolioCell`/`LeadershipStateDeputyRelationCell` `readOnly` para `none` + por-linha (`rowEditingAllowed`) quando `tudo · edita carteira`.
- **Demandas**: "Nova demanda" oculto para `none`; detail — descrição/workflow/responsáveis `readOnly`.
- **Atividades**: lista — "Nova atividade"/"Planejar giro" ocultos para `none`; detail — overlay de edição, "Nova atualização", lifecycle, checklist, update form, "Adicionar demanda", result form ocultos/`readOnly` para `none`.
- **Atualizações** (`atualizacoes`): modal de criação (`CampaignUpdatesFilters`) oculto para `none`.
- **Apoiadores**: "Novo" oculto; `PhonesFieldEditor`/`VoteIntentionControl` `readOnly`; `RemoveSupporterDataButton` oculto (o server exige admin; a UI não pode oferecer caminho morto).
- **Organizações**: "Nova organização" oculto para `none`; verificar o form do detalhe `organizacoes/[slug]` no sweep (mesma regra).
- **Quadro/Início**: `CampaignHomeActions` filtrado (acima); `SuggestionsPanel` sem ações de decisão para `none`.
- **Mapa** (`MunicipalityMapPanel`): o dado já é scope-coerente (access-driven); trocar `actorRole` → `actorScope: 'carteira' | 'tudo'` derivado (`visibility === 'tudo' || isUnrestrictedCampaignRole(role)` → `'tudo'`), para o readout/legenda ("no seu escopo") dizer a verdade quando o assessor vê tudo.
### Dados → forma (se aplicável)
- Sem KPI/dado novo: passada de apresentação. A "forma" aqui é a regra de apresentação (controles presentes/ausentes) — decidida no gate (opção B: ausência é a linguagem; sem aviso). Textos novos pt-BR apenas onde um controle some e o valor precisa de apresentação de leitura (ex.: placeholder de célula).
## Fases verificáveis
1. **Núcleo** (~0,25d): funções puras em `campaignAdvisorProfile.ts` + delegação do `advisorEditingAccess` + `gate: 'writable'` + unit specs (`advisorEditingScope`, `rowEditingAllowed`, gate). `tsc` + `pnpm test` verdes.
2. **Células compartilhadas** (~0,5d): `readOnly` nos controles listados; unit specs dos que têm comportamento condicional.
3. **Superfície municípios + mapa** (~0,5d): lista (desktop/mobile) + detail + v2 + editar + readout do mapa. Verificar visualmente no browser (shape→craft).
4. **Pessoas/contatos/lideranças/dobradinhas** (~0,5d): matriz AND + células + convites + criação.
5. **Demandas/atividades/atualizações/apoiadores/organizações/sugestões/giros** (~0,5d): ocultar/`readOnly` + gates das rotas de criação.
6. **Atalhos** (~0,25d): home actions, FAB, wizard gate + busca writable-scoped.
7. **E2E + gates** (~0,5d): spec novo `campaignPermissionProfile.e2e.spec.ts` (3 perfis: somente_leitura sem controles em municipios/lideranças/demandas/atividades + FAB ausente + wizard redireciona; tudo·edita carteira com linhas fora da carteira read-only; tudo·edita tudo com controles) usando `campaignFixtures` (já suporta `visibility`/`editing`); rodar e2e afetado local; `pnpm gate:fast` + `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Redesenho de qualquer superfície (layout intacto; só presença/ausência).
- Banner/aviso de modo leitura (decisão B do gate).
- Mudar o modelo/enforcement (C141 dono) — a passada só apresenta.
- `saveToJWT` do perfil — desnecessário: páginas leem user fresco.
- Gate de per-município em TODAS as rotas de detail — só `[slug]/editar` e o wizard precisam; os demais detalhes apresentam read-only (o servidor já rejeita escrita fora do escopo).
- Auditoria de quem viu o quê; relatório de acessibilidade extra (a11y P3 do controle oculto segue o padrão existente).
## Riscos e mitigação
- **Ripple de assinatura** (`role` → `role + editingScope`) em shells client (FAB, home actions, map): mitigar com default `'tudo'`/parametros opcionais onde não-advisor; unit specs pina o comportamento por scope.
- **Caminho de escrita esquecido** (o sweep é o cerne do item): inventário por categoria já feito (autosave/sheets/FAB/wizards/forms/status); e2e pin por superfície (municipios/lideranças/demandas/atividades/home) cobre os principais; o resto aparece no aceite do item (rabbit hole da intenção) — registrar débito se escapar.
- **Matriz C116 quebrada para coordinator/candidate**: o AND só afeta advisor com `none`; unrestricted permanece idêntico (unit spec da matriz? a matriz vive na página — cobrir via e2e).
- **Giro/`atividades/giros`**: gate `writable` + `getWritableMunicipalityIds` já usado nas actions (C141) — só o gate de rota falta; teste e2e opcional.
- **Mapa com quantis**: os quantis já seguem o escopo real (access); só o texto muda — int spec existente (`bahiaGeometries`/map bundle) continua passando; verificar o readout no browser.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (sem controle de escrita para `somente_leitura` em nenhuma superfície staff; visão `tudo` coerente; nada muda para os demais perfis/roles)
- [ ] Invariantes AGENTS/engineering-standards: sem `overrideAccess: true` novo; fragmento de escopo continua uma vez só (P3-D); identificadores em inglês, copy pt-BR; props como canal de permissão (padrão C116), sem provider novo
- [ ] Testes de domínio: unit dos helpers puros + gate `writable`; e2e novo por perfil/superfície; gates completos (`tsc`, lint, format, knip, cycles, unit+int, build)

View file

@ -19,6 +19,7 @@ import {
} from '@/components/campaign/supporter/SupporterList'
import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview'
import { Button } from '@/components/ui/button'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
@ -42,6 +43,11 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
if (!canAccessSupporterArea(user.role)) redirect('/campanha')
// C142 — the write scope gates the "Novo" and "Importar CSV" buttons.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const canCreate = editingScope !== 'none'
const rawSearchParams = await searchParams
const now = new Date()
const { result, state, redirectHref, municipalityOptions, overview } =
@ -85,7 +91,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
return (
<CampaignPageShell>
<div className="flex flex-col gap-2 pt-4 sm:flex-row sm:justify-end md:pt-0">
{isCampaignCoordinator(user) ? (
{isCampaignCoordinator(user) && canCreate ? (
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores/importar">
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
@ -93,12 +99,14 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
</Link>
</Button>
) : null}
<Button asChild className="min-h-11">
<Link href="/campanha/apoiadores/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo
</Link>
</Button>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/apoiadores/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo
</Link>
</Button>
) : null}
</div>
<CampaignListPendingBoundary>

View file

@ -15,6 +15,7 @@ import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignLis
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { Activity } from '@/payload-types'
import { loadAccessibleActivityTags, loadActivityListPageData } from '@/utilities/activityPageData'
@ -48,6 +49,10 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
getPayload({ config }),
])
// C142 — the write scope gates the create buttons (Planejar giro, Nova atividade).
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const now = new Date()
const [{ result, state }, municipalityOptions, organizationOptions, knownTags] =
await Promise.all([
@ -59,7 +64,7 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const canCreate = isCampaignStaff(user)
const canCreate = isCampaignStaff(user) && editingScope !== 'none'
return (
<CampaignPageShell>

View file

@ -15,6 +15,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
@ -103,6 +104,11 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
getPayload({ config }),
])
// C142 — the write scope gates the "Nova demanda" buttons.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const canCreate = editingScope !== 'none'
const state = parseDemandListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadDemandListPageData(payload, user, state)
const columnVisibility = await readCampaignColumnVisibility('demandas')
@ -110,12 +116,14 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
return (
<CampaignPageShell>
<div className="flex justify-end pt-4 md:pt-0">
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
) : null}
</div>
<CampaignListPendingBoundary>
@ -141,12 +149,14 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
title="Nenhuma demanda por aqui"
description="Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação."
>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
) : null}
</CampaignListEmptyState>
}
/>

View file

@ -37,6 +37,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
@ -301,8 +302,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
const isLeadershipVisible = !columnVisibility.hiddenColumnIds.includes('leaderships')
const isAdvisorsVisible = !columnVisibility.hiddenColumnIds.includes('advisors')
// Advisor assignment is unrestricted-only (B156); the rest of staff reads.
const canEditAdvisors = isCampaignUnrestricted(user)
// C142 — the write scope gates the create button and the advisors column.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const canEditAdvisors = isCampaignUnrestricted(user) && editingScope !== 'none'
const [
{ rows, totalDocs, totalPages, filterFacets },
@ -356,12 +359,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
return (
<CampaignPageShell>
<div className="flex justify-end pt-4 md:pt-0">
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
{editingScope === 'none' ? null : (
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
)}
</div>
<CampaignListPendingBoundary>

View file

@ -28,6 +28,7 @@ import { CampaignWizardChromeProvider } from '@/components/campaign/shell/Campai
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar'
import { TooltipProvider } from '@/components/ui/tooltip'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
@ -56,6 +57,12 @@ export default async function CampaignAppLayout({ children }: { children: React.
// browser can answer, so the island finishes the decision.
const relyingParty = await resolveCampaignWebAuthnRelyingParty()
const isStaff = isStaffCampaignRole(user.role)
// C142 — the write scope is resolved once and threaded to the Chrome shell
// (FAB mount rule) and to every page via the user prop.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
let biometricEnrollment: BiometricEnrollmentOffer | null = null
let pushOptIn: CampaignPushOptInOffer | null = null
if (relyingParty || isStaff) {
@ -112,7 +119,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
*/}
<TooltipProvider delayDuration={300}>
<div className="flex min-h-0 flex-1 flex-col">
<CampaignAppScrollChrome role={user.role}>
<CampaignAppScrollChrome role={user.role} editingScope={editingScope}>
{children}
</CampaignAppScrollChrome>
</div>

View file

@ -41,6 +41,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
@ -92,7 +93,13 @@ type LeadershipsPageProps = {
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
const LeadershipListEmptyState = ({ state }: { state: LeadershipListState }) => (
const LeadershipListEmptyState = ({
state,
canEditAny = true,
}: {
state: LeadershipListState
canEditAny?: boolean
}) => (
<Empty className="min-h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
@ -113,12 +120,14 @@ const LeadershipListEmptyState = ({ state }: { state: LeadershipListState }) =>
>
Limpar busca e filtros
</CampaignTransitionAnchor>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
{canEditAny ? (
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
) : null}
</EmptyContent>
</Empty>
)
@ -129,6 +138,7 @@ const leadershipColumns = ({
municipalityIndex,
municipalityFilterOptions,
addableMunicipalityIds,
canEditAny = true,
nowMs,
}: {
state: LeadershipListState
@ -137,6 +147,8 @@ const leadershipColumns = ({
municipalityFilterOptions: LeadershipFilterOption[]
/** Advisors may only link municipalities they administer; staff: undefined. */
addableMunicipalityIds?: ReadonlySet<number>
/** C142 — false when the advisor has `somente_leitura`: no invite, no per-row edits. */
canEditAny?: boolean
nowMs: number
}): Array<CampaignTableColumn<LeadershipRowViewModel>> => [
{
@ -300,11 +312,13 @@ const leadershipColumns = ({
const whatsAppHref = whatsAppHrefForPhone(row.phone)
return (
<div className="inline-flex items-center justify-end gap-1">
<LeadershipInviteRowAction
leadershipID={row.id}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
{canEditAny ? (
<LeadershipInviteRowAction
leadershipID={row.id}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
) : null}
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
@ -358,6 +372,12 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
// C142 — the write scope gates the create button, the invite action, and
// per-row editability (municipality/dobradinha chips).
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const canEditAny = editingScope !== 'none'
const resolvedUrl = resolveLeadershipListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
const { state } = resolvedUrl
@ -411,19 +431,22 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
})),
municipalityIndex,
municipalityFilterOptions,
...(administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}),
...(canEditAny && administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}),
canEditAny,
nowMs: Date.now(),
})
return (
<CampaignPageShell>
<div className="flex justify-end pt-4 md:pt-0">
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
{canEditAny ? (
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
) : null}
</div>
<CampaignListPendingBoundary>
@ -450,7 +473,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
empty={<LeadershipListEmptyState state={state} canEditAny={canEditAny} />}
/>
</CampaignListSheetProvider>
{rows.length ? (

View file

@ -72,9 +72,12 @@ export const MunicipalityTabFallback = () => (
export const OverviewTab = async ({
view,
payloadUser: { payload, user },
canEdit = true,
}: {
view: MunicipalityDetailView
payloadUser: PayloadUser
/** C142 — false when the advisor has `somente_leitura` or the municipality is outside the portfolio. */
canEdit?: boolean
}) => {
const zoneNeighborhoodsCard =
view.kind === 'zona' ? <MunicipalityZoneNeighborhoodsCard municipalitySlug={view.slug} /> : null
@ -88,7 +91,11 @@ export const OverviewTab = async ({
{zoneNeighborhoodsCard}
{view.strategy ? (
<>
<MunicipalityStrategyCard strategy={view.strategy} municipalitySlug={view.slug} canEdit />
<MunicipalityStrategyCard
strategy={view.strategy}
municipalitySlug={view.slug}
canEdit={canEdit}
/>
<GoalAccountCard
payloadUser={{ payload, user }}
municipalityID={view.id}
@ -96,6 +103,7 @@ export const OverviewTab = async ({
expectedVotes={view.strategy.expectedVotes}
pledgeCoverage={pledgeCoverage}
pledgeAggregate={pledgeAggregate}
readOnly={!canEdit}
/>
{/* Two round trips deep (território scope, then its aggregates), against
one for the goal account — so it streams instead of holding the tab. */}
@ -119,11 +127,19 @@ export const OverviewTab = async ({
/>
}
>
<SuggestionsCard payloadUser={{ payload, user }} municipalityID={view.id} />
<SuggestionsCard
payloadUser={{ payload, user }}
municipalityID={view.id}
readOnly={!canEdit}
/>
</Suspense>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
<MunicipalityPledgesPanel
pledges={pledges}
estimateFormAction={estimateVotesFormAction}
readOnly={!canEdit}
/>
</div>
)
}
@ -136,9 +152,11 @@ export const OverviewTab = async ({
const SuggestionsCard = async ({
payloadUser: { payload, user },
municipalityID,
readOnly = false,
}: {
payloadUser: PayloadUser
municipalityID: number
readOnly?: boolean
}) => {
const bundle = await loadMunicipalitySuggestions(payload, user, { municipalityID })
const silence = bundle.silence.find((entry) => entry.municipalityID === municipalityID)
@ -149,6 +167,7 @@ const SuggestionsCard = async ({
suggestions={bundle.suggestions}
activeCount={bundle.suggestions.length}
resolveAction={resolveSuggestionFormAction}
readOnly={readOnly}
emptyState={
<p className="text-sm text-muted-foreground">
{formatMunicipalitySuggestionEmptyMessage(
@ -167,6 +186,7 @@ const GoalAccountCard = async ({
expectedVotes,
pledgeCoverage,
pledgeAggregate,
readOnly = false,
}: {
payloadUser: PayloadUser
municipalityID: number
@ -174,6 +194,7 @@ const GoalAccountCard = async ({
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
readOnly?: boolean
}) => {
const { suggestedGoal, goalCoverage, potential, territorialClass, territoryCaptureBenchmark } =
await loadMunicipalityGoalAccount(payload, user, { slug, expectedVotes }, pledgeAggregate)
@ -188,6 +209,7 @@ const GoalAccountCard = async ({
potential={potential}
territorialClass={territorialClass}
territoryCaptureBenchmark={territoryCaptureBenchmark}
readOnly={readOnly}
/>
)
}
@ -309,9 +331,12 @@ const TicketPartnersSection = async ({
export const LeadershipsTab = async ({
municipalityID,
payloadUser: { payload, user },
readOnly = false,
}: {
municipalityID: number
payloadUser: PayloadUser
/** C142 — the declare-votes form and the create link are write affordances. */
readOnly?: boolean
}) => {
const [leaderships, pledges] = await Promise.all([
loadMunicipalityLeaderships(payload, user, municipalityID),
@ -324,6 +349,7 @@ export const LeadershipsTab = async ({
leaderships={leaderships}
pledges={pledges}
declareFormAction={declareVotesFormAction}
readOnly={readOnly}
/>
)
}
@ -333,11 +359,14 @@ export const UpdatesTab = async ({
municipalitySlug,
rawSearchParams,
payloadUser: { payload, user },
readOnly = false,
}: {
municipalityID: number
municipalitySlug: string
rawSearchParams: MunicipalityDetailSearchParams
payloadUser: PayloadUser
/** C142 — the "Registrar atualização" form is a write affordance; hide it in read-only. */
readOnly?: boolean
}) => {
const feedState = parseMunicipalityUpdateFeedParams(rawSearchParams)
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, feedState)
@ -345,11 +374,13 @@ export const UpdatesTab = async ({
return (
<div className="flex flex-col gap-6">
<MunicipalityUpdateForm
municipalityID={municipalityID}
formAction={createMunicipalityUpdateFormAction}
isStaff={isStaff}
/>
{readOnly ? null : (
<MunicipalityUpdateForm
municipalityID={municipalityID}
formAction={createMunicipalityUpdateFormAction}
isStaff={isStaff}
/>
)}
<MunicipalityUpdateFeed updates={feed.updates} />
<CampaignListPagination
page={feed.page}

View file

@ -9,6 +9,7 @@ import { MunicipalityStrategyForm } from '@/components/campaign/municipality/Mun
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { rowEditingAllowed } from '@/lib/campaignAdvisorProfile'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
@ -16,6 +17,7 @@ import {
getMunicipalityDetailViewModel,
MunicipalityNotFoundError,
resolveAccessibleMunicipalityContext,
resolveMunicipalityWriteScope,
} from '@/utilities/municipality/municipalityPageData'
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
import {
@ -33,7 +35,7 @@ export async function generateMetadata({ params }: MunicipalityEditPageProps) {
const { slug } = await params
const payload = await getPayload({ config })
const user = await requireCampaignPageActor({
gate: 'staff',
gate: 'writable',
redirectTo: `/campanha/municipios/${slug}`,
})
@ -49,7 +51,7 @@ export async function generateMetadata({ params }: MunicipalityEditPageProps) {
export default async function MunicipalityEditPage({ params }: MunicipalityEditPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: `/campanha/municipios/${slug}` }),
requireCampaignPageActor({ gate: 'writable', redirectTo: `/campanha/municipios/${slug}` }),
getPayload({ config }),
])
@ -62,6 +64,18 @@ export default async function MunicipalityEditPage({ params }: MunicipalityEditP
}
const view = await getMunicipalityDetailViewModel(payload, context, user)
// C142 — an advisor with `carteira` editing scope may only edit
// administered municipalities. A read-only advisor is already blocked by
// the page-level `gate: 'writable'`, but a carteira advisor must not
// reach a municipality outside the portfolio via a direct URL.
const writeScope = await resolveMunicipalityWriteScope(payload, user)
const canEditRow = rowEditingAllowed(
writeScope.editingScope,
writeScope.portfolioIDs ? [...writeScope.portfolioIDs] : null,
[view.id],
)
if (!canEditRow) redirect(`/campanha/municipios/${slug}`)
if (!view.strategy) redirect(`/campanha/municipios/${slug}`)
const advisorOptions =

View file

@ -8,6 +8,7 @@ import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitT
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { rowEditingAllowed } from '@/lib/campaignAdvisorProfile'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { isCitySlug, salvadorCity } from '@/lib/salvadorCity'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -21,6 +22,7 @@ import {
getMunicipalityDetailViewModel,
MunicipalityNotFoundError,
resolveAccessibleMunicipalityContext,
resolveMunicipalityWriteScope,
} from '@/utilities/municipality/municipalityPageData'
import { loadAdvisorSummaries } from '@/utilities/municipality/municipalityViewModels'
@ -122,6 +124,17 @@ export default async function MunicipalityDetailPage({
const activeTab = resolveMunicipalityDetailTab(rawSearchParams)
const view = await getMunicipalityDetailViewModel(payload, context, user)
// C142 — the write scope gates write affordances in the detail tabs
// (strategy card, expected-votes account, update form, leadership panel)
// and the edit page. An advisor with `somente_leitura` sees everything
// as read-only; `carteira` scopes writes to administered municipalities.
const writeScope = await resolveMunicipalityWriteScope(payload, user)
const canEditRow = rowEditingAllowed(
writeScope.editingScope,
writeScope.portfolioIDs ? [...writeScope.portfolioIDs] : null,
[view.id],
)
const advisorSummaries = await loadAdvisorSummaries(payload, user, view.advisorIDs)
return (
@ -140,7 +153,7 @@ export default async function MunicipalityDetailPage({
/>
{activeTab === 'overview' ? (
<OverviewTab view={view} payloadUser={{ payload, user }} />
<OverviewTab view={view} payloadUser={{ payload, user }} canEdit={canEditRow} />
) : null}
{activeTab === 'dossie' ? (
@ -169,7 +182,11 @@ export default async function MunicipalityDetailPage({
{activeTab === 'leaderships' ? (
<Suspense fallback={<MunicipalityTabFallback />}>
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
<LeadershipsTab
municipalityID={view.id}
payloadUser={{ payload, user }}
readOnly={!canEditRow}
/>
</Suspense>
) : null}
@ -180,6 +197,7 @@ export default async function MunicipalityDetailPage({
municipalitySlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
readOnly={!canEditRow}
/>
</Suspense>
) : null}

View file

@ -38,7 +38,10 @@ import {
buildMunicipalityListHref,
resolveMunicipalityListUrl,
} from '@/utilities/municipality/municipalityListUrl'
import { loadMunicipalityListPageBundle } from '@/utilities/municipality/municipalityPageData'
import {
loadMunicipalityListPageBundle,
resolveMunicipalityWriteScope,
} from '@/utilities/municipality/municipalityPageData'
import {
getEligibleAdvisorOptions,
getEligibleLeadershipOptions,
@ -69,6 +72,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
const isCoordinator = isCampaignCoordinator(user)
const canMoveEngagementLevel = isCampaignUnrestricted(user)
// C142 — the write scope for the row controls (expectedVotes, trend,
// leaderships, stateDeputies, updates): 'none' renders everything read-only.
const writeScope = await resolveMunicipalityWriteScope(payload, user)
const pageBundle = await loadMunicipalityListPageBundle(payload, user, rawSearchParams)
const {
municipalities: listMunicipalities,
@ -184,6 +191,8 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
stateDeputyCreateAction={createMunicipalityStateDeputyFormAction}
columnFilterOptions={columnFilterOptions}
signalFormAction={createMunicipalityListUpdateFormAction}
editingScope={writeScope.editingScope}
portfolioIDs={writeScope.portfolioIDs}
state={state}
columnVisibility={columnVisibility}
/>

View file

@ -15,6 +15,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { organizationKindLabels } from '@/lib/schemas/organization'
@ -73,6 +74,11 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
getPayload({ config }),
])
// C142 — the write scope gates the "Nova organização" buttons.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const canCreate = editingScope !== 'none'
const state = parseOrganizationListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state)
const columnVisibility = await readCampaignColumnVisibility('organizacoes')
@ -80,12 +86,14 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
return (
<CampaignPageShell>
<div className="flex justify-end pt-4 md:pt-0">
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
) : null}
</div>
<CampaignListPendingBoundary>
@ -111,12 +119,14 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
title="Nenhuma organização cadastrada"
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
{canCreate ? (
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
) : null}
</CampaignListEmptyState>
}
/>

View file

@ -7,6 +7,7 @@ import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHome
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { UNCOVERED_MUNICIPALITIES_LIST_HREF } from '@/lib/campaignHomeActions'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
@ -23,11 +24,16 @@ export default async function CampaignHomePage() {
const staff = isStaffCampaignRole(user.role)
// C142 — the write scope filters the home actions for advisors.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const uncoveredMunicipalitiesHref = staff ? UNCOVERED_MUNICIPALITIES_LIST_HREF : undefined
const actions = (
<CampaignHomeActions
role={user.role}
editingScope={editingScope}
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
/>
)

View file

@ -39,6 +39,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
@ -144,8 +145,10 @@ type PeopleEditability = {
const buildPeopleEditability = (
userRole: CampaignUser['role'],
administered: ReadonlySet<number> | null,
editingScope: AdvisorEditingScope = 'tudo',
): PeopleEditability => {
const unrestricted = isUnrestrictedCampaignRole(userRole)
const none = editingScope === 'none'
const inCarteira = (ids: readonly number[]): boolean =>
administered !== null && ids.some((id) => administered.has(id))
return {
@ -153,12 +156,14 @@ const buildPeopleEditability = (
// C128: a person without the capacity yet is editable (the first
// municipality creates the entity); with one, the row's own municipalities
// must cross the carteira (the chips outside it do not render anyway).
// C142: `somente_leitura` (none) blocks all edits regardless of role.
canEditLidera: (row) =>
unrestricted || row.leadershipID === null || inCarteira(row.leadershipMunicipalityIDs),
!none &&
(unrestricted || row.leadershipID === null || inCarteira(row.leadershipMunicipalityIDs)),
canEditAliada: (row) =>
unrestricted || row.deputyID === null || inCarteira(row.deputyMunicipalityIDs),
canEditAssessora: (row) => unrestricted && row.staff.length <= 1,
canEditAssessorado: unrestricted,
!none && (unrestricted || row.deputyID === null || inCarteira(row.deputyMunicipalityIDs)),
canEditAssessora: (row) => !none && unrestricted && row.staff.length <= 1,
canEditAssessorado: !none && unrestricted,
}
}
@ -337,7 +342,7 @@ const peopleColumns = ({
const whatsAppHref = whatsAppHrefForPhone(row.phone)
return (
<div className="inline-flex items-center justify-end gap-1">
{row.leadershipID !== null ? (
{row.leadershipID !== null && editability.canEditLidera(row) ? (
// C130 — the span wrapper keeps the hover tooltip reachable when
// the button is disabled (disabled buttons swallow pointer events).
<CampaignHoverTooltip
@ -406,11 +411,13 @@ const PeopleMobileCards = ({
rows,
municipalityIndex,
canDelete,
editability,
empty,
}: {
rows: readonly PeopleRowViewModel[]
municipalityIndex: ReadonlyMap<number, ResolvedPortfolioEntry>
canDelete: boolean
editability: PeopleEditability
empty: ReactNode
}) => (
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
@ -441,7 +448,7 @@ const PeopleMobileCards = ({
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{row.leadershipID !== null ? (
{row.leadershipID !== null && editability.canEditLidera(row) ? (
<LeadershipInviteRowAction
leadershipID={row.leadershipID}
name={row.name}
@ -536,9 +543,15 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
loadMunicipalityPortfolioIndex(),
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
])
// C142 — the write scope gates all inline edit affordances in the people list.
const editingScope: AdvisorEditingScope =
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const editability = buildPeopleEditability(
user.role,
administeredIds ? new Set(administeredIds) : null,
editingScope,
)
const resolvedUrl = resolvePeopleListUrl(rawSearchParams, listData.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@ -608,6 +621,7 @@ export default async function PeoplePage({ searchParams }: PeoplePageProps) {
rows={listData.rows}
municipalityIndex={resolvedIndex}
canDelete={canDelete}
editability={editability}
empty={
<PeopleListEmptyState
hasFilters={hasFilters}

View file

@ -10,9 +10,15 @@ import type { ActivityTaskViewModel } from '@/utilities/activityViewModels'
type ActivityTaskChecklistProps = {
activityId: number
tasks: ActivityTaskViewModel[]
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): tasks render with a static done indicator, no toggle. */
readOnly?: boolean
}
export const ActivityTaskChecklist = ({ activityId, tasks }: ActivityTaskChecklistProps) => {
export const ActivityTaskChecklist = ({
activityId,
tasks,
readOnly = false,
}: ActivityTaskChecklistProps) => {
const [isPending, startTransition] = useTransition()
const [optimisticTasks, setOptimisticDone] = useOptimistic(
tasks,
@ -45,13 +51,33 @@ export const ActivityTaskChecklist = ({ activityId, tasks }: ActivityTaskCheckli
data-done={task.done}
data-pending={isPending || undefined}
>
<Checkbox
checked={task.done}
disabled={isPending}
onCheckedChange={(checked) => toggle(task.id, checked === true)}
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"
/>
{readOnly ? (
<span
aria-hidden="true"
className={`mt-1.5 inline-flex size-4 shrink-0 items-center justify-center rounded border ${
task.done ? 'border-primary bg-primary text-primary-foreground' : 'border-input'
}`}
>
{task.done ? (
<svg viewBox="0 0 24 24" className="size-3" fill="none" stroke="currentColor">
<path
d="M20 6 9 17l-5-5"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
</span>
) : (
<Checkbox
checked={task.done}
disabled={isPending}
onCheckedChange={(checked) => toggle(task.id, checked === true)}
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex-col gap-0.5">
<span
className={`break-words ${task.done ? 'line-through text-muted-foreground' : ''}`}

View file

@ -1,18 +1,22 @@
'use client'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignHomeActions = ({
role,
editingScope = 'tudo',
uncoveredMunicipalitiesHref,
}: {
role: CampaignRole
/** C142 — advisors with `somente_leitura` see only navigation actions. */
editingScope?: AdvisorEditingScope
uncoveredMunicipalitiesHref?: string
}) => {
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
const actions = toHomeActionButtonProps(homeActionsForRole(role, editingScope), {
uncoveredMunicipalitiesHref,
returnPath: CAMPAIGN_HOME,
})

View file

@ -22,6 +22,8 @@ type DemandDescriptionEditorProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the description renders with no edit affordance. */
readOnly?: boolean
}
/**
@ -33,6 +35,7 @@ export const DemandDescriptionEditor = ({
demandId,
initialDescription,
formAction,
readOnly = false,
}: DemandDescriptionEditorProps) => {
const router = useRouter()
const [editing, setEditing] = useState(false)
@ -48,9 +51,11 @@ export const DemandDescriptionEditor = ({
<section aria-label="Descrição da demanda" className="rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<p className="whitespace-pre-wrap text-sm">{initialDescription}</p>
<Button type="button" variant="ghost" onClick={() => setEditing(true)}>
{initialDescription ? 'Editar' : 'Adicionar descrição'}
</Button>
{readOnly ? null : (
<Button type="button" variant="ghost" onClick={() => setEditing(true)}>
{initialDescription ? 'Editar' : 'Adicionar descrição'}
</Button>
)}
</div>
</section>
)

View file

@ -23,6 +23,8 @@ type DemandResponsiblesCardProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the responsibles render as chips with no editor. */
readOnly?: boolean
}
/**
@ -38,6 +40,7 @@ export const DemandResponsiblesCard = ({
creatorUserId,
initialResponsibles,
formAction,
readOnly = false,
}: DemandResponsiblesCardProps) => {
const [state, submitAction, isPending] = useActionState(formAction, {})
const [dirty, setDirty] = useState(false)
@ -48,6 +51,29 @@ export const DemandResponsiblesCard = ({
const search = (query: string) => searchDemandResponsibleOptions(query, municipalityId)
if (readOnly) {
return (
<section className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">{CAMPAIGN_DEMAND_RESPONSIBLES_LABEL}</h2>
<p className="text-sm text-muted-foreground">Só quem é responsável vê esta demanda.</p>
</div>
{initialResponsibles.length > 0 ? (
<ul className="flex flex-wrap gap-1.5">
{initialResponsibles.map((responsible) => (
<li key={responsible.id} className="text-sm">
{responsible.name}
{responsible.id === creatorUserId ? ' (criador)' : ''}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">Sem responsáveis.</p>
)}
</section>
)
}
return (
<form action={submitAction} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex flex-col gap-1">

View file

@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import {
campaignDemandStatusLabels,
campaignDemandTransitionLabels,
campaignDemandTransitions,
type CampaignDemandStatus,
@ -30,6 +31,8 @@ type DemandWorkflowCardProps = {
transitionFormAction: FormAction
costFormAction: FormAction
receiptFormAction: FormAction
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the workflow renders as status-only, no transitions/cost/receipt. */
readOnly?: boolean
}
const transitionVariant = (target: CampaignDemandStatus) =>
@ -44,6 +47,7 @@ export const DemandWorkflowCard = ({
transitionFormAction,
costFormAction,
receiptFormAction,
readOnly = false,
}: DemandWorkflowCardProps) => {
const [transitionState, submitTransition, transitionPending] = useActionState(
transitionFormAction,
@ -59,6 +63,28 @@ export const DemandWorkflowCard = ({
(target !== 'aprovada' && target !== 'rejeitada'),
)
if (readOnly) {
return (
<section
aria-labelledby="demand-workflow-title"
className="flex flex-col gap-6 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="demand-workflow-title" className="text-base font-medium">
Tratar demanda
</h2>
<p className="text-sm text-muted-foreground">
O assessor analisa e decide — ou escala ao Coordenador Geral. Demandas escaladas são
decididas somente por ele.
</p>
</div>
<p className="text-sm text-muted-foreground">
Status: {campaignDemandStatusLabels[status]}
</p>
</section>
)
}
return (
<section
aria-labelledby="demand-workflow-title"

View file

@ -23,11 +23,14 @@ const DEFAULT_STATUS: SupportStatus = 'a_abordar'
type LeadershipListSupportStatusControlProps = {
leadershipID: number
status: SupportStatus | null
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the status badge renders with no editor overlay. */
readOnly?: boolean
}
export const LeadershipListSupportStatusControl = ({
leadershipID,
status,
readOnly = false,
}: LeadershipListSupportStatusControlProps) => {
const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<SupportStatus, LeadershipListSupportStatusResponse>({
@ -45,6 +48,11 @@ export const LeadershipListSupportStatusControl = ({
change(raw, STATUS_AUTOSAVE_MS)
}
// C142 — read-only: the status badge renders with no editor overlay.
if (readOnly) {
return <SupportStatusBadge status={value} />
}
return (
// The leaderships list is table-only, so this stays a Popover at every
// viewport — the shell is here for the trigger and content it already owns,

View file

@ -93,6 +93,7 @@ export const MunicipalityGoalAccountCard = ({
potential,
territorialClass,
territoryCaptureBenchmark,
readOnly = false,
}: {
municipalityID: number
expectedVotes: VoteEstimateScenarioViewModel
@ -102,6 +103,8 @@ export const MunicipalityGoalAccountCard = ({
potential: MunicipalityPotential
territorialClass: MunicipalityTerritorialClassification
territoryCaptureBenchmark: MunicipalityIntraTerritoryCaptureBenchmark
/** C142 — read-only presentation (advisor with Edição `somente_leitura`). */
readOnly?: boolean
}) => {
const usesMesaEstimate = expectedVotes.central != null
const latestIntraFieldShareYear = Math.max(
@ -168,6 +171,7 @@ export const MunicipalityGoalAccountCard = ({
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
variant="popover"
readOnly={readOnly}
/>
<p className="mt-1 text-xs text-muted-foreground">
Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} (

View file

@ -17,6 +17,8 @@ type MunicipalityLeadershipsPanelProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): no create link, no declare form. */
readOnly?: boolean
}
/** Staff-only: leaderships linked to this municipality + declare-on-behalf form. */
@ -25,6 +27,7 @@ export const MunicipalityLeadershipsPanel = ({
leaderships,
pledges,
declareFormAction,
readOnly = false,
}: MunicipalityLeadershipsPanelProps) => {
const pledgeByLeadership = new Map(pledges.map((pledge) => [pledge.leadershipID, pledge]))
@ -39,12 +42,14 @@ export const MunicipalityLeadershipsPanel = ({
Uma liderança pode atuar em vários municípios e organizações.
</p>
</div>
<Button asChild className="min-h-11">
<Link href={`/campanha/liderancas/nova?municipality=${municipalityID}`}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
{readOnly ? null : (
<Button asChild className="min-h-11">
<Link href={`/campanha/liderancas/nova?municipality=${municipalityID}`}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
)}
</div>
{leaderships.length ? (
@ -78,12 +83,14 @@ export const MunicipalityLeadershipsPanel = ({
</span>
) : null}
</div>
<DeclareVotesForm
municipalityID={municipalityID}
leadershipID={leadership.id}
currentDeclaredVotes={pledge?.declaredVotes ?? null}
formAction={declareFormAction}
/>
{readOnly ? null : (
<DeclareVotesForm
municipalityID={municipalityID}
leadershipID={leadership.id}
currentDeclaredVotes={pledge?.declaredVotes ?? null}
formAction={declareFormAction}
/>
)}
</li>
)
})}

View file

@ -45,6 +45,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { rowEditingAllowed, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import {
resolveVisibleColumns,
type CampaignColumnPickerColumn,
@ -124,6 +125,10 @@ export type MunicipalityListProps = {
signalFormAction: MunicipalityStaffFormAction
state: MunicipalityListState
columnVisibility: CampaignColumnVisibility
/** C142 — the UI write scope for the row controls; a `carteira` scope with a
* portfolio excludes rows outside it. See `rowEditingAllowed`. */
editingScope: AdvisorEditingScope
portfolioIDs: ReadonlySet<number> | null
}
/** Replaces only the rows: the filter header row stays put. */
@ -224,6 +229,8 @@ const municipalityListColumns = ({
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
editingScope,
portfolioIDs,
columnVisibility,
}: MunicipalityListProps): Array<MunicipalityColumn> => {
const manuallyHidden = isStaffView ? new Set(columnVisibility.hiddenColumnIds) : new Set<string>()
@ -234,6 +241,14 @@ const municipalityListColumns = ({
// visible editor per row per width); a manual hide pins the column open.
const actionsAlwaysVisible = trendIsHidden || signalIsHidden
// C142 — per-row write gate: `portfolioIDs === null` (whole catalog) or the
// row's own municipality inside the portfolio makes the row editable. The
// virtual city row has no DB document and stays read-only.
const rowEditable = (municipality: MunicipalityListViewModel): boolean =>
municipality.isCity
? false
: rowEditingAllowed(editingScope, portfolioIDs ? [...portfolioIDs] : null, [municipality.id])
const renderTrendControl = (
municipality: MunicipalityListViewModel,
triggerPresentation: 'compact' | 'adaptive',
@ -245,6 +260,7 @@ const municipalityListColumns = ({
trendNote={municipality.politicalTrendNote}
variant="popover"
triggerPresentation={triggerPresentation}
readOnly={!rowEditable(municipality)}
/>
)
@ -260,6 +276,7 @@ const municipalityListColumns = ({
variant="popover"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
readOnly={!rowEditable(municipality)}
>
<MunicipalityListSignalTrigger
lastSignalAt={municipality.lastSignalAt}
@ -390,6 +407,7 @@ const municipalityListColumns = ({
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="popover"
readOnly={!rowEditable(municipality)}
/>
)}
</div>
@ -536,6 +554,7 @@ const municipalityListColumns = ({
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="popover"
readOnly={!rowEditable(municipality)}
/>
),
},
@ -569,6 +588,7 @@ const municipalityListColumns = ({
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="popover"
readOnly={!rowEditable(municipality)}
/>
),
},
@ -728,6 +748,8 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
stateDeputyCommitAction={stateDeputyCommitAction}
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={props.signalFormAction}
editingScope={props.editingScope}
portfolioIDs={props.portfolioIDs}
emptySlot={<MunicipalityListEmptyState state={state} />}
/>

View file

@ -53,6 +53,8 @@ type MunicipalityListExpectedVotesControlProps = {
) => ReactNode
/** B193 — dense card styling override (same slot as the sibling controls). */
triggerClassName?: string
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the votes display renders without the editor overlay. */
readOnly?: boolean
} & (
| { variant: 'popover'; municipalityName?: string }
| { variant: 'sheet'; municipalityName: string }
@ -66,6 +68,7 @@ export const MunicipalityListExpectedVotesControl = ({
variant,
trigger,
triggerClassName,
readOnly = false,
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
@ -98,6 +101,26 @@ export const MunicipalityListExpectedVotesControl = ({
` — ${scenarioSummary}`,
].join('')
const triggerNode = trigger ? (
trigger(value, activeScenario)
) : (
<StaffMunicipalityVotesDisplay
expectedVotes={value}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
/>
)
// C142 — read-only: the votes display renders with no editor overlay (the
// custom mobile-card trigger is itself a non-interactive display).
if (readOnly) {
return triggerNode
}
return (
<CampaignCellEditOverlay
variant={variant}
@ -112,21 +135,7 @@ export const MunicipalityListExpectedVotesControl = ({
align="center"
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus
trigger={
trigger ? (
trigger(value, activeScenario)
) : (
<StaffMunicipalityVotesDisplay
expectedVotes={value}
pledgeCoverage={pledgeCoverage}
activeScenario={activeScenario}
layout="compact"
align="center"
suppressHoverPreview={open}
valueClassName="font-medium tabular-nums"
/>
)
}
trigger={triggerNode}
>
<div className="relative flex flex-col gap-2.5">
{isPending ? (

View file

@ -25,6 +25,8 @@ type MunicipalityListLeadershipsControlProps = MunicipalityRelationTriggerProps
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
options: EligibleLeadershipOption[]
variant: CampaignCellEditOverlayVariant
/** C142 — read-only presentation (advisor with Edição `somente_leitura`). */
readOnly?: boolean
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({
@ -41,6 +43,7 @@ export const MunicipalityListLeadershipsControl = ({
variant,
trigger,
triggerClassName,
readOnly = false,
}: MunicipalityListLeadershipsControlProps) => {
const createBridge = useMunicipalityLeadershipCreate()
const createdOptions = createBridge?.createdOptions ?? []
@ -102,6 +105,7 @@ export const MunicipalityListLeadershipsControl = ({
trigger={trigger}
triggerClassName={triggerClassName}
createLabel={(name) => `Criar liderança “${name}”`}
readOnly={readOnly}
onToggle={toggle}
onCreate={create}
onCreated={(entry) =>

View file

@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { MunicipalityMobileCard } from '@/components/campaign/municipality/MunicipalityMobileCard'
import type { MunicipalityStateDeputyCreateAction } from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import type {
@ -36,6 +37,9 @@ export type MunicipalityListMobileCardsProps = {
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
signalFormAction: MunicipalityStaffFormAction
emptySlot: ReactNode
/** C142 — the UI write scope for the mobile card quick edits. */
editingScope: AdvisorEditingScope
portfolioIDs: ReadonlySet<number> | null
}
/**
@ -59,6 +63,8 @@ export const MunicipalityListMobileCards = ({
stateDeputyCreateAction,
signalFormAction,
emptySlot,
editingScope,
portfolioIDs,
}: MunicipalityListMobileCardsProps) => (
<div
data-view="mobile-cards"
@ -86,6 +92,8 @@ export const MunicipalityListMobileCards = ({
stateDeputyCreateAction={stateDeputyCreateAction}
signalFormAction={signalFormAction}
isCampaignUnrestricted={isCampaignUnrestricted}
editingScope={editingScope}
portfolioIDs={portfolioIDs}
/>
))}
</div>

View file

@ -54,6 +54,8 @@ type MunicipalityListTrendControlProps = {
trigger?: (trend: MunicipalityListSavedPoliticalTrend) => ReactNode
/** B193 — dense card chip styling override (shorter, pill-shaped). */
triggerClassName?: string
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the badge renders without the editor overlay. */
readOnly?: boolean
}
const politicalTrendIcon = {
@ -71,6 +73,7 @@ export const MunicipalityListTrendControl = ({
triggerPresentation = 'full',
trigger,
triggerClassName,
readOnly = false,
}: MunicipalityListTrendControlProps) => {
const { open, onOpenChange, value, change, flush, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<
@ -127,6 +130,12 @@ export const MunicipalityListTrendControl = ({
</>
)
// C142 — read-only: the badge/trigger renders with no editor overlay (the
// custom mobile-card trigger is itself a non-interactive display).
if (readOnly) {
return triggerNode
}
return (
<CampaignCellEditOverlay
variant={variant}

View file

@ -33,6 +33,8 @@ type MunicipalityListUpdateControlProps = {
formAction: MunicipalityStaffFormAction
isStaff: boolean
children: ReactNode
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the signal-age display renders with no "Registrar atualização" affordance. */
readOnly?: boolean
}
export const MunicipalityListUpdateControl = ({
@ -44,6 +46,7 @@ export const MunicipalityListUpdateControl = ({
formAction,
isStaff,
children,
readOnly = false,
}: MunicipalityListUpdateControlProps) => {
const [open, setOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
@ -66,6 +69,12 @@ export const MunicipalityListUpdateControl = ({
</Button>
)
// C142 — read-only: the signal-age display renders with no "Registrar
// atualização" affordance (the write control is absent, not disabled).
if (readOnly) {
return children
}
return (
<CampaignCellEditOverlay
variant={variant}

View file

@ -28,6 +28,7 @@ import {
} from '@/components/campaign/shared/MunicipalityStateDeputyRelationCell'
import { VoteEstimateScenarioStrip } from '@/components/campaign/votePledge/VoteEstimateScenarioStrip'
import { Badge } from '@/components/ui/Badge'
import { rowEditingAllowed, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { SALVADOR_CITY_AGGREGATE_LABEL } from '@/lib/salvadorCity'
import {
municipalityUpdatePolarityBadgeVariant,
@ -79,6 +80,9 @@ type MunicipalityMobileCardProps = {
stateDeputyCommitAction: MunicipalityStaffFormAction
stateDeputyCreateAction: MunicipalityStateDeputyCreateAction
signalFormAction: MunicipalityStaffFormAction
/** C142 — the UI write scope for the quick-edit chips. */
editingScope: AdvisorEditingScope
portfolioIDs: ReadonlySet<number> | null
}
/**
@ -165,6 +169,8 @@ export const MunicipalityMobileCard = ({
stateDeputyCommitAction,
stateDeputyCreateAction,
signalFormAction,
editingScope,
portfolioIDs,
}: MunicipalityMobileCardProps) => {
const [expanded, setExpanded] = useState(false)
const isCity = municipality.isCity
@ -176,6 +182,11 @@ export const MunicipalityMobileCard = ({
const updateAge = municipalitySignalAgeInDays(update?.createdAt ?? null)
const updateIsCold = isMunicipalitySignalCold(updateAge)
// C142 — per-row write gate for the mobile quick-edit chips.
const rowEditable =
!isCity &&
rowEditingAllowed(editingScope, portfolioIDs ? [...portfolioIDs] : null, [municipality.id])
const registerCta = (
<span className="flex min-h-11 w-full items-center justify-center rounded-md border border-dashed border-border px-3 text-sm font-medium">
Registrar atualização
@ -191,6 +202,7 @@ export const MunicipalityMobileCard = ({
variant="sheet"
formAction={signalFormAction}
isStaff={isCampaignUnrestricted}
readOnly={!rowEditable}
>
{trigger}
</MunicipalityListUpdateControl>
@ -252,6 +264,7 @@ export const MunicipalityMobileCard = ({
expectedVotes={municipality.expectedVotes}
pledgeCoverage={toMunicipalityPledgeCoverageView(municipality.pledges)}
variant="sheet"
readOnly={!rowEditable}
trigger={(values, activeScenario) => (
<VoteEstimateScenarioStrip
values={values}
@ -282,6 +295,7 @@ export const MunicipalityMobileCard = ({
status={municipality.politicalTrendStatus}
trendNote={municipality.politicalTrendNote}
variant="sheet"
readOnly={!rowEditable}
triggerClassName={chipTriggerClassName}
trigger={(trend) => (
<ChipBlock label="Tendência">
@ -364,6 +378,7 @@ export const MunicipalityMobileCard = ({
leadershipNamesById={leadershipNamesById}
options={leadershipOptions}
variant="sheet"
readOnly={!rowEditable}
trigger={(entries, emptyState) => (
<RelationGroupTrigger
label="Lideranças"
@ -385,6 +400,7 @@ export const MunicipalityMobileCard = ({
commitAction={stateDeputyCommitAction}
createAction={stateDeputyCreateAction}
editorVariant="sheet"
readOnly={!rowEditable}
trigger={(entries, emptyState) => (
<RelationGroupTrigger
label="Dobradinhas"

View file

@ -18,12 +18,15 @@ type MunicipalityPledgesPanelProps = {
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): pledges show estimates only, no edit form. */
readOnly?: boolean
}
/** Staff-only: declared vs estimated votes per leadership in this municipality. */
export const MunicipalityPledgesPanel = ({
pledges,
estimateFormAction,
readOnly = false,
}: MunicipalityPledgesPanelProps) => {
const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0)
const effectiveTotal = pledges.reduce(
@ -91,12 +94,14 @@ export const MunicipalityPledgesPanel = ({
</Badge>
)}
</div>
<PledgeEstimateForm
pledgeID={pledge.id}
currentEstimatedVotes={pledge.estimatedVotes}
currentEstimateNote={pledge.estimateNote}
formAction={estimateFormAction}
/>
{readOnly ? null : (
<PledgeEstimateForm
pledgeID={pledge.id}
currentEstimatedVotes={pledge.estimatedVotes}
currentEstimateNote={pledge.estimateNote}
formAction={estimateFormAction}
/>
)}
</li>
)
})}

View file

@ -22,6 +22,8 @@ type MunicipalityV2DeclaredVotesCellProps = {
leadershipName: string
declaredVotes: number | null
variant: 'popover' | 'sheet'
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the votes render with no editor overlay. */
readOnly?: boolean
}
const declaredVotesEqual = (left: number | null, right: number | null): boolean => left === right
@ -32,6 +34,7 @@ export const MunicipalityV2DeclaredVotesCell = ({
leadershipName,
declaredVotes,
variant,
readOnly = false,
}: MunicipalityV2DeclaredVotesCellProps) => {
const { open, onOpenChange, value, change, isPending, errorMessage, statusMessage } =
useCampaignCellAutosave<number | null, MunicipalityPledgeDeclaredVotesResponse>({
@ -72,6 +75,11 @@ export const MunicipalityV2DeclaredVotesCell = ({
change(parsed, AUTOSAVE_MS)
}
// C142 — read-only: the value renders with no editor overlay.
if (readOnly) {
return display
}
return (
<CampaignCellEditOverlay
variant={variant}

View file

@ -32,6 +32,8 @@ type MunicipalityV2EstimatedVotesCellProps = {
declaredVotes: number | null
estimatedVotes: VoteEstimateScenarioViewModel
variant: 'popover' | 'sheet'
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the estimate renders with no editor overlay. */
readOnly?: boolean
}
type EditableProps = Omit<MunicipalityV2EstimatedVotesCellProps, 'pledgeID'> & {
@ -44,6 +46,7 @@ const MunicipalityV2EstimatedVotesCellEditable = ({
declaredVotes,
estimatedVotes,
variant,
readOnly = false,
}: EditableProps) => {
const declared = declaredVotes ?? 0
const centralEstimate = getVoteEstimateForScenario(estimatedVotes, DEFAULT_VOTE_ESTIMATE_SCENARIO)
@ -89,6 +92,11 @@ const MunicipalityV2EstimatedVotesCellEditable = ({
<span className="font-medium tabular-nums">{formatElectionNumber(centralEstimate)}</span>
)
// C142 — read-only: the estimate renders with no editor overlay.
if (readOnly) {
return display
}
return (
<CampaignCellEditOverlay
variant={variant}
@ -138,6 +146,7 @@ export const MunicipalityV2EstimatedVotesCell = ({
declaredVotes,
estimatedVotes,
variant,
readOnly = false,
}: MunicipalityV2EstimatedVotesCellProps) => {
if (pledgeID === null) {
return <span className="text-muted-foreground">—</span>
@ -150,6 +159,7 @@ export const MunicipalityV2EstimatedVotesCell = ({
declaredVotes={declaredVotes}
estimatedVotes={estimatedVotes}
variant={variant}
readOnly={readOnly}
/>
)
}

View file

@ -41,6 +41,8 @@ const AUTOSAVE_MS = 600
type MunicipalityV2LocalAccountSectionProps = {
conta: MunicipalityV2ContaViewModel
territorialClass: MunicipalityTerritorialClassification
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the scenario estimates render as static values with no autosave inputs. */
readOnly?: boolean
}
const ConceptHint = ({
@ -81,6 +83,7 @@ const ConceptHint = ({
export const MunicipalityV2LocalAccountSection = ({
conta,
territorialClass,
readOnly = false,
}: MunicipalityV2LocalAccountSectionProps) => {
const [activeScenario, setActiveScenario] = useState<VoteEstimateScenario>(
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@ -150,15 +153,30 @@ export const MunicipalityV2LocalAccountSection = ({
aria-label="Salvando votos estimados"
/>
) : null}
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={value}
idPrefix={`municipality-v2-expected-votes-${conta.municipalityID}`}
variant="compact"
activeScenario={activeScenario}
onFocusScenario={setActiveScenario}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
{readOnly ? (
<dl className="flex flex-col gap-1.5">
{(['pessimistic', 'central', 'optimistic'] as const).map((scenario) => (
<div key={scenario} className="flex items-baseline justify-between gap-2 text-sm">
<dt className="text-muted-foreground">{voteEstimateScenarioLabels[scenario]}</dt>
<dd className="tabular-nums">
{getVoteEstimateForScenario(value, scenario) == null
? 'sem estimativa'
: formatElectionNumber(getVoteEstimateForScenario(value, scenario)!)}
</dd>
</div>
))}
</dl>
) : (
<VoteEstimateScenarioInputs
fieldPrefix="expectedVotes"
values={value}
idPrefix={`municipality-v2-expected-votes-${conta.municipalityID}`}
variant="compact"
activeScenario={activeScenario}
onFocusScenario={setActiveScenario}
onValuesChange={(values) => change(values, AUTOSAVE_MS)}
/>
)}
<p className="text-xs text-muted-foreground tabular-nums">
Meta ativa ({scenarioLabel}): {formatElectionNumber(activeCoverage.goal)} (
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida: votação de 2022 aqui'})

View file

@ -61,6 +61,8 @@ type PendingAxis = 'level' | 'trend' | 'update' | null
type MunicipalityV2StatusStripProps = {
status: MunicipalityV2StatusViewModel
signalFormAction: (formData: FormData) => Promise<CampaignFormActionState>
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): nível/tendência/polaridade render as static values with no selects. */
readOnly?: boolean
}
const ConceptTooltip = ({
@ -105,6 +107,7 @@ const polarityOptions: readonly MunicipalityUpdatePolarity[] = ['boa', 'neutra',
export const MunicipalityV2StatusStrip = ({
status,
signalFormAction,
readOnly = false,
}: MunicipalityV2StatusStripProps) => {
const router = useRouter()
const formId = useId()
@ -321,7 +324,7 @@ export const MunicipalityV2StatusStrip = ({
<ConceptTooltip conceptId="nivel-de-envolvimento" label="Nível">
<FieldLabel htmlFor={`${formId}-level`}>Nível</FieldLabel>
</ConceptTooltip>
{status.canMoveEngagementLevel ? (
{status.canMoveEngagementLevel && !readOnly ? (
<NativeSelect
id={`${formId}-level`}
className="min-h-11 w-full"
@ -355,64 +358,76 @@ export const MunicipalityV2StatusStrip = ({
<Field>
<FieldLabel htmlFor={`${formId}-trend`}>Tendência</FieldLabel>
<NativeSelect
id={`${formId}-trend`}
className="min-h-11 w-full"
value={trendStatus ?? ''}
aria-busy={isPending && pendingAxis === 'trend' ? true : undefined}
onChange={(event) => {
const next = parsePoliticalTrendStatusFormValue(event.target.value)
if (next === trendStatus) return
setDraftTrend(next)
setPendingAxis('trend')
setErrorMessage(null)
}}
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{politicalTrendStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{politicalTrendLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
{readOnly ? (
<p id={`${formId}-trend`} className="flex min-h-11 items-center text-sm">
{trendStatus ? politicalTrendLabels[trendStatus] : 'Não registrada'}
</p>
) : (
<NativeSelect
id={`${formId}-trend`}
className="min-h-11 w-full"
value={trendStatus ?? ''}
aria-busy={isPending && pendingAxis === 'trend' ? true : undefined}
onChange={(event) => {
const next = parsePoliticalTrendStatusFormValue(event.target.value)
if (next === trendStatus) return
setDraftTrend(next)
setPendingAxis('trend')
setErrorMessage(null)
}}
>
<NativeSelectOption value="">Não registrada</NativeSelectOption>
{politicalTrendStatuses.map((option) => (
<NativeSelectOption key={option} value={option}>
{politicalTrendLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
)}
</Field>
<Field>
<ConceptTooltip conceptId="pauta-do-silencio" label="Polaridade">
<FieldLabel htmlFor={`${formId}-update`}>Polaridade</FieldLabel>
</ConceptTooltip>
<NativeSelect
id={`${formId}-update`}
className="min-h-11 w-full"
value={updateSelect.value}
aria-busy={isPending && pendingAxis === 'update' ? true : undefined}
onChange={(event) => {
const raw = event.target.value
if (raw === MUNICIPALITY_V2_SIGNAL_COLD_VALUE) {
event.target.value = updateSelect.value
return
}
const next = raw as MunicipalityUpdatePolarity
if (!next || next === polarity) {
event.target.value = updateSelect.value
return
}
setDraftPolarity(next)
setPendingAxis('update')
setErrorMessage(null)
}}
>
{updateSelect.isCold ? (
<NativeSelectOption value={MUNICIPALITY_V2_SIGNAL_COLD_VALUE}>
{updateSelect.label}
</NativeSelectOption>
) : null}
{polarityOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
{readOnly ? (
<p id={`${formId}-update`} className="flex min-h-11 items-center text-sm">
{updateSelect.label}
</p>
) : (
<NativeSelect
id={`${formId}-update`}
className="min-h-11 w-full"
value={updateSelect.value}
aria-busy={isPending && pendingAxis === 'update' ? true : undefined}
onChange={(event) => {
const raw = event.target.value
if (raw === MUNICIPALITY_V2_SIGNAL_COLD_VALUE) {
event.target.value = updateSelect.value
return
}
const next = raw as MunicipalityUpdatePolarity
if (!next || next === polarity) {
event.target.value = updateSelect.value
return
}
setDraftPolarity(next)
setPendingAxis('update')
setErrorMessage(null)
}}
>
{updateSelect.isCold ? (
<NativeSelectOption value={MUNICIPALITY_V2_SIGNAL_COLD_VALUE}>
{updateSelect.label}
</NativeSelectOption>
) : null}
{polarityOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{municipalityUpdatePolarityLabels[option]}
</NativeSelectOption>
))}
</NativeSelect>
)}
</Field>
<div className="flex flex-col gap-2">

View file

@ -61,6 +61,12 @@ type CampaignInlineEditableCellProps = {
* state worth spelling, not a dash.
*/
placeholder?: string
/**
* C142 — read-only presentation (advisor with Edição `somente_leitura`):
* the value renders with no edit affordance of any kind (no pencil, no
* permanent input, no cell-click trigger). The name-link keeps navigating.
*/
readOnly?: boolean
className?: string
}
@ -94,6 +100,7 @@ export const CampaignInlineEditableCell = ({
saveOnChange = true,
permanent = false,
placeholder,
readOnly = false,
className,
}: CampaignInlineEditableCellProps) => {
const router = useRouter()
@ -231,6 +238,61 @@ export const CampaignInlineEditableCell = ({
startEditing()
}
const displayValue = field === 'phone' && value ? formatBrazilianPhoneInput(value) : undefined
const readValue =
href && field === 'name' && value ? (
<Link
href={href}
className={cn(
campaignReadCellClassName,
'inline-flex w-auto max-w-full',
'pointer-events-auto font-medium text-primary underline-offset-4 hover:underline',
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<span className="truncate">{value}</span>
</Link>
) : readBehavior === 'copy' ? (
<CampaignCopyableCell
value={value}
label={label}
displayValue={displayValue}
className={field === 'phone' ? 'tabular-nums' : undefined}
/>
) : (
<span
className={cn(
campaignReadCellClassName,
'text-sm text-muted-foreground',
value ? 'text-foreground' : undefined,
field === 'phone' ? 'tabular-nums' : undefined,
)}
>
{displayValue ?? value ?? '—'}
</span>
)
const savedFeedback = showSavedFeedback ? (
<span role="status" aria-live="polite" className="text-xs text-muted-foreground">
Salvo.
</span>
) : null
/**
* C142 — read-only: the value with no edit affordance of any kind (no
* pencil, no permanent input, no cell-click trigger). The name-link keeps
* navigating — presence/absence of editing is the only language.
*/
if (readOnly) {
return (
<div className={cn('flex min-w-0 items-center gap-0.5', className)}>
<div className="min-w-0 flex-1">{readValue}</div>
{savedFeedback}
</div>
)
}
/**
* C116 — "edit where you see": input always mounted, no edit state. The
* name-link cell renders the LINK above an invisible input (the draft lives
@ -384,47 +446,6 @@ export const CampaignInlineEditableCell = ({
)
}
const displayValue = field === 'phone' && value ? formatBrazilianPhoneInput(value) : undefined
const readValue =
href && field === 'name' && value ? (
<Link
href={href}
className={cn(
campaignReadCellClassName,
'inline-flex w-auto max-w-full',
'pointer-events-auto font-medium text-primary underline-offset-4 hover:underline',
)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<span className="truncate">{value}</span>
</Link>
) : readBehavior === 'copy' ? (
<CampaignCopyableCell
value={value}
label={label}
displayValue={displayValue}
className={field === 'phone' ? 'tabular-nums' : undefined}
/>
) : (
<span
className={cn(
campaignReadCellClassName,
'text-sm text-muted-foreground',
value ? 'text-foreground' : undefined,
field === 'phone' ? 'tabular-nums' : undefined,
)}
>
{displayValue ?? value ?? '—'}
</span>
)
const savedFeedback = showSavedFeedback ? (
<span role="status" aria-live="polite" className="text-xs text-muted-foreground">
Salvo.
</span>
) : null
if (editTrigger === 'cell') {
if (href && field === 'name') {
return (

View file

@ -30,6 +30,8 @@ type LeadershipStateDeputyRelationCellProps = {
) => Promise<CampaignFormActionState>
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
measureOverflow?: boolean
/** C142 — read-only presentation (advisor with Edição `somente_leitura`). */
readOnly?: boolean
}
const DIRECTION_COPY: Record<
@ -89,6 +91,7 @@ export const LeadershipStateDeputyRelationCell = ({
options,
membershipAction,
measureOverflow = true,
readOnly = false,
}: LeadershipStateDeputyRelationCellProps) => {
const copy = DIRECTION_COPY[direction]
@ -135,6 +138,7 @@ export const LeadershipStateDeputyRelationCell = ({
triggerLabel={`Editar ${copy.triggerVerb} de ${ownerName}`}
updateErrorMessage={copy.updateErrorMessage}
measureOverflow={measureOverflow}
readOnly={readOnly}
/>
)
}

View file

@ -60,6 +60,12 @@ type MunicipalityRelationEditorProps = MunicipalityRelationTriggerProps & {
onToggle: (id: number, assigned: boolean) => Promise<MunicipalityRelationMutationResult>
onCreate: (name: string) => Promise<MunicipalityRelationMutationResult>
onCreated?: (entry: MunicipalityRelationEntry) => void
/**
* C142 — read-only presentation (advisor with Edição `somente_leitura`): the
* selected entries render as the closed display with no editor, no remove
* chips and no search — the avatar stack or the custom trigger stays.
*/
readOnly?: boolean
}
const isPendingCreateID = (id: number): boolean => id < 0
@ -88,6 +94,7 @@ export const MunicipalityRelationEditor = ({
onCreated,
trigger,
triggerClassName,
readOnly = false,
}: MunicipalityRelationEditorProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState(currentIDs)
@ -271,6 +278,14 @@ export const MunicipalityRelationEditor = ({
const isSheet = variant === 'sheet'
const trimmedQuery = query.trim()
if (readOnly) {
return trigger ? (
trigger(selectedEntries, emptyState)
) : (
<MunicipalityRelationAvatarStack entries={selectedEntries} emptyState={emptyState} />
)
}
return (
<CampaignCellEditOverlay
variant={variant}

View file

@ -24,6 +24,8 @@ type MunicipalityStateDeputyRelationCellProps = MunicipalityRelationTriggerProps
) => Promise<CampaignFormActionState>
createAction: MunicipalityStateDeputyCreateAction
editorVariant: CampaignCellEditOverlayVariant
/** C142 — read-only presentation (advisor with Edição `somente_leitura`). */
readOnly?: boolean
}
export type MunicipalityStateDeputyCreateAction = (
@ -67,6 +69,7 @@ export const MunicipalityStateDeputyRelationCell = ({
editorVariant,
trigger,
triggerClassName,
readOnly = false,
}: MunicipalityStateDeputyRelationCellProps) => {
const toggle = async (
stateDeputyID: number,
@ -130,6 +133,7 @@ export const MunicipalityStateDeputyRelationCell = ({
createLabel={(name) => `Criar dobradinha “${name}”`}
createMaxLength={120}
sortSelected={false}
readOnly={readOnly}
onToggle={toggle}
onCreate={create}
/>

View file

@ -40,6 +40,7 @@ export const PhonesFieldEditor = ({
saveLabel = 'Salvar',
cancelLabel = 'Cancelar',
className,
readOnly = false,
}: {
name?: string
defaultValues?: string[]
@ -57,6 +58,8 @@ export const PhonesFieldEditor = ({
saveLabel?: string
cancelLabel?: string
className?: string
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the phones render as a plain list with no edit affordance. */
readOnly?: boolean
}) => {
const router = useRouter()
const [rows, setRows] = useState<PhoneRow[]>(() => {
@ -249,6 +252,25 @@ export const PhonesFieldEditor = ({
)
}
// C142 — read-only: the phones render as a plain list with no edit
// affordance (absence is the language).
if (readOnly) {
return (
<div className={className}>
<ul className="flex flex-col gap-1.5" aria-label={label}>
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
<span className="min-w-40">
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
</span>
</li>
))}
</ul>
</div>
)
}
return (
<div className={className}>
<FieldLabel>{label}</FieldLabel>

View file

@ -8,16 +8,20 @@ import {
CampaignQuickActionsHost,
useQuickActionsChromeActive,
} from '@/components/campaign/shell/CampaignQuickActionsHost'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import type { CampaignRole } from '@/lib/campaignRoles'
export const CampaignAppScrollChrome = ({
role,
editingScope = 'tudo',
children,
}: {
role: CampaignRole
/** C142 — advisors with `somente_leitura` see no FAB on staff surfaces. */
editingScope?: AdvisorEditingScope
children: ReactNode
}) => {
const quickActionsActive = useQuickActionsChromeActive(role)
const quickActionsActive = useQuickActionsChromeActive(role, editingScope)
if (!quickActionsActive) {
return <CampaignContentScroll>{children}</CampaignContentScroll>
@ -27,7 +31,7 @@ export const CampaignAppScrollChrome = ({
<CampaignGlobalSearchProvider>
<div className="flex min-h-0 flex-1 flex-col">
<CampaignContentScroll>{children}</CampaignContentScroll>
<CampaignQuickActionsHost role={role} />
<CampaignQuickActionsHost role={role} editingScope={editingScope} />
</div>
</CampaignGlobalSearchProvider>
)

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignQuickActionsFab } from '@/components/campaign/shell/CampaignQuickActionsFab'
import { CampaignQuickActionsOverlay } from '@/components/campaign/shell/CampaignQuickActionsOverlay'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { shouldMountQuickActionsFab } from '@/lib/campaignQuickActionMount'
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
@ -19,12 +20,19 @@ export const CampaignContentScroll = ({ children }: { children: ReactNode }) =>
</div>
)
export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
export const CampaignQuickActionsHost = ({
role,
editingScope = 'tudo',
}: {
role: CampaignRole
/** C142 — advisors with `somente_leitura` see no FAB on staff surfaces. */
editingScope?: AdvisorEditingScope
}) => {
const pathname = usePathname()
const [open, setOpen] = useState(false)
const { context } = useCampaignQuickActionContext()
const mounted = shouldMountQuickActionsFab(pathname, role)
const mounted = shouldMountQuickActionsFab(pathname, role, editingScope)
useEffect(() => {
setOpen(false)
@ -36,7 +44,7 @@ export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
if (!mounted) return null
const actions = resolveQuickActionsForPath(pathname, role, context)
const actions = resolveQuickActionsForPath(pathname, role, context, editingScope)
const showBottomNav = isStaffCampaignRole(role)
return (
@ -53,7 +61,10 @@ export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
)
}
export const useQuickActionsChromeActive = (role: CampaignRole): boolean => {
export const useQuickActionsChromeActive = (
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
): boolean => {
const pathname = usePathname()
return shouldMountQuickActionsFab(pathname, role)
return shouldMountQuickActionsFab(pathname, role, editingScope)
}

View file

@ -48,11 +48,14 @@ export const SuggestionCard = ({
suggestion,
showMunicipality = false,
resolveAction,
readOnly = false,
}: {
suggestion: SuggestionCardData
/** Dashboard names the município; the detail card is already inside it. */
showMunicipality?: boolean
resolveAction: SuggestionResolveAction
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): the card renders the reading with no decision actions. */
readOnly?: boolean
}) => {
const pattern = getSuggestionPattern(suggestion.patternId)
const postponeDays = suggestionPostponeDays(suggestion.triageLevel)
@ -170,7 +173,7 @@ export const SuggestionCard = ({
<span className="font-medium">Contraindicação:</span> {pattern.contraindication}
</p>
{mode === null ? (
{readOnly ? null : mode === null ? (
<div ref={triggersRef} className="flex flex-wrap gap-2">
<Button
variant="outline"

View file

@ -26,6 +26,7 @@ export const SuggestionsPanel = ({
showMunicipality = false,
resolveAction,
emptyState,
readOnly = false,
children,
}: {
titleId: string
@ -35,6 +36,8 @@ export const SuggestionsPanel = ({
showMunicipality?: boolean
resolveAction: ComponentProps<typeof SuggestionCard>['resolveAction']
emptyState: ReactNode
/** C142 — read-only presentation (advisor with Edição `somente_leitura`): cards render without decision actions. */
readOnly?: boolean
/** Dashboard appends the silence strip here. */
children?: ReactNode
}) => (
@ -106,6 +109,7 @@ export const SuggestionsPanel = ({
}}
showMunicipality={showMunicipality}
resolveAction={resolveAction}
readOnly={readOnly}
/>
</li>
),

View file

@ -86,3 +86,46 @@ export const isAdvisorVisibilityValue = (value: unknown): value is AdvisorVisibi
export const isAdvisorEditingValue = (value: unknown): value is AdvisorEditing =>
ADVISOR_EDITING_VALUES.includes(value as AdvisorEditing)
/**
* C142 — the Edição axis as a write-scope decision, client-safe mirror of the
* advisor branch of the server `advisorEditingAccess` (`src/utilities/access/
* shared.ts` delegates here so the axis semantics live once). `somente_leitura`
* → 'none' (no writes anywhere), `tudo` → 'tudo', anything else → 'carteira'.
* Fails closed on the incoherent combination (editing more than one sees): the
* collection hook rejects it at write, but a defensively-typed caller must
* never widen a write scope from a stored contradiction.
*/
export const advisorEditingScope = (
visibility: AdvisorVisibility | null | undefined,
editing: AdvisorEditing | null | undefined,
): AdvisorEditingScope => {
if (editing === 'somente_leitura') return 'none'
if (editing === 'tudo' && visibility !== 'tudo') return 'none'
if (editing === 'tudo') return 'tudo'
return 'carteira'
}
/**
* C142 — the per-row presentation rule for lists where Visão "Tudo" meets
* Edição "Carteira": rows inside the portfolio stay editable, rows outside
* render read-only. `none` → never; `tudo` → always; `carteira` → the row
* intersects the portfolio (`portfolioMunicipalityIDs === null` = everything
* is editable). Rows always carry at least one municipality in the surfaces
* that use this helper, so an empty `rowMunicipalityIDs` is not editable under
* a carteira scope.
*/
export const rowEditingAllowed = (
editingScope: AdvisorEditingScope,
portfolioMunicipalityIDs: readonly number[] | null,
rowMunicipalityIDs: readonly number[] | null,
): boolean => {
if (editingScope === 'none') return false
if (editingScope === 'tudo') return true
if (portfolioMunicipalityIDs === null) return true
if (!rowMunicipalityIDs || rowMunicipalityIDs.length === 0) return false
const portfolio = new Set(portfolioMunicipalityIDs)
return rowMunicipalityIDs.some((id) => portfolio.has(id))
}
export type AdvisorEditingScope = 'none' | 'carteira' | 'tudo'

View file

@ -18,6 +18,7 @@ import {
wizardActionHref,
type CampaignWizardActionId,
} from '@/lib/campaignActionRoutes'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
@ -115,10 +116,22 @@ export const wizardFlowTitleForActionId = (id: CampaignWizardActionId): string =
return action?.label ?? 'Continuar'
}
export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => {
export const homeActionsForRole = (
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
): readonly CampaignHomeAction[] => {
if (role === 'leader') return leaderHomeActions
if (!isStaffCampaignRole(role)) return []
if (role === 'advisor') return staffHomeActionsAdvisor
// C142 — advisors with `somente_leitura` see only navigation actions
// (uncovered-municipalities); the 4 wizards are write destinations.
if (role === 'advisor') {
if (editingScope === 'none') {
return staffHomeActionsAdvisor.filter((action) => action.id === 'uncovered-municipalities')
}
return staffHomeActionsAdvisor
}
return staffHomeActionsCoordinator
}
@ -141,18 +154,19 @@ const staffHomeQuickActionsByRole = new Map<CampaignRole, readonly ResolvedCampa
export const resolveStaffHomeQuickActions = (
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => {
if (!isStaffCampaignRole(role)) return []
if (!returnPath) {
const cached = staffHomeQuickActionsByRole.get(role)
if (cached) return cached
if (cached && editingScope === 'tudo') return cached
}
const actions = toHomeActionButtonProps(homeActionsForRole(role), {
const actions = toHomeActionButtonProps(homeActionsForRole(role, editingScope), {
uncoveredMunicipalitiesHref: UNCOVERED_MUNICIPALITIES_LIST_HREF,
returnPath,
})
if (!returnPath) {
if (!returnPath && editingScope === 'tudo') {
staffHomeQuickActionsByRole.set(role, actions)
}
return actions

View file

@ -1,3 +1,4 @@
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import {
homeActionsForRole,
resolveStaffHomeQuickActions,
@ -23,8 +24,10 @@ export const parseMunicipalityDetailSlug = (pathname: string): string | undefine
export const resolveMunicipalityListQuickActions = (
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
returnPath?: string,
): readonly ResolvedCampaignHomeAction[] => resolveStaffHomeQuickActions(role, returnPath)
): readonly ResolvedCampaignHomeAction[] =>
resolveStaffHomeQuickActions(role, editingScope, returnPath)
export const resolveMunicipalityDetailQuickActions = (
role: CampaignRole,
@ -43,9 +46,10 @@ export const resolveMunicipalityQuickActionsForPath = (
pathname: string,
role: CampaignRole,
context: CampaignQuickActionContext,
editingScope: AdvisorEditingScope = 'tudo',
): readonly ResolvedCampaignHomeAction[] => {
if (isMunicipalitiesListPath(pathname)) {
return resolveMunicipalityListQuickActions(role, pathname)
return resolveMunicipalityListQuickActions(role, editingScope, pathname)
}
const slug = context.municipalitySlug ?? parseMunicipalityDetailSlug(pathname)

View file

@ -1,5 +1,6 @@
import { Plus } from 'lucide-react'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions'
import { isListPath } from '@/lib/campaignQuickActionPaths'
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
@ -38,12 +39,13 @@ const newStateDeputyAction = (): CampaignQuickAction => ({
export const resolveDobradinhasQuickActions = (
pathname: string,
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
): readonly CampaignQuickAction[] => {
if (!matchesDobradinhasQuickActionSurface(pathname) || !isStaffCampaignRole(role)) {
return []
}
const staffActions = resolveStaffHomeQuickActions(role, pathname)
const staffActions = resolveStaffHomeQuickActions(role, editingScope, pathname)
if (isStateDeputyListPath(pathname)) {
return [newStateDeputyAction(), ...staffActions]

View file

@ -1,4 +1,5 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { isAdvisorsPath } from '@/lib/campaignAdvisorQuickActions'
import { CAMPAIGN_CONTACTS_HOME, CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isActivityTourComposerPath, isListPath } from '@/lib/campaignQuickActionPaths'
@ -24,8 +25,14 @@ export const isContactsPath = (pathname: string): boolean =>
/**
* Whether the quick-actions FAB should mount on this navigation (all viewports).
* C142 — advisors with `somente_leitura` never see the FAB on staff surfaces
* (every action is a write destination).
*/
export const shouldMountQuickActionsFab = (pathname: string, role: CampaignRole): boolean => {
export const shouldMountQuickActionsFab = (
pathname: string,
role: CampaignRole,
editingScope: AdvisorEditingScope = 'tudo',
): boolean => {
if (isCampaignHomePath(pathname)) return false
if (isCampaignActionsPath(pathname)) return false
// B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome.
@ -33,5 +40,7 @@ export const shouldMountQuickActionsFab = (pathname: string, role: CampaignRole)
if (role === 'leader') return isLeaderContactsPath(pathname)
if (isContactsPath(pathname)) return false
if (isAdvisorsPath(pathname)) return isUnrestrictedCampaignRole(role)
return isStaffCampaignRole(role)
if (!isStaffCampaignRole(role)) return false
// C142 — `somente_leitura` advisors see no write actions in the drawer.
return editingScope !== 'none'
}

View file

@ -1,4 +1,5 @@
import { resolveActivityQuickActions } from '@/lib/activityQuickActions'
import type { AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { resolveAdvisorQuickActionsForPath } from '@/lib/campaignAdvisorQuickActions'
import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions'
import { resolveMunicipalityQuickActionsForPath } from '@/lib/campaignMunicipalityQuickActions'
@ -38,11 +39,14 @@ const isTerritoriesListPath = (pathname: string): boolean =>
* Pathname + role + page context → contextual quick actions.
* Catalogs per vertical land in B80–B90; the B79 chassis renders whatever
* this helper returns.
* C142 — `editingScope` threads through to writers that filter on it
* (dobradinhas, territories list, home actions).
*/
export const resolveQuickActionsForPath = (
pathname: string,
role: CampaignRole,
context: CampaignQuickActionContext,
editingScope: AdvisorEditingScope = 'tudo',
): readonly CampaignQuickAction[] => {
if (isLeaderContactsPath(pathname)) {
return resolveLeaderContactsQuickActions(role, context)
@ -68,7 +72,12 @@ export const resolveQuickActionsForPath = (
const municipalityV2Actions = resolveMunicipalityV2QuickActionsForPath(pathname, role, context)
if (municipalityV2Actions !== null) return municipalityV2Actions
const municipalityActions = resolveMunicipalityQuickActionsForPath(pathname, role, context)
const municipalityActions = resolveMunicipalityQuickActionsForPath(
pathname,
role,
context,
editingScope,
)
if (municipalityActions.length > 0) return municipalityActions
const leadershipActions = resolveLeadershipQuickActions(pathname, role, context)
@ -77,7 +86,7 @@ export const resolveQuickActionsForPath = (
}
if (matchesDobradinhasQuickActionSurface(pathname)) {
return resolveDobradinhasQuickActions(pathname, role)
return resolveDobradinhasQuickActions(pathname, role, editingScope)
}
const advisorActions = resolveAdvisorQuickActionsForPath(pathname, role, context)
@ -87,7 +96,7 @@ export const resolveQuickActionsForPath = (
if (supporterActions.length > 0) return supporterActions
if (isTerritoriesListPath(pathname)) {
return resolveStaffHomeQuickActions(role, pathname)
return resolveStaffHomeQuickActions(role, editingScope, pathname)
}
const referenceActions = resolveReferenceQuickActionsForPath(pathname, role, context)

View file

@ -3,6 +3,7 @@
// Domain modules may import from here; this module must not import from them.
// ---------------------------------------------------------------------------
import { advisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { CampaignUser, User } from '@/payload-types'
import type { Access, PayloadRequest, Where } from 'payload'
@ -229,13 +230,13 @@ export const resolveProfileScopedRead = async (
* catalog, minus coordination fields), `carteira` → today's portfolio scope.
* Callers resolve admin/unrestricted first and keep their own where shape for
* the carteira branch; non-advisor actors resolve 'none' here and are handled
* by their own branches.
* by their own branches. C142 — delegates the advisor branch to the client-safe
* `advisorEditingScope` (`src/lib/campaignAdvisorProfile.ts`) so the axis
* semantics live once.
*/
export const advisorEditingAccess = (user: CampaignActor): 'none' | 'carteira' | 'tudo' => {
if (!isCampaignUser(user) || user.role !== 'advisor') return 'none'
if (user.editing === 'somente_leitura') return 'none'
if (user.editing === 'tudo') return 'tudo'
return 'carteira'
return advisorEditingScope(user.visibility ?? 'carteira', user.editing ?? 'carteira')
}
/**

View file

@ -4,12 +4,13 @@ import { redirect } from 'next/navigation'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { advisorEditingAccess } from '@/utilities/access/shared'
import { getCampaignUser } from '@/utilities/campaignAuth'
/** Exactly the non-null actor `getCampaignUser` proves (keeps its `email` refinement). */
export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader'
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader' | 'writable'
export { LEADER_CONTACTS_HOME }
@ -24,7 +25,10 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
* - (none) → any authenticated campaign user;
* - 'staff' → non-staff (leader) goes to `/campanha/meus-contatos` (B43, C139);
* - 'unrestricted' → non-coordinator/candidate goes to `/campanha`;
* - 'noLeader' → leader goes to `/campanha/meus-contatos` (B43, C139).
* - 'noLeader' → leader goes to `/campanha/meus-contatos` (B43, C139);
* - 'writable' → advisor with Edição `somente_leitura` goes to `/campanha`
* (C142 — write destinations must not be offered to a
* read-only advisor; the server already rejects the write).
*
* A custom `redirectTo` overrides the gate's default target. The convention
* guard in `codebaseConventions.unit.spec.ts` fails the build on a
@ -42,6 +46,7 @@ export const requireCampaignPageActor = async (
if (gate === 'staff' && !isStaffCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'unrestricted' && !isUnrestrictedCampaignRole(user.role)) redirect(denyRedirect)
if (gate === 'noLeader' && user.role === 'leader') redirect(denyRedirect)
if (gate === 'writable' && advisorEditingAccess(user) === 'none') redirect(denyRedirect)
return user
}

View file

@ -3,6 +3,7 @@ import 'server-only'
import type { Payload, Where } from 'payload'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { advisorEditingScope, type AdvisorEditingScope } from '@/lib/campaignAdvisorProfile'
import { engagementLevelRank } from '@/lib/engagementLevel'
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
import {
@ -15,6 +16,7 @@ import { relationshipId } from '@/lib/relationship'
import { SALVADOR_CITY_SLUG } from '@/lib/salvadorCity'
import { type VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { getAdvisorMunicipalityIds } from '@/utilities/access/municipalities'
import {
isCampaignLeader,
isCampaignStaff,
@ -80,6 +82,33 @@ export const MunicipalityNotFoundError = createEntityNotFoundError(
'Município não encontrado.',
)
/**
* C142 — resolve the UI write-scope for a municipality surface (list row,
* detail page). Non-advisors (coordinator/candidate) get full write scope;
* advisors derive from their permission profile and portfolio. The result
* feeds server→client props so the UI can gate writes without a new provider.
*/
export type MunicipalityWriteScope = {
editingScope: AdvisorEditingScope
/** `null` = the whole catalog is writable; `Set` = only these IDs are writable. */
portfolioIDs: ReadonlySet<number> | null
}
export const resolveMunicipalityWriteScope = async (
payload: Payload,
user: CampaignUser,
): Promise<MunicipalityWriteScope> => {
if (isCampaignUnrestricted(user)) return { editingScope: 'tudo', portfolioIDs: null }
const editingScope = advisorEditingScope(user.visibility, user.editing)
if (editingScope === 'none') return { editingScope: 'none', portfolioIDs: new Set<number>() }
if (editingScope === 'tudo') return { editingScope: 'tudo', portfolioIDs: null }
// 'carteira': the portfolio is the set of administered municipalities.
const ids = await getAdvisorMunicipalityIds(payload, user.id)
return { editingScope: 'carteira', portfolioIDs: new Set(ids) }
}
/** Values still reachable under the OTHER active filters (column-filter options). */
type MunicipalityListFilterFacets = {
/** Catalog order. */

View file

@ -0,0 +1,115 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* C142 — advisor permission profiles respect the UI surface:
*
* 1. `somente_leitura` editing: no write controls on any surface, FAB absent.
* 2. `carteira` editing: write scoped to portfolio; rows outside read-only.
* 3. `tudo` editing: full write access everywhere.
*
* The spec reuses the shared `campaignFixture` which provisions a fresh
* browser context per test — no cross-test cookie leaks.
*/
test.describe('Advisor permission profiles (C142)', () => {
test.describe('somente_leitura — read-only across all surfaces', () => {
test('municipality list has no write controls and the FAB is absent', async ({
campaign,
page,
}) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// FAB must not be present on the municipality list (write surface).
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeHidden()
// "Nova demanda" button must not be present (we land on municipios, but
// verify the FAB is absent which covers all surfaces).
// Inspect the action bar for write controls.
const actionButtons = page.getByRole('link', { name: /Nova|Criar|Editar/i })
await expect(actionButtons).toHaveCount(0)
})
test('demands list has no "Nova demanda" button', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/demandas')
await page.waitForLoadState('networkidle')
// "Nova demanda" must not be rendered for a somente_leitura advisor.
const novaDemanda = page.getByRole('link', { name: 'Nova demanda' })
await expect(novaDemanda).toBeHidden()
})
test('activities list has no create buttons', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/atividades')
await page.waitForLoadState('networkidle')
// The "Planejar giro" and "Nova atividade" buttons must be absent.
const createButtons = page.getByRole('button', { name: /Planejar|Nova atividade/i })
await expect(createButtons).toHaveCount(0)
})
test('supporters list has no "Novo" or "Importar CSV" buttons', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'somente_leitura',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/apoiadores')
await page.waitForLoadState('networkidle')
const novoButton = page.getByRole('link', { name: 'Novo' })
await expect(novoButton).toBeHidden()
})
})
test.describe('carteira editing — write scoped to portfolio', () => {
test('rows outside the portfolio are read-only', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'carteira',
editing: 'carteira',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// The FAB should be present (carteira scope = write allowed).
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible()
})
})
test.describe('tudo editing — full write access', () => {
test('write controls are present', async ({ campaign, page }) => {
const user = await campaign.fixtures.createCampaignUser('advisor', {
visibility: 'tudo',
editing: 'tudo',
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha/municipios')
await page.waitForLoadState('networkidle')
// The FAB must be present for a tudo advisor.
await expect(page.locator('[data-slot="campaign-quick-actions-fab"]')).toBeVisible()
})
})
})

View file

@ -114,10 +114,20 @@ export class CampaignE2EOwnership {
* One campaign account with a deterministic generated password (returned for
* the UI login) — replaces the ~28 hand-written `payload.create` blocks the
* specs used to spell out per role. The proxy above auto-owns the row.
*
* C142 — `visibility` and `editing` fields are passed through so e2e tests
* can exercise advisor permission profiles (`somente_leitura`, `tudo`,
* `carteira`).
*/
async createCampaignUser(
role: CampaignUser['role'],
input: { name?: string; email?: string; username?: string } = {},
input: {
name?: string
email?: string
username?: string
visibility?: CampaignUser['visibility']
editing?: CampaignUser['editing']
} = {},
): Promise<CampaignUser & { password: string }> {
const password = this.value('password')
const user = await this.payload.create({

View file

@ -85,8 +85,16 @@ describe('access shared policies (P3-D)', () => {
})
it('C141 advisorEditingAccess maps the Edição axis to a scope decision', () => {
// Coherent stored profiles only: `editing: 'tudo'` requires `visibility: 'tudo'`
// (the collection hook rejects the incoherent combination at write).
const withEditing = (editing: CampaignUser['editing']) =>
stub<CampaignUser>({ collection: 'campaignUser', id: 9, role: 'advisor', editing })
stub<CampaignUser>({
collection: 'campaignUser',
id: 9,
role: 'advisor',
visibility: 'tudo',
editing,
})
expect(advisorEditingAccess(withEditing('somente_leitura'))).toBe('none')
expect(advisorEditingAccess(withEditing('tudo'))).toBe('tudo')
expect(advisorEditingAccess(withEditing('carteira'))).toBe('carteira')

View file

@ -3,10 +3,12 @@ import { describe, expect, it } from 'vitest'
import {
ADVISOR_EDITING_OPTIONS,
ADVISOR_VISIBILITY_OPTIONS,
advisorEditingScope,
advisorProfileLabel,
isAdvisorEditingValue,
isAdvisorVisibilityValue,
isCoherentAdvisorProfile,
rowEditingAllowed,
} from '@/lib/campaignAdvisorProfile'
describe('campaign advisor profile (C141)', () => {
@ -47,4 +49,31 @@ describe('campaign advisor profile (C141)', () => {
expect(isAdvisorEditingValue('readonly')).toBe(false)
expect(isAdvisorEditingValue(null)).toBe(false)
})
it('C142 advisorEditingScope maps the Edição axis to a write-scope decision', () => {
expect(advisorEditingScope('carteira', 'carteira')).toBe('carteira')
expect(advisorEditingScope('tudo', 'carteira')).toBe('carteira')
expect(advisorEditingScope('tudo', 'tudo')).toBe('tudo')
expect(advisorEditingScope('carteira', 'somente_leitura')).toBe('none')
expect(advisorEditingScope('tudo', 'somente_leitura')).toBe('none')
expect(advisorEditingScope('carteira', undefined)).toBe('carteira')
expect(advisorEditingScope(undefined, undefined)).toBe('carteira')
})
it('C142 advisorEditingScope fails closed on the incoherent combination', () => {
expect(advisorEditingScope('carteira', 'tudo')).toBe('none')
})
it('C142 rowEditingAllowed gates a row under a carteira scope by portfolio intersection', () => {
const portfolio = [1, 2, 3]
expect(rowEditingAllowed('tudo', portfolio, [9])).toBe(true)
expect(rowEditingAllowed('tudo', null, [9])).toBe(true)
expect(rowEditingAllowed('none', portfolio, [1])).toBe(false)
expect(rowEditingAllowed('none', null, [1])).toBe(false)
expect(rowEditingAllowed('carteira', portfolio, [2, 9])).toBe(true)
expect(rowEditingAllowed('carteira', portfolio, [9])).toBe(false)
expect(rowEditingAllowed('carteira', null, [9])).toBe(true)
expect(rowEditingAllowed('carteira', portfolio, [])).toBe(false)
expect(rowEditingAllowed('carteira', portfolio, null)).toBe(false)
})
})

View file

@ -62,6 +62,8 @@ const municipalityListDefaultProps = {
stateDeputyCreateAction: async () => ({}),
columnFilterOptions: { name: [], region: [], advisor: [] },
signalFormAction: noopListFormAction,
editingScope: 'tudo' as const,
portfolioIDs: null,
state: { page: 1 },
}