Pass 2 W4c: RSCs das paginas de detalhe extraidos + gemeos de tab dedupados

- municipios/[slug]/page.tsx 391->161 linhas: tabs (Overview/Dossier/Elections/Leaderships/Updates + fallback) em MunicipalityDetailTabs.tsx colocado na rota
- planos/[slug]/page.tsx 323->137 linhas: visao geral em ActionPlanOverviewTab.tsx
- createDetailTabHelpers (detailTabUi.ts) dedupa municipalityDetailTabUi/actionPlanDetailTabUi (resolve/href/redirect identicos; modulos viram config + re-export)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 15:21:58 -03:00
parent e357c3cc19
commit c52e0e7957
7 changed files with 591 additions and 533 deletions

View file

@ -0,0 +1,249 @@
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import { CandidateComparePicker } from '@/components/campaign/map/CandidateComparePicker'
import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard'
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/municipality/MunicipalityCandidateComparisonTable'
import { MunicipalityDossier } from '@/components/campaign/municipality/MunicipalityDossier'
import { MunicipalityGoalAccountCard } from '@/components/campaign/municipality/MunicipalityGoalAccountCard'
import { MunicipalityLeadershipsPanel } from '@/components/campaign/municipality/MunicipalityLeadershipsPanel'
import { MunicipalityPledgesPanel } from '@/components/campaign/municipality/MunicipalityPledgesPanel'
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import type { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadMunicipalityLeaderships } from '@/utilities/leadershipData'
import {
loadMunicipalityCandidateComparison,
MAX_COMPARISON_CANDIDATES,
} from '@/utilities/municipalityCandidateComparison'
import type { MunicipalityDetailSearchParams } from '@/utilities/municipalityDetailTabUi'
import { loadMunicipalityDossierData } from '@/utilities/municipalityDossierData'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
import type { getMunicipalityDetailViewModel } from '@/utilities/municipalityPageData'
import {
loadMunicipalityUpdatesFeed,
parseMunicipalityUpdateFeedParams,
} from '@/utilities/municipalityUpdatePageData'
import type { loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
import {
aggregateMunicipalityPledgesFromRows,
toMunicipalityPledgeCoverageView,
type MunicipalityPledgeAggregate,
type MunicipalityPledgeCoverageView,
} from '@/utilities/votePledgeViews'
import type { getPayload } from 'payload'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createMunicipalityUpdateFormAction } from './updateFormActions'
type PayloadUser = {
payload: Awaited<ReturnType<typeof getPayload>>
user: NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
}
type MunicipalityDetailView = Awaited<ReturnType<typeof getMunicipalityDetailViewModel>>
export const MunicipalityTabFallback = () => (
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
<div className="h-64 w-full animate-pulse rounded-xl border bg-muted/40" />
</div>
)
export const OverviewTab = async ({
view,
payloadUser: { payload, user },
}: {
view: MunicipalityDetailView
payloadUser: PayloadUser
}) => {
const zoneNeighborhoodsCard =
view.kind === 'zona' ? <MunicipalityZoneNeighborhoodsCard municipalitySlug={view.slug} /> : null
const pledges = await loadMunicipalityPledges(payload, user, view.id)
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
const pledgeCoverage = toMunicipalityPledgeCoverageView(pledgeAggregate)
return (
<div className="flex flex-col gap-6">
{zoneNeighborhoodsCard}
{view.strategy ? (
<>
<MunicipalityStrategyCard strategy={view.strategy} municipalitySlug={view.slug} canEdit />
<GoalAccountCard
payloadUser={{ payload, user }}
municipalityID={view.id}
slug={view.slug}
expectedVotes={view.strategy.expectedVotes}
pledgeCoverage={pledgeCoverage}
pledgeAggregate={pledgeAggregate}
/>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
</div>
)
}
const GoalAccountCard = async ({
payloadUser: { payload, user },
municipalityID,
slug,
expectedVotes,
pledgeCoverage,
pledgeAggregate,
}: {
payloadUser: PayloadUser
municipalityID: number
slug: string
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
}) => {
const { suggestedGoal, goalCoverage, potential } = await loadMunicipalityGoalAccount(
payload,
user,
{ slug, expectedVotes },
pledgeAggregate,
)
return (
<MunicipalityGoalAccountCard
municipalityID={municipalityID}
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
suggestedGoal={suggestedGoal}
goalCoverage={goalCoverage}
potential={potential}
/>
)
}
export const DossierTab = async ({
view,
advisorSummaries,
payloadUser: { payload, user },
}: {
view: MunicipalityDetailView
advisorSummaries: Awaited<ReturnType<typeof loadAdvisorSummaries>>
payloadUser: PayloadUser
}) => {
const data = await loadMunicipalityDossierData(payload, user, view)
return <MunicipalityDossier view={view} advisors={advisorSummaries} data={data} />
}
export const ElectionsTab = async ({
slug,
rawSearchParams,
payloadUser: { user },
}: {
slug: string
rawSearchParams: MunicipalityDetailSearchParams
payloadUser: PayloadUser
}) => {
const geography = municipalityElectionGeographyForSlug(slug)
const rawCompare = rawSearchParams.compare
const compareNumbers = [
...new Set(
(Array.isArray(rawCompare) ? rawCompare : rawCompare ? [rawCompare] : [])
.filter((value) => /^[1-9]\d*$/.test(value))
.map(Number),
),
].slice(0, MAX_COMPARISON_CANDIDATES)
const [baseline, comparisonRows, candidateOptions] = await Promise.all([
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
geography
? loadMunicipalityCandidateComparison(user, geography, compareNumbers)
: ([] as Awaited<ReturnType<typeof loadMunicipalityCandidateComparison>>),
loadFederalCandidateOptions(user),
])
return (
<div className="flex flex-col gap-6">
<MunicipalityBaselineCard baseline={baseline} municipalitySlug={slug} />
<section
aria-labelledby="municipality-comparison-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="municipality-comparison-title" className="text-base font-medium">
Comparativo entre candidatos
</h2>
<p className="text-sm text-muted-foreground">
Compare a votação de deputados federais neste município através dos anos (fonte TSE,
votos nominais do 1º turno).
</p>
</div>
<CandidateComparePicker
options={candidateOptions}
selectedNumbers={compareNumbers}
maxSelected={MAX_COMPARISON_CANDIDATES}
/>
{comparisonRows.length ? (
<MunicipalityCandidateComparisonTable rows={comparisonRows} />
) : null}
</section>
</div>
)
}
export const LeadershipsTab = async ({
municipalityID,
payloadUser: { payload, user },
}: {
municipalityID: number
payloadUser: PayloadUser
}) => {
const [leaderships, pledges] = await Promise.all([
loadMunicipalityLeaderships(payload, user, municipalityID),
loadMunicipalityPledges(payload, user, municipalityID),
])
return (
<MunicipalityLeadershipsPanel
municipalityID={municipalityID}
leaderships={leaderships}
pledges={pledges}
declareFormAction={declareVotesFormAction}
/>
)
}
export const UpdatesTab = async ({
municipalityID,
municipalitySlug,
rawSearchParams,
payloadUser: { payload, user },
}: {
municipalityID: number
municipalitySlug: string
rawSearchParams: MunicipalityDetailSearchParams
payloadUser: PayloadUser
}) => {
const feedState = parseMunicipalityUpdateFeedParams(rawSearchParams)
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, feedState)
return (
<div className="flex flex-col gap-6">
<MunicipalityUpdateForm
municipalityID={municipalityID}
formAction={createMunicipalityUpdateFormAction}
/>
<MunicipalityUpdateFeed updates={feed.updates} />
<CampaignListPagination
page={feed.page}
totalPages={feed.totalPages}
hrefForPage={(page) =>
`/campanha/municipios/${municipalitySlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}`
}
/>
</div>
)
}

View file

@ -4,57 +4,35 @@ import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/shared/CampaignListPagination'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { CandidateComparePicker } from '@/components/campaign/map/CandidateComparePicker'
import { MunicipalityBaselineCard } from '@/components/campaign/municipality/MunicipalityBaselineCard'
import { MunicipalityCandidateComparisonTable } from '@/components/campaign/municipality/MunicipalityCandidateComparisonTable'
import { MunicipalityDossier } from '@/components/campaign/municipality/MunicipalityDossier'
import { MunicipalityGoalAccountCard } from '@/components/campaign/municipality/MunicipalityGoalAccountCard'
import { MunicipalityLeadershipsPanel } from '@/components/campaign/municipality/MunicipalityLeadershipsPanel'
import { MunicipalityPledgesPanel } from '@/components/campaign/municipality/MunicipalityPledgesPanel'
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { MunicipalityUpdateForm } from '@/components/campaign/municipality/MunicipalityUpdateForm'
import { MunicipalityZoneNeighborhoodsCard } from '@/components/campaign/municipality/MunicipalityZoneNeighborhoodsCard'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { Badge } from '@/components/ui/Badge'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
import { loadMunicipalityLeaderships } from '@/utilities/leadershipData'
import {
loadMunicipalityCandidateComparison,
MAX_COMPARISON_CANDIDATES,
} from '@/utilities/municipalityCandidateComparison'
import {
resolveMunicipalityDetailTab,
type MunicipalityDetailSearchParams,
} from '@/utilities/municipalityDetailTabUi'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipalityElectionGeography'
import { loadMunicipalityElectoralBaseline } from '@/utilities/municipalityElectoralBaseline'
import { loadMunicipalityGoalAccount } from '@/utilities/municipalityGoalAccount'
import { loadMunicipalityDossierData } from '@/utilities/municipalityDossierData'
import {
getMunicipalityDetailViewModel,
MunicipalityNotFoundError,
resolveAccessibleMunicipalityContext,
} from '@/utilities/municipalityPageData'
import {
formatMunicipalityGeographyLabel,
municipalityKindLabels,
} from '@/utilities/municipalityLabels'
import {
loadMunicipalityUpdatesFeed,
parseMunicipalityUpdateFeedParams,
} from '@/utilities/municipalityUpdatePageData'
getMunicipalityDetailViewModel,
MunicipalityNotFoundError,
resolveAccessibleMunicipalityContext,
} from '@/utilities/municipalityPageData'
import { loadAdvisorSummaries } from '@/utilities/municipalityViewModels'
import type { VoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
import { loadMunicipalityPledges } from '@/utilities/votePledgeData'
import { aggregateMunicipalityPledgesFromRows, toMunicipalityPledgeCoverageView, type MunicipalityPledgeAggregate, type MunicipalityPledgeCoverageView } from '@/utilities/votePledgeViews'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createMunicipalityUpdateFormAction } from './updateFormActions'
import {
DossierTab,
ElectionsTab,
LeadershipsTab,
MunicipalityTabFallback,
OverviewTab,
UpdatesTab,
} from './MunicipalityDetailTabs'
type MunicipalityDetailPageProps = {
params: Promise<{ slug: string }>
@ -180,207 +158,3 @@ export default async function MunicipalityDetailPage({
</CampaignPageShell>
)
}
type PayloadUser = {
payload: Awaited<ReturnType<typeof getPayload>>
user: NonNullable<Awaited<ReturnType<typeof getCampaignUser>>>
}
const OverviewTab = async ({
view,
payloadUser: { payload, user },
}: {
view: Awaited<ReturnType<typeof getMunicipalityDetailViewModel>>
payloadUser: PayloadUser
}) => {
const zoneNeighborhoodsCard =
view.kind === 'zona' ? <MunicipalityZoneNeighborhoodsCard municipalitySlug={view.slug} /> : null
const pledges = await loadMunicipalityPledges(payload, user, view.id)
const pledgeAggregate = aggregateMunicipalityPledgesFromRows(pledges)
const pledgeCoverage = toMunicipalityPledgeCoverageView(pledgeAggregate)
return (
<div className="flex flex-col gap-6">
{zoneNeighborhoodsCard}
{view.strategy ? (
<>
<MunicipalityStrategyCard strategy={view.strategy} municipalitySlug={view.slug} canEdit />
<GoalAccountCard
payloadUser={{ payload, user }}
municipalityID={view.id}
slug={view.slug}
expectedVotes={view.strategy.expectedVotes}
pledgeCoverage={pledgeCoverage}
pledgeAggregate={pledgeAggregate}
/>
</>
) : null}
<MunicipalityPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
</div>
)
}
const GoalAccountCard = async ({
payloadUser: { payload, user },
municipalityID,
slug,
expectedVotes,
pledgeCoverage,
pledgeAggregate,
}: {
payloadUser: PayloadUser
municipalityID: number
slug: string
expectedVotes: VoteEstimateScenarioViewModel
pledgeCoverage: MunicipalityPledgeCoverageView | null
pledgeAggregate: MunicipalityPledgeAggregate
}) => {
const { suggestedGoal, goalCoverage, potential } = await loadMunicipalityGoalAccount(
payload,
user,
{ slug, expectedVotes },
pledgeAggregate,
)
return (
<MunicipalityGoalAccountCard
municipalityID={municipalityID}
expectedVotes={expectedVotes}
pledgeCoverage={pledgeCoverage}
suggestedGoal={suggestedGoal}
goalCoverage={goalCoverage}
potential={potential}
/>
)
}
const DossierTab = async ({
view,
advisorSummaries,
payloadUser: { payload, user },
}: {
view: Awaited<ReturnType<typeof getMunicipalityDetailViewModel>>
advisorSummaries: Awaited<ReturnType<typeof loadAdvisorSummaries>>
payloadUser: PayloadUser
}) => {
const data = await loadMunicipalityDossierData(payload, user, view)
return <MunicipalityDossier view={view} advisors={advisorSummaries} data={data} />
}
const MunicipalityTabFallback = () => (
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
<div className="h-64 w-full animate-pulse rounded-xl border bg-muted/40" />
</div>
)
const ElectionsTab = async ({
slug,
rawSearchParams,
payloadUser: { user },
}: {
slug: string
rawSearchParams: MunicipalityDetailSearchParams
payloadUser: PayloadUser
}) => {
const geography = municipalityElectionGeographyForSlug(slug)
const rawCompare = rawSearchParams.compare
const compareNumbers = [
...new Set(
(Array.isArray(rawCompare) ? rawCompare : rawCompare ? [rawCompare] : [])
.filter((value) => /^[1-9]\d*$/.test(value))
.map(Number),
),
].slice(0, MAX_COMPARISON_CANDIDATES)
const [baseline, comparisonRows, candidateOptions] = await Promise.all([
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
geography
? loadMunicipalityCandidateComparison(user, geography, compareNumbers)
: ([] as Awaited<ReturnType<typeof loadMunicipalityCandidateComparison>>),
loadFederalCandidateOptions(user),
])
return (
<div className="flex flex-col gap-6">
<MunicipalityBaselineCard baseline={baseline} municipalitySlug={slug} />
<section
aria-labelledby="municipality-comparison-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="municipality-comparison-title" className="text-base font-medium">
Comparativo entre candidatos
</h2>
<p className="text-sm text-muted-foreground">
Compare a votação de deputados federais neste município através dos anos (fonte TSE, votos
nominais do 1º turno).
</p>
</div>
<CandidateComparePicker
options={candidateOptions}
selectedNumbers={compareNumbers}
maxSelected={MAX_COMPARISON_CANDIDATES}
/>
{comparisonRows.length ? (
<MunicipalityCandidateComparisonTable rows={comparisonRows} />
) : null}
</section>
</div>
)
}
const LeadershipsTab = async ({
municipalityID,
payloadUser: { payload, user },
}: {
municipalityID: number
payloadUser: PayloadUser
}) => {
const [leaderships, pledges] = await Promise.all([
loadMunicipalityLeaderships(payload, user, municipalityID),
loadMunicipalityPledges(payload, user, municipalityID),
])
return (
<MunicipalityLeadershipsPanel
municipalityID={municipalityID}
leaderships={leaderships}
pledges={pledges}
declareFormAction={declareVotesFormAction}
/>
)
}
const UpdatesTab = async ({
municipalityID,
municipalitySlug,
rawSearchParams,
payloadUser: { payload, user },
}: {
municipalityID: number
municipalitySlug: string
rawSearchParams: MunicipalityDetailSearchParams
payloadUser: PayloadUser
}) => {
const feedState = parseMunicipalityUpdateFeedParams(rawSearchParams)
const feed = await loadMunicipalityUpdatesFeed(payload, user, municipalityID, feedState)
return (
<div className="flex flex-col gap-6">
<MunicipalityUpdateForm
municipalityID={municipalityID}
formAction={createMunicipalityUpdateFormAction}
/>
<MunicipalityUpdateFeed updates={feed.updates} />
<CampaignListPagination
page={feed.page}
totalPages={feed.totalPages}
hrefForPage={(page) =>
`/campanha/municipios/${municipalitySlug}?tab=updates${feedState.kind ? `&updateKind=${feedState.kind}` : ''}&updatePage=${page}`
}
/>
</div>
)
}

View file

@ -0,0 +1,210 @@
import { PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { ActionPlanResultForm } from '@/components/campaign/actionPlan/ActionPlanResultForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanOriginLabels } from '@/lib/schemas/actionPlan'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import type { getActionPlanDetailPageData } from '@/utilities/actionPlanDetailPageData'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
})
type ActionPlanDetailView = Awaited<ReturnType<typeof getActionPlanDetailPageData>>
/** The "Visão geral" tab: details, team/orgs, linked demands and the result block. */
export const ActionPlanOverviewTab = ({
view,
isStaff,
}: {
view: ActionPlanDetailView
isStaff: boolean
}) => (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.description ? (
<p className="whitespace-pre-wrap">{view.description}</p>
) : (
<p className="text-muted-foreground">Sem descrição.</p>
)}
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Município</dt>
<dd>
{view.municipality ? (
<Link
href={`/campanha/municipios/${view.municipality.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{view.municipality.name}
</Link>
) : (
'—'
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Local</dt>
<dd>{view.locality ?? '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Origem da ação</dt>
<dd>{actionPlanOriginLabels[view.origin]}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipe e organizações</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div>
<p className="text-muted-foreground">Assessores responsáveis</p>
{view.advisors.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.advisors.map((advisor) => (
<li key={advisor.id}>{advisor.name}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum assessor vinculado.</p>
)}
</div>
<div>
<p className="text-muted-foreground">Organizações apoiadoras</p>
{view.organizations.length ? (
<ul role="list" className="flex flex-wrap gap-1.5 pt-1">
{view.organizations.map((organization) => (
<li key={organization.id}>
<Badge variant="outline">{organization.name}</Badge>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhuma organização vinculada.</p>
)}
</div>
<p className="text-muted-foreground">
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
) : null}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<CardTitle>Demandas vinculadas</CardTitle>
<p className="text-sm text-muted-foreground">
Custo estimado: {currencyFormatter.format(view.demandCostTotal)}
</p>
</div>
{view.municipality ? (
<Button asChild variant="outline" className="min-h-11 shrink-0">
<Link
href={`/campanha/demandas/nova?actionPlan=${view.id}&municipality=${view.municipality.id}`}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda
</Link>
</Button>
) : null}
</CardHeader>
<CardContent>
{view.demands.length ? (
<ul role="list" className="divide-y">
{view.demands.map((demand) => (
<li
key={demand.id}
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<Link
href={`/campanha/demandas/${demand.slug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{demand.title}
</Link>
<p className="text-sm text-muted-foreground">
{campaignDemandKindLabels[demand.kind]} ·{' '}
{campaignDemandStatusLabels[demand.status]}
</p>
</div>
<span className="text-sm text-muted-foreground">
{demand.cost == null
? 'Custo não registrado'
: currencyFormatter.format(demand.cost)}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma demanda vinculada a este plano.
</p>
)}
</CardContent>
</Card>
{view.status === 'realizado' && isStaff ? (
<ActionPlanResultForm
planId={view.id}
initialSummary={view.result?.summary ?? null}
recordedByName={view.result?.recordedByName ?? null}
recordedAt={view.result?.recordedAt ?? null}
/>
) : view.result ? (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
<p className="text-sm text-muted-foreground">
{[
view.result.recordedByName
? `Registrado por ${view.result.recordedByName}`
: 'Registrado',
view.result.recordedAt
? `em ${formatBahiaDateTimeLabel(view.result.recordedAt)}`
: null,
]
.filter(Boolean)
.join(' ')}
</p>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{view.result.summary}</p>
</CardContent>
</Card>
) : null}
</div>
)

View file

@ -8,7 +8,6 @@ import {
CancelActionPlanDialog,
MarkActionPlanRealizedDialog,
} from '@/components/campaign/actionPlan/ActionPlanLifecycleDialog'
import { ActionPlanResultForm } from '@/components/campaign/actionPlan/ActionPlanResultForm'
import { ActionPlanStatusBadge } from '@/components/campaign/actionPlan/ActionPlanStatusBadge'
import { ActionPlanTabNav } from '@/components/campaign/actionPlan/ActionPlanTabNav'
import { ActionPlanTaskChecklist } from '@/components/campaign/actionPlan/ActionPlanTaskChecklist'
@ -17,8 +16,7 @@ import { ActionPlanUpdateForm } from '@/components/campaign/actionPlan/ActionPla
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { actionPlanKindLabels, actionPlanOriginLabels } from '@/lib/schemas/actionPlan'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import { actionPlanKindLabels } from '@/lib/schemas/actionPlan'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
@ -32,16 +30,13 @@ import {
} from '@/utilities/actionPlanDetailTabUi'
import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime'
import { ActionPlanOverviewTab } from './ActionPlanOverviewTab'
type ActionPlanDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const currencyFormatter = new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
})
export default async function ActionPlanDetailPage({
params,
searchParams,
@ -116,190 +111,7 @@ export default async function ActionPlanDetailPage({
<ActionPlanTabNav activeTab={activeTab} planSlug={view.slug} searchParams={query} />
{activeTab === 'overview' ? (
<div className="flex flex-col gap-4">
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{view.description ? (
<p className="whitespace-pre-wrap">{view.description}</p>
) : (
<p className="text-muted-foreground">Sem descrição.</p>
)}
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Município</dt>
<dd>
{view.municipality ? (
<Link
href={`/campanha/municipios/${view.municipality.slug}`}
className="text-primary underline-offset-4 hover:underline"
>
{view.municipality.name}
</Link>
) : (
'—'
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Local</dt>
<dd>{view.locality ?? '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Prazo de conclusão</dt>
<dd>{view.deadline ? formatBahiaDateTimeLabel(view.deadline) : '—'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Responsável</dt>
<dd>{view.responsibleName ?? 'Não definido'}</dd>
</div>
<div>
<dt className="text-muted-foreground">Origem da ação</dt>
<dd>{actionPlanOriginLabels[view.origin]}</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Equipe e organizações</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div>
<p className="text-muted-foreground">Assessores responsáveis</p>
{view.advisors.length ? (
<ul role="list" className="flex flex-col gap-1">
{view.advisors.map((advisor) => (
<li key={advisor.id}>{advisor.name}</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhum assessor vinculado.</p>
)}
</div>
<div>
<p className="text-muted-foreground">Organizações apoiadoras</p>
{view.organizations.length ? (
<ul role="list" className="flex flex-wrap gap-1.5 pt-1">
{view.organizations.map((organization) => (
<li key={organization.id}>
<Badge variant="outline">{organization.name}</Badge>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">Nenhuma organização vinculada.</p>
)}
</div>
<p className="text-muted-foreground">
{view.taskProgress.done}/{view.taskProgress.total} tarefas concluídas
</p>
{view.createdByName ? (
<p className="text-muted-foreground">Criado por {view.createdByName}</p>
) : null}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex-row items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<CardTitle>Demandas vinculadas</CardTitle>
<p className="text-sm text-muted-foreground">
Custo estimado: {currencyFormatter.format(view.demandCostTotal)}
</p>
</div>
{view.municipality ? (
<Button asChild variant="outline" className="min-h-11 shrink-0">
<Link
href={`/campanha/demandas/nova?actionPlan=${view.id}&municipality=${view.municipality.id}`}
>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Adicionar demanda
</Link>
</Button>
) : null}
</CardHeader>
<CardContent>
{view.demands.length ? (
<ul role="list" className="divide-y">
{view.demands.map((demand) => (
<li
key={demand.id}
className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<Link
href={`/campanha/demandas/${demand.slug}`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{demand.title}
</Link>
<p className="text-sm text-muted-foreground">
{campaignDemandKindLabels[demand.kind]} ·{' '}
{campaignDemandStatusLabels[demand.status]}
</p>
</div>
<span className="text-sm text-muted-foreground">
{demand.cost == null
? 'Custo não registrado'
: currencyFormatter.format(demand.cost)}
</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Nenhuma demanda vinculada a este plano.
</p>
)}
</CardContent>
</Card>
{view.status === 'realizado' && isStaff ? (
<ActionPlanResultForm
planId={view.id}
initialSummary={view.result?.summary ?? null}
recordedByName={view.result?.recordedByName ?? null}
recordedAt={view.result?.recordedAt ?? null}
/>
) : view.result ? (
<Card>
<CardHeader>
<CardTitle>Resultado da ação</CardTitle>
<p className="text-sm text-muted-foreground">
{[
view.result.recordedByName
? `Registrado por ${view.result.recordedByName}`
: 'Registrado',
view.result.recordedAt
? `em ${formatBahiaDateTimeLabel(view.result.recordedAt)}`
: null,
]
.filter(Boolean)
.join(' ')}
</p>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm">{view.result.summary}</p>
</CardContent>
</Card>
) : null}
</div>
) : null}
{activeTab === 'overview' ? <ActionPlanOverviewTab view={view} isStaff={isStaff} /> : null}
{activeTab === 'tasks' ? (
<Card>

View file

@ -1,60 +1,26 @@
import { createDetailTabHelpers, type DetailTabSearchParams } from '@/utilities/detailTabUi'
export const actionPlanDetailTabs = ['overview', 'tasks', 'updates'] as const
export type ActionPlanDetailTab = (typeof actionPlanDetailTabs)[number]
export type ActionPlanDetailSearchParams = Record<string, string | string[] | undefined>
export type ActionPlanDetailSearchParams = DetailTabSearchParams
const tabQueryKeys: Record<ActionPlanDetailTab, readonly string[]> = {
overview: [],
tasks: [],
updates: ['newUpdate'],
}
const helpers = createDetailTabHelpers<ActionPlanDetailTab>({
tabs: actionPlanDetailTabs,
defaultTab: 'overview',
tabQueryKeys: {
overview: [],
tasks: [],
updates: ['newUpdate'],
},
basePath: (planSlug) => `/campanha/planos/${planSlug}`,
forcedTab: (searchParams) =>
(Array.isArray(searchParams.newUpdate) ? searchParams.newUpdate[0] : searchParams.newUpdate) ===
'1'
? 'updates'
: null,
})
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const appendQueryValue = (
params: URLSearchParams,
key: string,
value: string | string[] | undefined,
) => {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined) params.append(key, item)
}
}
const isAllowedTab = (value: string | undefined): value is ActionPlanDetailTab =>
actionPlanDetailTabs.includes(value as ActionPlanDetailTab)
export const resolveActionPlanDetailTab = (
searchParams: ActionPlanDetailSearchParams,
): ActionPlanDetailTab => {
if (firstValue(searchParams.newUpdate) === '1') return 'updates'
const requestedTab = firstValue(searchParams.tab)
return isAllowedTab(requestedTab) ? requestedTab : 'overview'
}
export const buildActionPlanDetailTabHref = (
planSlug: string,
tab: ActionPlanDetailTab,
searchParams: ActionPlanDetailSearchParams,
): string => {
const params = new URLSearchParams()
for (const key of tabQueryKeys[tab]) {
appendQueryValue(params, key, searchParams[key])
}
params.set('tab', tab)
return `/campanha/planos/${planSlug}?${params.toString()}`
}
export const getActionPlanDetailTabRedirect = (
planSlug: string,
searchParams: ActionPlanDetailSearchParams,
): string | null => {
const activeTab = resolveActionPlanDetailTab(searchParams)
const requestedTab = searchParams.tab
if (typeof requestedTab === 'string' && requestedTab === activeTab) return null
return buildActionPlanDetailTabHref(planSlug, activeTab, searchParams)
}
export const resolveActionPlanDetailTab = helpers.resolveTab
export const buildActionPlanDetailTabHref = helpers.buildTabHref
export const getActionPlanDetailTabRedirect = helpers.getTabRedirect

View file

@ -0,0 +1,71 @@
/**
* Shared factory behind the detail-page tab helpers (município, plano de
* ação): querystring-tab resolution with per-tab preserved params and
* canonical hrefs. Each detail page declares its config in its own
* `*DetailTabUi.ts` module and re-exports the generated helpers.
*/
export type DetailTabSearchParams = Record<string, string | string[] | undefined>
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const appendQueryValue = (
params: URLSearchParams,
key: string,
value: string | string[] | undefined,
) => {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined) params.append(key, item)
}
}
export const createDetailTabHelpers = <Tab extends string>({
tabs,
defaultTab,
tabQueryKeys,
basePath,
forcedTab,
}: {
tabs: readonly Tab[]
defaultTab: Tab
/** Query params each tab preserves when linked to. */
tabQueryKeys: Record<Tab, readonly string[]>
basePath: (slug: string) => string
/** Params that force a tab regardless of `?tab=` (e.g. `?newUpdate=1`). */
forcedTab?: (searchParams: DetailTabSearchParams) => Tab | null
}) => {
const isAllowedTab = (value: string | undefined): value is Tab =>
tabs.includes(value as Tab)
const resolveTab = (searchParams: DetailTabSearchParams): Tab => {
const forced = forcedTab?.(searchParams)
if (forced) return forced
const requestedTab = firstValue(searchParams.tab)
return isAllowedTab(requestedTab) ? requestedTab : defaultTab
}
const buildTabHref = (
slug: string,
tab: Tab,
searchParams: DetailTabSearchParams,
): string => {
const params = new URLSearchParams()
for (const key of tabQueryKeys[tab]) {
appendQueryValue(params, key, searchParams[key])
}
params.set('tab', tab)
return `${basePath(slug)}?${params.toString()}`
}
const getTabRedirect = (slug: string, searchParams: DetailTabSearchParams): string | null => {
const activeTab = resolveTab(searchParams)
const requestedTab = searchParams.tab
if (typeof requestedTab === 'string' && requestedTab === activeTab) return null
return buildTabHref(slug, activeTab, searchParams)
}
return { resolveTab, buildTabHref, getTabRedirect }
}

View file

@ -1,3 +1,5 @@
import { createDetailTabHelpers, type DetailTabSearchParams } from '@/utilities/detailTabUi'
export const municipalityDetailTabs = [
'overview',
'dossie',
@ -8,52 +10,26 @@ export const municipalityDetailTabs = [
] as const
export type MunicipalityDetailTab = (typeof municipalityDetailTabs)[number]
export type MunicipalityDetailSearchParams = Record<string, string | string[] | undefined>
export type MunicipalityDetailSearchParams = DetailTabSearchParams
const tabQueryKeys: Record<MunicipalityDetailTab, readonly string[]> = {
overview: [],
dossie: [],
elections: ['compare'],
leaderships: ['leadershipQ', 'leadershipPage'],
updates: ['updateKind', 'updatePage', 'newUpdate'],
demands: ['demandStatus'],
}
const helpers = createDetailTabHelpers<MunicipalityDetailTab>({
tabs: municipalityDetailTabs,
defaultTab: 'overview',
tabQueryKeys: {
overview: [],
dossie: [],
elections: ['compare'],
leaderships: ['leadershipQ', 'leadershipPage'],
updates: ['updateKind', 'updatePage', 'newUpdate'],
demands: ['demandStatus'],
},
basePath: (municipalitySlug) => `/campanha/municipios/${municipalitySlug}`,
forcedTab: (searchParams) =>
(Array.isArray(searchParams.newUpdate) ? searchParams.newUpdate[0] : searchParams.newUpdate) ===
'1'
? 'updates'
: null,
})
const firstValue = (value: string | string[] | undefined): string | undefined =>
Array.isArray(value) ? value[0] : value
const appendQueryValue = (
params: URLSearchParams,
key: string,
value: string | string[] | undefined,
) => {
for (const item of Array.isArray(value) ? value : [value]) {
if (item !== undefined) params.append(key, item)
}
}
const isAllowedTab = (value: string | undefined): value is MunicipalityDetailTab =>
municipalityDetailTabs.includes(value as MunicipalityDetailTab)
export const resolveMunicipalityDetailTab = (
searchParams: MunicipalityDetailSearchParams,
): MunicipalityDetailTab => {
if (firstValue(searchParams.newUpdate) === '1') return 'updates'
const requestedTab = firstValue(searchParams.tab)
return isAllowedTab(requestedTab) ? requestedTab : 'overview'
}
export const buildMunicipalityDetailTabHref = (
municipalitySlug: string,
tab: MunicipalityDetailTab,
searchParams: MunicipalityDetailSearchParams,
): string => {
const params = new URLSearchParams()
for (const key of tabQueryKeys[tab]) {
appendQueryValue(params, key, searchParams[key])
}
params.set('tab', tab)
return `/campanha/municipios/${municipalitySlug}?${params.toString()}`
}
export const resolveMunicipalityDetailTab = helpers.resolveTab
export const buildMunicipalityDetailTabHref = helpers.buildTabHref