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:
parent
e357c3cc19
commit
c52e0e7957
7 changed files with 591 additions and 533 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
71
src/utilities/detailTabUi.ts
Normal file
71
src/utilities/detailTabUi.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue