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:
parent
0822cca82c
commit
0d483a08db
57 changed files with 1155 additions and 257 deletions
131
docs/plans/ui-respeita-perfil-permissao-impl.md
Normal file
131
docs/plans/ui-respeita-perfil-permissao-impl.md
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)} (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'})
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
115
tests/e2e/campaignPermissionProfile.e2e.spec.ts
Normal file
115
tests/e2e/campaignPermissionProfile.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ const municipalityListDefaultProps = {
|
|||
stateDeputyCreateAction: async () => ({}),
|
||||
columnFilterOptions: { name: [], region: [], advisor: [] },
|
||||
signalFormAction: noopListFormAction,
|
||||
editingScope: 'tudo' as const,
|
||||
portfolioIDs: null,
|
||||
state: { page: 1 },
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue