B145 — move entity identity to shell header on detail pages

- Detail routes return null from campaignPageChrome catalog (override per page)
- SetCampaignPageChrome + generateMetadata on 8 entity detail routes
- Remove body hero (h1, Voltar links, duplicate identity chips)
- Keep operational badges/chips in page body
- Update zone map e2e to assert shell chrome title slot

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:13:59 +00:00
parent deefe566b0
commit 5696aef9ed
No known key found for this signature in database
12 changed files with 347 additions and 207 deletions

View file

@ -0,0 +1,77 @@
# Impl: Detalhe de entidade — título no header (sem hero espaçoso)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #315
Intenção: docs/plans/detalhe-entidade-titulo-no-header.md
Appetite restante: ~0,5 dia eng (8 rotas de detalhe + catálogo + e2e)
## Leitura da intenção
- **Outcome:** Em cada detalhe de entidade em `/campanha`, a identidade do registro (título + subtítulo) vive no header da shell; o corpo começa direto em abas/seções/chips operacionais, sem hero nem “Voltar para…”.
- **O que NÃO negociar:** listas mantêm header de seção (B123/B133); edit routes inalteradas; chips operacionais no corpo; leader lockdown; aba do browser alinhada à entidade.
- **O que reavaliar:** hipótese de tocar só `campaignPageChrome` — insuficiente: títulos são dinâmicos por registro; precedente de `SetCampaignPageChrome` + `generateMetadata` nas rotas `/editar` é o dono.
## Abordagem recomendada
```mermaid
flowchart LR
RSC[Detail page RSC] --> MD[generateMetadata]
RSC --> SC[SetCampaignPageChrome]
SC --> Shell[CampaignPageChromeDisplay]
RSC --> Body[Corpo sem hero]
```
**Opções consideradas:**
- A) Estender `resolveCampaignPageChrome` com dados de entidade (impossível — catálogo é estático/client-safe)
- B) `SetCampaignPageChrome` + `generateMetadata` por rota (precedente edit routes)
- C) Novo provider server-side de chrome
**Recomendação:** B — reusa `SetCampaignPageChrome` e `campaignPageMetadata` já usados em `/editar` e `MunicipalityListPageChrome`; path rules de detalhe passam a `null` (como edit) para não flashar título de seção antes do override.
**Rejeitadas:** C (twin desnecessário); A (dados dinâmicos não cabem no catálogo).
### Componentes / mudanças
- **`campaignPageChrome.ts`:** regras de detalhe retornam `null`; teste unitário atualizado.
- **8 rotas de detalhe** (`municipios/[slug]`, `liderancas/[id]`, `atividades/[slug]`, `demandas/[slug]`, `dobradinhas/[slug]`, `organizacoes/[slug]`, `apoiadores/[id]`, `assessores/[id]`): `generateMetadata` + `SetCampaignPageChrome`; remover hero (`h1`, “Voltar…”, chips de identidade duplicados, linha Assessoria no município).
- **`campaignZoneMap.e2e.spec.ts`:** assert no chrome da shell (`data-slot=campaign-page-chrome-title`) em vez de `h1` no corpo.
- **Migration:** sem migration
- **Access / Consent:** inalterado
### Mapeamento chrome por rota
| Rota | title | subtitle |
| ---- | ----- | -------- |
| municipios/[slug] | view.name | formatMunicipalityGeographyLabel(view) |
| liderancas/[id] | Liderança | leadership.name |
| atividades/[slug] | view.title | view.locationLabel ou municipality.name |
| demandas/[slug] | demand.title | `${kind} · ${municipalityName}` |
| dobradinhas/[slug] | stateDeputy.name | party (opcional) |
| organizacoes/[slug] | organization.name | organizationKindLabels[kind] |
| apoiadores/[id] | Apoiador | supporter.name |
| assessores/[id] | Assessor | advisor.name |
## Fases verificáveis
1. **Catálogo + testes** — path rules `null` para detalhes; unit spec
2. **UI — 8 páginas** — chrome override + remoção de hero; metadata dinâmica
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Novo bloco UI para Assessoria no município
- Wizard, giros, perfil, contatos, rotas `/editar`, listas
- Refatorar `CampaignPageChromeText` (layout já suporta subtitle)
## Riscos e mitigação
- **Flash de título de seção:** path rule `null` + `useLayoutEffect` do override (mesmo que edit routes)
- **E2E que asserta `h1`:** atualizar para chrome slot
- **Atividade sem locationLabel:** fallback para `municipality?.name`
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit chrome + e2e zone map)

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react'
import { ShieldCheckIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
@ -9,10 +9,10 @@ import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntentionControl'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadSupporterDetailConsentData,
@ -25,7 +25,21 @@ import {
} from '@/utilities/supporter/supporterUi'
import { parseSupporterId } from '@/utilities/supporter/supporterViewModels'
export const metadata = campaignPageMetadataFromCatalog('apoiadores')
export async function generateMetadata({ params }: SupporterDetailPageProps) {
const { id: rawId } = await params
const supporterId = parseSupporterId(rawId)
if (supporterId === null) return campaignPageMetadata({ title: 'Apoiador' })
const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })])
if (!canAccessSupporterArea(user.role)) return campaignPageMetadata({ title: 'Apoiador' })
try {
const supporter = await loadSupporterDetailPageData(payload, user, supporterId)
return campaignPageMetadata({ title: 'Apoiador', subtitle: supporter.name })
} catch {
return campaignPageMetadata({ title: 'Apoiador' })
}
}
type SupporterDetailPageProps = {
params: Promise<{ id: string }>
@ -65,19 +79,11 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para apoiadores
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
{supporter.city ?? 'Cidade não informada'}
{supporter.territory ? ` · ${supporter.territory}` : ''}
</p>
</header>
<SetCampaignPageChrome chrome={{ title: 'Apoiador', subtitle: supporter.name }} />
<p className="text-muted-foreground">
{supporter.city ?? 'Cidade não informada'}
{supporter.territory ? ` · ${supporter.territory}` : ''}
</p>
<Card>
<CardHeader>

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { ArrowLeftIcon, MapPinIcon } from 'lucide-react'
import { MapPinIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
@ -7,16 +7,28 @@ import { getPayload } from 'payload'
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
export const metadata = campaignPageMetadataFromCatalog('assessores')
export async function generateMetadata({ params }: AdvisorDetailPageProps) {
const { id: rawId } = await params
const advisorId = Number(rawId)
if (!Number.isInteger(advisorId) || advisorId <= 0) {
return campaignPageMetadata({ title: 'Assessor' })
}
const payload = await getPayload({ config })
const advisor = await loadAdvisorDetail(payload, advisorId)
if (!advisor) return campaignPageMetadata({ title: 'Assessor' })
return campaignPageMetadata({ title: 'Assessor', subtitle: advisor.name })
}
type AdvisorDetailPageProps = {
params: Promise<{ id: string }>
@ -39,19 +51,11 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/assessores">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para assessores
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{advisor.name}</h1>
<p className="text-muted-foreground">
E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo
edição da lista.
</p>
</header>
<SetCampaignPageChrome chrome={{ title: 'Assessor', subtitle: advisor.name }} />
<p className="text-muted-foreground">
E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo
edição da lista.
</p>
<section aria-labelledby="advisor-account-title" className="flex flex-col gap-2">
<h2 id="advisor-account-title" className="text-base font-medium">

View file

@ -14,10 +14,11 @@ import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTa
import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed'
import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm'
import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { activityKindLabels } from '@/lib/schemas/activity'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
@ -35,7 +36,27 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { ActivityOverviewTab } from './ActivityOverviewTab'
export const metadata = campaignPageMetadataFromCatalog('atividades')
export async function generateMetadata({ params }: ActivityDetailPageProps) {
const { slug } = await params
if (!slug) return campaignPageMetadata({ title: 'Atividade' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
try {
const activeTab = 'overview' as const
const context = await resolveAccessibleActivityContext(payload, user, slug, activeTab)
const view = await getActivityDetailPageData(payload, user, context, activeTab, 1)
const subtitle = view.locationLabel || view.municipality?.name
return campaignPageMetadata(
subtitle ? { title: view.title, subtitle } : { title: view.title },
)
} catch {
return campaignPageMetadata({ title: 'Atividade' })
}
}
type ActivityDetailPageProps = {
params: Promise<{ slug: string }>
@ -73,30 +94,31 @@ export default async function ActivityDetailPage({
const isStaff = isCampaignStaff(user)
const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado'
const chromeSubtitle = view.locationLabel || view.municipality?.name
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<SetCampaignPageChrome
chrome={
chromeSubtitle
? { title: view.title, subtitle: chromeSubtitle }
: { title: view.title }
}
/>
<CampaignQuickActionContextBridge
activitySlug={view.slug}
municipalitySlug={view.municipality?.slug}
/>
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{view.title}</h1>
<ActivityStatusBadge status={view.status} />
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground">
<Badge variant="secondary">{activityKindLabels[view.kind]}</Badge>
<span>
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>
{view.locationLabel ? <span>· {view.locationLabel}</span> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex flex-wrap items-center gap-2">
<ActivityStatusBadge status={view.status} />
{view.deputyPresent ? <Badge>Deputado presente</Badge> : null}
<Badge variant="secondary">{activityKindLabels[view.kind]}</Badge>
<span className="text-muted-foreground">
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
</span>
</div>
<div className="flex flex-wrap gap-2">
{isStaff ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/atividades/${view.slug}/editar`}>
@ -119,7 +141,6 @@ export default async function ActivityDetailPage({
) : null}
</div>
</div>
</header>
<ActivityTabNav activeTab={activeTab} activitySlug={view.slug} searchParams={query} />

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { ArrowLeftIcon, PaperclipIcon } from 'lucide-react'
import { PaperclipIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
@ -7,9 +7,9 @@ import { getPayload } from 'payload'
import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
@ -24,7 +24,21 @@ import {
transitionDemandFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('demandas')
export async function generateMetadata({ params }: DemandDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const demand = await loadDemandDetail(payload, user, slug)
if (!demand) return campaignPageMetadata({ title: 'Demanda' })
return campaignPageMetadata({
title: demand.title,
subtitle: `${campaignDemandKindLabels[demand.kind]} · ${demand.municipalityName}`,
})
}
type DemandDetailPageProps = {
params: Promise<{ slug: string }>
@ -63,48 +77,44 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps
return (
<CampaignPageShell>
<SetCampaignPageChrome
chrome={{
title: demand.title,
subtitle: `${campaignDemandKindLabels[demand.kind]} · ${demand.municipalityName}`,
}}
/>
<CampaignQuickActionContextSync
municipalitySlug={demand.municipalitySlug}
municipalityId={demand.municipalityId > 0 ? demand.municipalityId : undefined}
demandSlug={demand.slug}
/>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/demandas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para demandas
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{demand.title}</h1>
<Badge variant={statusVariant[demand.status]}>
{campaignDemandStatusLabels[demand.status]}
</Badge>
</div>
<p className="text-muted-foreground">
{campaignDemandKindLabels[demand.kind]} ·{' '}
<Link
href={`/campanha/municipios/${demand.municipalitySlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.municipalityName}
</Link>
{demand.activity ? (
<>
{' '}
· Atividade:{' '}
<Link
href={`/campanha/atividades/${demand.activity.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.activity.title}
</Link>
</>
) : null}
{demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '}
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>
</header>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant[demand.status]}>
{campaignDemandStatusLabels[demand.status]}
</Badge>
</div>
<p className="text-muted-foreground">
<Link
href={`/campanha/municipios/${demand.municipalitySlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.municipalityName}
</Link>
{demand.activity ? (
<>
{' '}
· Atividade:{' '}
<Link
href={`/campanha/atividades/${demand.activity.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{demand.activity.title}
</Link>
</>
) : null}
{demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '}
{dateTimeFormatter.format(new Date(demand.createdAt))}
</p>
{demand.description ? (
<section aria-label="Descrição da demanda" className="rounded-xl border p-4">

View file

@ -1,19 +1,34 @@
import config from '@payload-config'
import { ArrowLeftIcon, HandshakeIcon, MapPinIcon } from 'lucide-react'
import { HandshakeIcon, MapPinIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyDetail } from '@/utilities/stateDeputyData'
import { updateStateDeputyFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
export async function generateMetadata({ params }: StateDeputyDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const stateDeputy = await loadStateDeputyDetail(payload, user, slug)
if (!stateDeputy) return campaignPageMetadata({ title: 'Dobradinha' })
return campaignPageMetadata(
stateDeputy.party
? { title: stateDeputy.name, subtitle: stateDeputy.party }
: { title: stateDeputy.name },
)
}
type StateDeputyDetailPageProps = {
params: Promise<{ slug: string }>
@ -31,21 +46,16 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/dobradinhas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para dobradinhas
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{stateDeputy.name}</h1>
{stateDeputy.party ? <Badge variant="secondary">{stateDeputy.party}</Badge> : null}
</div>
{stateDeputy.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>
) : null}
</header>
<SetCampaignPageChrome
chrome={
stateDeputy.party
? { title: stateDeputy.name, subtitle: stateDeputy.party }
: { title: stateDeputy.name }
}
/>
{stateDeputy.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{stateDeputy.notes}</p>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<section

View file

@ -1,6 +1,4 @@
import config from '@payload-config'
import { ArrowLeftIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
@ -9,10 +7,10 @@ import { LeadershipInternalForm } from '@/components/campaign/leadership/Leaders
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
@ -24,7 +22,20 @@ import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { updateLeadershipInternalFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancas')
export async function generateMetadata({ params }: LeadershipDetailPageProps) {
const { id } = await params
if (!/^[1-9]\d*$/.test(id)) return campaignPageMetadata({ title: 'Liderança' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const leadership = await loadLeadershipDetail(payload, user, Number(id))
if (!leadership) return campaignPageMetadata({ title: 'Liderança' })
return campaignPageMetadata({ title: 'Liderança', subtitle: leadership.name })
}
type LeadershipDetailPageProps = {
params: Promise<{ id: string }>
@ -58,34 +69,26 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
return (
<CampaignPageShell>
<SetCampaignPageChrome chrome={{ title: 'Liderança', subtitle: leadership.name }} />
<CampaignQuickActionContextSync
leadershipId={leadership.id}
municipalitySlug={singleMunicipalitySlug}
/>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para lideranças
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{leadership.name}</h1>
{leadership.supportStatus ? (
<SupportStatusBadge status={leadership.supportStatus} />
) : null}
<Badge variant={leadership.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'}
</Badge>
</div>
<p className="text-muted-foreground">
{leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
{leadership.email ? ` · ${leadership.email}` : ''}
</p>
{leadership.stateDeputies.length > 0 ? (
<StateDeputyChips deputies={leadership.stateDeputies} />
<div className="flex flex-wrap items-center gap-2">
{leadership.supportStatus ? (
<SupportStatusBadge status={leadership.supportStatus} />
) : null}
</header>
<Badge variant={leadership.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'}
</Badge>
</div>
<p className="text-muted-foreground">
{leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
{leadership.email ? ` · ${leadership.email}` : ''}
</p>
{leadership.stateDeputies.length > 0 ? (
<StateDeputyChips deputies={leadership.stateDeputies} />
) : null}
<section
aria-labelledby="leadership-invites-title"

View file

@ -7,8 +7,8 @@ import { Suspense } from 'react'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
resolveMunicipalityDetailTab,
@ -16,7 +16,6 @@ import {
} from '@/utilities/municipality/municipalityDetailTabUi'
import {
formatMunicipalityGeographyLabel,
municipalityKindLabels,
} from '@/utilities/municipality/municipalityLabels'
import {
getMunicipalityDetailViewModel,
@ -34,15 +33,28 @@ import {
UpdatesTab,
} from './MunicipalityDetailTabs'
export const metadata = campaignPageMetadataFromCatalog('municipios')
export async function generateMetadata({ params }: MunicipalityDetailPageProps) {
const { slug } = await params
const payload = await getPayload({ config })
const user = await requireCampaignPageActor({ gate: 'noLeader' })
try {
const context = await resolveAccessibleMunicipalityContext(payload, user, slug)
const view = await getMunicipalityDetailViewModel(payload, context, user)
return campaignPageMetadata({
title: view.name,
subtitle: formatMunicipalityGeographyLabel(view),
})
} catch {
return campaignPageMetadata({ title: 'Município' })
}
}
type MunicipalityDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<MunicipalityDetailSearchParams>
}
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
export default async function MunicipalityDetailPage({
params,
searchParams,
@ -68,22 +80,12 @@ export default async function MunicipalityDetailPage({
return (
<CampaignPageShell>
{/* print: the dossier tab carries its own capa; page chrome stays out. */}
<header className="flex flex-col gap-2 print:hidden">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{view.name}</h1>
<Badge variant="scope">{municipalityKindLabels[view.kind]}</Badge>
</div>
<p className="text-muted-foreground">{formatMunicipalityGeographyLabel(view)}</p>
<p className="text-sm text-muted-foreground">
{advisorSummaries.length
? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}`
: 'Sem assessor designado.'}
{view.lastUpdateAt
? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}`
: ''}
</p>
</header>
<SetCampaignPageChrome
chrome={{
title: view.name,
subtitle: formatMunicipalityGeographyLabel(view),
}}
/>
<MunicipalityTabNav
activeTab={activeTab}

View file

@ -1,5 +1,5 @@
import config from '@payload-config'
import { ArrowLeftIcon, CalendarDaysIcon, HandshakeIcon } from 'lucide-react'
import { CalendarDaysIcon, HandshakeIcon } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
@ -7,16 +7,30 @@ import { getPayload } from 'payload'
import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { loadOrganizationDetail } from '@/utilities/organizationData'
import { updateOrganizationFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('organizacoes')
export async function generateMetadata({ params }: OrganizationDetailPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
getPayload({ config }),
])
const organization = await loadOrganizationDetail(payload, user, slug)
if (!organization) return campaignPageMetadata({ title: 'Organização' })
return campaignPageMetadata({
title: organization.name,
subtitle: organizationKindLabels[organization.kind],
})
}
type OrganizationDetailPageProps = {
params: Promise<{ slug: string }>
@ -38,26 +52,20 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/organizacoes">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para organizações
</Link>
</Button>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{organization.name}</h1>
<Badge variant="secondary">{organizationKindLabels[organization.kind]}</Badge>
</div>
{organization.municipalityNames.length ? (
<p className="text-muted-foreground">
Atua em {organization.municipalityNames.join(', ')}
</p>
) : null}
{organization.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{organization.notes}</p>
) : null}
</header>
<SetCampaignPageChrome
chrome={{
title: organization.name,
subtitle: organizationKindLabels[organization.kind],
}}
/>
{organization.municipalityNames.length ? (
<p className="text-muted-foreground">
Atua em {organization.municipalityNames.join(', ')}
</p>
) : null}
{organization.notes ? (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">{organization.notes}</p>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<section

View file

@ -102,8 +102,6 @@ const resolveCatalogEntry = (
: { title: entry.title }
}
const sectionOnly = (title: string): CampaignPageChrome => ({ title })
type PathRule = {
match: (pathname: string) => boolean
resolve: () => CampaignPageChrome | null
@ -128,12 +126,12 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
resolve: () => null,
},
{
// B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now.
match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
resolve: () => ({ title: campaignPageChromeCatalog.municipios.title }),
},
{
match: (pathname) => pathname === '/campanha/territorios',
@ -145,7 +143,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.liderancas.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/liderancas',
@ -157,7 +155,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.dobradinhas.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/dobradinhas',
@ -177,7 +175,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.atividades.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/atividades',
@ -189,7 +187,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.demandas.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/demandas',
@ -205,7 +203,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.apoiadores.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/apoiadores',
@ -213,7 +211,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.assessores.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/assessores',
@ -225,7 +223,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.organizacoes.title),
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/organizacoes',

View file

@ -49,9 +49,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca
await expect(readout).toContainText(/Salvador — ZE \d+/)
await readout.getByRole('link', { name: 'Abrir município' }).click()
// The slug is the assertion: the detail page's own <h1> reads the municipality
// ROW, which other specs rename, while the polygon's name comes from the
// static catalog.
// B145: entity identity lives in the shell header, not a body h1.
await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.locator('[data-slot="campaign-page-chrome-title"]')).toBeVisible()
})

View file

@ -21,10 +21,13 @@ describe('campaignPageChrome', () => {
})
})
it('resolves detail pages with section title only', () => {
expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({
title: 'Municípios',
})
it('returns null for entity detail pages (chrome set per route)', () => {
expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toBeNull()
expect(resolveCampaignPageChrome('/campanha/liderancas/42', 'advisor')).toBeNull()
expect(resolveCampaignPageChrome('/campanha/atividades/foo', 'coordinator')).toBeNull()
})
it('keeps section title on parallel municipio v2 route (B147 soft-dep)', () => {
expect(resolveCampaignPageChrome('/campanha/municipio/cairu/v2', 'coordinator')).toEqual({
title: 'Municípios',
})