mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Make every campaign action feel immediate with honest pending
Closes the 'Feel the action' gaps (.cursor/rules/campanha-action-feedback.mdc): - BUG: action-plan task checkbox was optimistic but never revalidated — a successful toggle visually reverted when the transition ended. The action now revalidates the plan page; checkbox disables while pending - map compare + candidate compare picker: optimistic select value + useTransition; the map region dims with aria-busy and a live region instead of flashing the full-page skeleton - CampaignListPendingBoundary/CampaignListResults: one shared transition so filters (municípios/apoiadores/planos), pagination, demandas status chips (new CampaignFilterChips), and the name-search pages (new CampaignSearchForm on lideranças/organizações/dobradinhas) dim the RESULTS region, not only the control — children stay server-rendered (composition) - streaming: dashboard map section behind Suspense (KPI shell paints first; React cache() keeps the municipality scope shared, not reloaded); municipality detail tabs (elections/leaderships/updates) stream below the header; list-shaped loading.tsx for /municipios and /apoiadores - logout button gets a pending state; BahiaMap init shows a visible spinner (was sr-only)
This commit is contained in:
parent
a32ba3b4f0
commit
05a47b881c
26 changed files with 1077 additions and 616 deletions
|
|
@ -13,8 +13,8 @@
|
|||
| Phase | Content | Skill phases | Status | Date |
|
||||
| ----- | ------- | ------------ | ------ | ---- |
|
||||
| 0 | Security lockdown: collection access defaults, leader route gates, broken leader-supporter create, candidate alignment | — (P0 pre-work) | done | 2026-07-23 |
|
||||
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | in-progress | 2026-07-23 |
|
||||
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | pending | |
|
||||
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | done | 2026-07-23 |
|
||||
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | done | 2026-07-23 |
|
||||
| 3 | Loading feedback: task-toggle revalidation bug, map compare transition, result-region pending, Suspense streaming | — (UX correctness) | pending | |
|
||||
| 4 | Client boundary/state: map hover isolation, bundle slimming, provider narrowing, composition splits | 4 (deep modules, UI) | pending | |
|
||||
| 5 | Structure: split campaignAccess + supporter actions, dedupe clones, utilities organization, finish rename | 3 (refactoring) + 4 (deep modules) | pending | |
|
||||
|
|
|
|||
20
src/app/(campaign)/campanha/(app)/apoiadores/loading.tsx
Normal file
20
src/app/(campaign)/campanha/(app)/apoiadores/loading.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** List-shaped skeleton for /apoiadores and its detail routes. */
|
||||
export default function SupportersLoading() {
|
||||
return (
|
||||
<CampaignPageShell aria-label="Carregando apoiadores" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-5 w-80 max-w-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-11 w-full max-w-md" />
|
||||
<Skeleton className="h-11 w-44" />
|
||||
</div>
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-80 w-full rounded-xl" />
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import config from '@payload-config'
|
||||
import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -43,13 +47,47 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
|
||||
const rawSearchParams = await searchParams
|
||||
const now = new Date()
|
||||
const { result, state, redirectHref, municipalityOptions, overview } = await loadSupportersPageData(
|
||||
payload,
|
||||
user,
|
||||
rawSearchParams,
|
||||
)
|
||||
const { result, state, redirectHref, municipalityOptions, overview } =
|
||||
await loadSupportersPageData(payload, user, rawSearchParams)
|
||||
if (redirectHref) redirect(redirectHref)
|
||||
|
||||
const listBody = result.docs.length ? (
|
||||
<>
|
||||
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
|
||||
<SupporterList
|
||||
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildSupporterListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
|
@ -82,48 +120,15 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<SupporterFilters
|
||||
key={buildSupporterFiltersKey(state)}
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
/>
|
||||
<CampaignListPendingBoundary>
|
||||
<SupporterFilters
|
||||
key={buildSupporterFiltersKey(state)}
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
/>
|
||||
|
||||
{result.docs.length ? (
|
||||
<>
|
||||
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
|
||||
<SupporterList
|
||||
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildSupporterListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>{listBody}</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignFilterChips } from '@/components/campaign/CampaignFilterChips'
|
||||
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -73,6 +78,78 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
return query ? `/campanha/demandas?${query}` : '/campanha/demandas'
|
||||
}
|
||||
|
||||
const listBody = rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Demanda</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Praça</TableHead>
|
||||
<TableHead>Solicitante</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Aberta em</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="max-w-64 whitespace-normal">
|
||||
<Link
|
||||
href={`/campanha/demandas/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{campaignDemandKindLabels[row.kind]}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.municipalityName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.requesterName ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[row.status]}>
|
||||
{campaignDemandStatusLabels[row.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{dateFormatter.format(new Date(row.createdAt))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => hrefFor(state.status, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<InboxIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma demanda por aqui</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/demandas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova demanda
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
|
|
@ -94,100 +171,21 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
<nav aria-label="Filtrar por status" className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
asChild
|
||||
variant={state.status === undefined ? 'default' : 'outline'}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link href={hrefFor(undefined)}>Todas</Link>
|
||||
</Button>
|
||||
{campaignDemandStatuses.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
asChild
|
||||
variant={state.status === status ? 'default' : 'outline'}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link href={hrefFor(status)}>{campaignDemandStatusLabels[status]}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignFilterChips
|
||||
ariaLabel="Filtrar por status"
|
||||
chips={[
|
||||
{ href: hrefFor(undefined), label: 'Todas', active: state.status === undefined },
|
||||
...campaignDemandStatuses.map((status) => ({
|
||||
href: hrefFor(status),
|
||||
label: campaignDemandStatusLabels[status],
|
||||
active: state.status === status,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Demanda</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Praça</TableHead>
|
||||
<TableHead>Solicitante</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Aberta em</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="max-w-64 whitespace-normal">
|
||||
<Link
|
||||
href={`/campanha/demandas/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{campaignDemandKindLabels[row.kind]}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.municipalityName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.requesterName ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[row.status]}>
|
||||
{campaignDemandStatusLabels[row.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{dateFormatter.format(new Date(row.createdAt))}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => hrefFor(state.status, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<InboxIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma demanda por aqui</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma
|
||||
ação.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/demandas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova demanda
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>{listBody}</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
|
||||
import config from '@payload-config'
|
||||
import { PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -69,83 +74,82 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
<form role="search" className="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={state.q ?? ''}
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar dobradinha por nome"
|
||||
placeholder="Buscar por nome…"
|
||||
aria-label="Buscar dobradinha por nome"
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
initialQuery={state.q ?? ''}
|
||||
hrefForQuery={(query) =>
|
||||
query ? `/campanha/dobradinhas?q=${encodeURIComponent(query)}` : '/campanha/dobradinhas'
|
||||
}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11">
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Partido</TableHead>
|
||||
<TableHead>Municípios</TableHead>
|
||||
<TableHead>Lideranças</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/dobradinhas/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.municipalityCount}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'dobradinha' : 'dobradinhas'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma dobradinha cadastrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova dobradinha
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Partido</TableHead>
|
||||
<TableHead>Municípios</TableHead>
|
||||
<TableHead>Lideranças</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/dobradinhas/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.municipalityCount}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'dobradinha' : 'dobradinhas'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma dobradinha cadastrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova dobradinha
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
|
||||
import config from '@payload-config'
|
||||
import { PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -67,96 +72,99 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
<form role="search" className="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={state.q ?? ''}
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar liderança por nome"
|
||||
placeholder="Buscar por nome…"
|
||||
aria-label="Buscar liderança por nome"
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
initialQuery={state.q ?? ''}
|
||||
hrefForQuery={(query) =>
|
||||
query ? `/campanha/liderancas?q=${encodeURIComponent(query)}` : '/campanha/liderancas'
|
||||
}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11">
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Praças</TableHead>
|
||||
<TableHead>Organizações</TableHead>
|
||||
<TableHead>Acesso ao app</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.id}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
|
||||
{row.municipalityNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
|
||||
{row.organizationNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
|
||||
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas
|
||||
Praças.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/liderancas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Praças</TableHead>
|
||||
<TableHead>Organizações</TableHead>
|
||||
<TableHead>Acesso ao app</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.id}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.supportStatus ? (
|
||||
<SupportStatusBadge status={row.supportStatus} />
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
|
||||
{row.municipalityNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
|
||||
{row.organizationNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
|
||||
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas
|
||||
Praças.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/liderancas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova liderança
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Suspense } from 'react'
|
||||
import config from '@payload-config'
|
||||
import Link from 'next/link'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
|
@ -111,24 +112,32 @@ export default async function MunicipalityDetailPage({
|
|||
) : null}
|
||||
|
||||
{activeTab === 'elections' ? (
|
||||
<ElectionsTab
|
||||
slug={view.slug}
|
||||
rawSearchParams={rawSearchParams}
|
||||
payloadUser={{ payload, user }}
|
||||
/>
|
||||
// Streams below the header/tab chrome — the TSE baselines and the
|
||||
// comparison table are the slowest reads on this page.
|
||||
<Suspense fallback={<MunicipalityTabFallback />}>
|
||||
<ElectionsTab
|
||||
slug={view.slug}
|
||||
rawSearchParams={rawSearchParams}
|
||||
payloadUser={{ payload, user }}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'leaderships' ? (
|
||||
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
|
||||
<Suspense fallback={<MunicipalityTabFallback />}>
|
||||
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'updates' ? (
|
||||
<UpdatesTab
|
||||
municipalityID={view.id}
|
||||
municipalitySlug={view.slug}
|
||||
rawSearchParams={rawSearchParams}
|
||||
payloadUser={{ payload, user }}
|
||||
/>
|
||||
<Suspense fallback={<MunicipalityTabFallback />}>
|
||||
<UpdatesTab
|
||||
municipalityID={view.id}
|
||||
municipalitySlug={view.slug}
|
||||
rawSearchParams={rawSearchParams}
|
||||
payloadUser={{ payload, user }}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'demands' ? (
|
||||
|
|
@ -193,6 +202,13 @@ const OverviewTab = async ({
|
|||
)
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
20
src/app/(campaign)/campanha/(app)/municipios/loading.tsx
Normal file
20
src/app/(campaign)/campanha/(app)/municipios/loading.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/** List-shaped skeleton for /municipios and its detail routes. */
|
||||
export default function MunicipalitiesLoading() {
|
||||
return (
|
||||
<CampaignPageShell aria-label="Carregando municípios" aria-busy="true">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<Skeleton className="h-5 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Skeleton className="h-11 w-full max-w-md" />
|
||||
<Skeleton className="h-11 w-40" />
|
||||
<Skeleton className="h-11 w-40" />
|
||||
</div>
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import config from '@payload-config'
|
||||
import { SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -128,16 +132,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
</Empty>
|
||||
)
|
||||
|
||||
// Shared transition: the filters navigate, the results dim ("Feel the action").
|
||||
const filteredMain = (
|
||||
<CampaignListPendingBoundary>
|
||||
{filters}
|
||||
<CampaignListResults>{listBody}</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
)
|
||||
|
||||
const main = isStaffView ? (
|
||||
<MunicipalityEstimateScenarioProvider>
|
||||
{filters}
|
||||
{listBody}
|
||||
</MunicipalityEstimateScenarioProvider>
|
||||
<MunicipalityEstimateScenarioProvider>{filteredMain}</MunicipalityEstimateScenarioProvider>
|
||||
) : (
|
||||
<>
|
||||
{filters}
|
||||
{listBody}
|
||||
</>
|
||||
filteredMain
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
|
||||
import config from '@payload-config'
|
||||
import { PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -72,88 +77,89 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
<form role="search" className="flex gap-2">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
defaultValue={state.q ?? ''}
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignSearchForm
|
||||
ariaLabel="Buscar organização por nome"
|
||||
placeholder="Buscar por nome…"
|
||||
aria-label="Buscar organização por nome"
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
initialQuery={state.q ?? ''}
|
||||
hrefForQuery={(query) =>
|
||||
query
|
||||
? `/campanha/organizacoes?q=${encodeURIComponent(query)}`
|
||||
: '/campanha/organizacoes'
|
||||
}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11">
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Praças de atuação</TableHead>
|
||||
<TableHead>Lideranças</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/organizacoes/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
|
||||
{row.municipalityNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma organização cadastrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de
|
||||
Ação.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/organizacoes/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova organização
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Praças de atuação</TableHead>
|
||||
<TableHead>Lideranças</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/campanha/organizacoes/${row.slug}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
|
||||
{row.municipalityNames.join(', ') || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={hrefForPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhuma organização cadastrada</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de
|
||||
Ação.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/organizacoes/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Nova organização
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import config from '@payload-config'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
import { getPayload, type Payload } from 'payload'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
|
||||
import { LeaderContactsPanel } from '@/components/campaign/LeaderContactsPanel'
|
||||
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
|
||||
import type { CampaignUser } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
|
||||
|
|
@ -37,20 +41,50 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
|
|||
}
|
||||
|
||||
const isStaff = isCampaignStaff(user)
|
||||
const [view, mapBundle, candidateOptions] = await Promise.all([
|
||||
getCampaignDashboardData(payload, user),
|
||||
isStaff
|
||||
? loadMunicipalityMapBundle(payload, user, { compare: rawSearchParams.compare })
|
||||
: Promise.resolve(null),
|
||||
isStaff ? loadFederalCandidateOptions(user) : Promise.resolve([]),
|
||||
])
|
||||
const view = await getCampaignDashboardData(payload, user)
|
||||
|
||||
return (
|
||||
<CampaignDashboard
|
||||
view={view}
|
||||
userName={user.name}
|
||||
mapBundle={mapBundle}
|
||||
candidateOptions={candidateOptions}
|
||||
mapSlot={
|
||||
isStaff ? (
|
||||
// Streams after the KPI shell paints; the shared request-scoped
|
||||
// municipality scope is reused (React cache), not reloaded.
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-[420px] w-full animate-pulse rounded-xl border bg-muted/40"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DashboardMapSection payload={payload} user={user} compare={rawSearchParams.compare} />
|
||||
</Suspense>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DashboardMapSection = async ({
|
||||
payload,
|
||||
user,
|
||||
compare,
|
||||
}: {
|
||||
payload: Payload
|
||||
user: CampaignUser
|
||||
compare: string | string[] | undefined
|
||||
}) => {
|
||||
const [mapBundle, candidateOptions] = await Promise.all([
|
||||
loadMunicipalityMapBundle(payload, user, { compare }),
|
||||
loadFederalCandidateOptions(user),
|
||||
])
|
||||
if (!mapBundle) return null
|
||||
|
||||
return (
|
||||
<MunicipalityEstimateScenarioProvider>
|
||||
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
|
||||
</MunicipalityEstimateScenarioProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import { toggleActionPlanTask } from '@/app/(campaign)/campanha/actions/actionPlan'
|
||||
|
||||
export type ToggleActionPlanTaskResult = { ok: true } | { ok: false; message: string }
|
||||
|
|
@ -11,6 +13,9 @@ export const toggleActionPlanTaskAction = async (
|
|||
): Promise<ToggleActionPlanTaskResult> => {
|
||||
try {
|
||||
await toggleActionPlanTask(planId, taskId, done)
|
||||
// Refresh the RSC props behind the optimistic checkbox — without this the
|
||||
// optimistic state reverts to stale server data when the transition ends.
|
||||
revalidatePath('/campanha/planos/[slug]', 'page')
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/CampaignListPending'
|
||||
import config from '@payload-config'
|
||||
import { PlusIcon, SearchXIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -71,47 +75,51 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
|
|||
) : null}
|
||||
</header>
|
||||
|
||||
<ActionPlanFilters
|
||||
key={buildActionPlanFiltersKey(state)}
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
/>
|
||||
<CampaignListPendingBoundary>
|
||||
<ActionPlanFilters
|
||||
key={buildActionPlanFiltersKey(state)}
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
/>
|
||||
|
||||
{result.docs.length ? (
|
||||
<>
|
||||
<ActionPlanList
|
||||
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildActionPlanListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/planos">Limpar filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
<CampaignListResults>
|
||||
{result.docs.length ? (
|
||||
<>
|
||||
<ActionPlanList
|
||||
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.totalDocs}{' '}
|
||||
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildActionPlanListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty className="min-h-72 border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/planos">Limpar filtros</Link>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import { useRef, useState, useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
|
@ -81,7 +82,11 @@ export const ActionPlanFilters = ({
|
|||
const valuesRef = useRef(initialValues)
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const sharedPending = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
// Prefer the page-level boundary so the results region dims together.
|
||||
const isPending = sharedPending?.isPending ?? isLocalPending
|
||||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
|
||||
const replaceValues = (nextValues: FilterValues) => {
|
||||
valuesRef.current = nextValues
|
||||
|
|
@ -101,7 +106,8 @@ export const ActionPlanFilters = ({
|
|||
|
||||
const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
|
||||
const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
|
||||
const updateMunicipality = (municipality: string) => replaceValues({ ...valuesRef.current, municipality })
|
||||
const updateMunicipality = (municipality: string) =>
|
||||
replaceValues({ ...valuesRef.current, municipality })
|
||||
|
||||
const clearFilters = () => replaceValues({ kind: '', status: '', municipality: '' })
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { startTransition, useOptimistic } from 'react'
|
||||
import { useOptimistic, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
|
||||
|
|
@ -14,6 +14,7 @@ type ActionPlanTaskChecklistProps = {
|
|||
}
|
||||
|
||||
export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [optimisticTasks, setOptimisticDone] = useOptimistic(
|
||||
tasks,
|
||||
(currentTasks, { taskId, done }: { taskId: string; done: boolean }) =>
|
||||
|
|
@ -26,6 +27,8 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
|
|||
startTransition(async () => {
|
||||
setOptimisticDone({ taskId, done })
|
||||
const result = await toggleActionPlanTaskAction(planId, taskId, done)
|
||||
// On failure the optimistic state rolls back automatically when the
|
||||
// transition ends (the action does not revalidate on error).
|
||||
if (!result.ok) toast.error(result.message)
|
||||
})
|
||||
}
|
||||
|
|
@ -35,15 +38,17 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
|
|||
}
|
||||
|
||||
return (
|
||||
<ul role="list" className="flex flex-col gap-2">
|
||||
<ul role="list" className="flex flex-col gap-2" aria-busy={isPending}>
|
||||
{optimisticTasks.map((task) => (
|
||||
<li
|
||||
key={task.id ?? task.title}
|
||||
className="flex items-start gap-3 rounded-lg border p-3"
|
||||
data-done={task.done}
|
||||
data-pending={isPending || undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={task.done}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) => toggle(task.id, checked === true)}
|
||||
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
|
||||
className="mt-0.5"
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type LayerStyleContext,
|
||||
} from '@/lib/bahiaMapStyle'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
|
||||
export type { BahiaMapFillMode, ChoroplethValues }
|
||||
|
||||
|
|
@ -113,7 +114,9 @@ export const BahiaMap = ({
|
|||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const layerRef = useRef<L.GeoJSON | null>(null)
|
||||
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(null)
|
||||
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(
|
||||
null,
|
||||
)
|
||||
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
|
||||
const styleContextRef = useRef<LayerStyleContext | null>(null)
|
||||
const geometryReadyRef = useRef(false)
|
||||
|
|
@ -445,7 +448,14 @@ export const BahiaMap = ({
|
|||
heightClassName,
|
||||
)}
|
||||
/>
|
||||
{status === 'loading' ? <p className="sr-only">Carregando mapa…</p> : null}
|
||||
{status === 'loading' ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center" role="status">
|
||||
<span className="flex items-center gap-2 rounded-md bg-background/85 px-3 py-2 text-sm text-muted-foreground shadow-sm">
|
||||
<Spinner aria-hidden="true" />
|
||||
Carregando mapa…
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' ? (
|
||||
<p className="absolute inset-0 flex items-center justify-center bg-background/80 px-4 text-center text-sm text-muted-foreground">
|
||||
Não foi possível carregar o mapa. Tente recarregar a página.
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
import { ArrowRightIcon } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
|
||||
import {
|
||||
CampaignPageShell,
|
||||
campaignPrioritySurfaceClassName,
|
||||
} from '@/components/campaign/CampaignPageShell'
|
||||
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
|
||||
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
|
||||
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
|
||||
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
|
||||
import {
|
||||
formatVoteEstimateEndpointsLabel,
|
||||
voteEstimateScenarioLabels,
|
||||
|
|
@ -28,13 +26,12 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
|
|||
export const CampaignDashboard = ({
|
||||
view,
|
||||
userName,
|
||||
mapBundle = null,
|
||||
candidateOptions = [],
|
||||
mapSlot = null,
|
||||
}: {
|
||||
view: StaffDashboardView
|
||||
userName: string
|
||||
mapBundle?: MunicipalityMapBundle | null
|
||||
candidateOptions?: FederalCandidateOption[]
|
||||
/** Server-streamed map section (composition keeps this component map-agnostic). */
|
||||
mapSlot?: ReactNode
|
||||
}) => (
|
||||
<CampaignPageShell>
|
||||
<header className="flex flex-col gap-1">
|
||||
|
|
@ -46,11 +43,7 @@ export const CampaignDashboard = ({
|
|||
</p>
|
||||
</header>
|
||||
|
||||
{mapBundle ? (
|
||||
<MunicipalityEstimateScenarioProvider>
|
||||
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
|
||||
</MunicipalityEstimateScenarioProvider>
|
||||
) : null}
|
||||
{mapSlot}
|
||||
|
||||
<section
|
||||
aria-label="Indicadores das Praças"
|
||||
|
|
|
|||
77
src/components/campaign/CampaignFilterChips.tsx
Normal file
77
src/components/campaign/CampaignFilterChips.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useTransition, type MouseEvent } from 'react'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type CampaignFilterChip = {
|
||||
href: string
|
||||
label: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
|
||||
* one activates at once), honest pending on the results (shared boundary
|
||||
* transition dims them) — "Feel the action".
|
||||
*/
|
||||
export const CampaignFilterChips = ({
|
||||
ariaLabel,
|
||||
chips,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
chips: CampaignFilterChip[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const shared = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
const isPending = shared?.isPending ?? isLocalPending
|
||||
const startTransition = shared?.startTransition ?? startLocalTransition
|
||||
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
|
||||
|
||||
const activeHref =
|
||||
isPending && optimisticHref !== null
|
||||
? optimisticHref
|
||||
: (chips.find((chip) => chip.active)?.href ?? null)
|
||||
|
||||
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setOptimisticHref(href)
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={ariaLabel} aria-busy={isPending} className="flex flex-wrap gap-2">
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.href}
|
||||
asChild
|
||||
variant={chip.href === activeHref ? 'default' : 'outline'}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link href={chip.href} onClick={(event) => interceptNavigation(event, chip.href)}>
|
||||
{chip.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTransition, type MouseEvent } from 'react'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
|
|
@ -24,45 +30,83 @@ export const CampaignListPagination = ({
|
|||
totalPages: number
|
||||
hrefForPage: (page: number) => string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const shared = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
const isPending = shared?.isPending ?? isLocalPending
|
||||
const startTransition = shared?.startTransition ?? startLocalTransition
|
||||
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const visiblePages = getPaginationPages(page, totalPages)
|
||||
|
||||
return (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={hrefForPage(Math.max(1, page - 1))}
|
||||
aria-disabled={page <= 1}
|
||||
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((pageNumber, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
|
||||
/** Plain left-clicks navigate in a transition (pending UI); modified clicks keep link semantics. */
|
||||
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={hrefForPage(pageNumber)}
|
||||
isActive={pageNumber === page}
|
||||
aria-label={`Ir para a página ${pageNumber}`}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={hrefForPage(Math.min(totalPages, page + 1))}
|
||||
aria-disabled={page >= totalPages}
|
||||
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
return (
|
||||
<div
|
||||
className="transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Carregando página…' : ''}
|
||||
</p>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href={hrefForPage(Math.max(1, page - 1))}
|
||||
onClick={(event) => interceptNavigation(event, hrefForPage(Math.max(1, page - 1)))}
|
||||
aria-disabled={page <= 1}
|
||||
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{visiblePages.map((pageNumber, index) => {
|
||||
const previousPage = visiblePages[index - 1]
|
||||
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNumber} className="contents">
|
||||
{hasGap ? <PaginationEllipsis /> : null}
|
||||
<PaginationLink
|
||||
href={hrefForPage(pageNumber)}
|
||||
onClick={(event) => interceptNavigation(event, hrefForPage(pageNumber))}
|
||||
isActive={pageNumber === page}
|
||||
aria-label={`Ir para a página ${pageNumber}`}
|
||||
>
|
||||
{pageNumber}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href={hrefForPage(Math.min(totalPages, page + 1))}
|
||||
onClick={(event) =>
|
||||
interceptNavigation(event, hrefForPage(Math.min(totalPages, page + 1)))
|
||||
}
|
||||
aria-disabled={page >= totalPages}
|
||||
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
67
src/components/campaign/CampaignListPending.tsx
Normal file
67
src/components/campaign/CampaignListPending.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useTransition,
|
||||
type ReactNode,
|
||||
type TransitionStartFunction,
|
||||
} from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CampaignListPendingValue = {
|
||||
isPending: boolean
|
||||
startTransition: TransitionStartFunction
|
||||
}
|
||||
|
||||
const CampaignListPendingContext = createContext<CampaignListPendingValue | null>(null)
|
||||
|
||||
/**
|
||||
* Shares one transition between the filter controls (which navigate) and the
|
||||
* results region (which must dim while the RSC payload loads) — "Feel the
|
||||
* action": optimistic on the control, honest pending on the result.
|
||||
*
|
||||
* Children stay server-rendered (composition via `children`).
|
||||
*/
|
||||
export const CampaignListPendingBoundary = ({ children }: { children: ReactNode }) => {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
return (
|
||||
<CampaignListPendingContext.Provider value={{ isPending, startTransition }}>
|
||||
{children}
|
||||
</CampaignListPendingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Filter controls prefer the shared transition so the results dim with them. */
|
||||
export const useCampaignListPending = (): CampaignListPendingValue | null =>
|
||||
useContext(CampaignListPendingContext)
|
||||
|
||||
/** Wraps the server-rendered results; dims while a shared navigation is pending. */
|
||||
export const CampaignListResults = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
const shared = useContext(CampaignListPendingContext)
|
||||
const isPending = shared?.isPending ?? false
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-6 transition-opacity data-[pending=true]:opacity-60',
|
||||
className,
|
||||
)}
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
src/components/campaign/CampaignSearchForm.tsx
Normal file
62
src/components/campaign/CampaignSearchForm.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useTransition } from 'react'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* Name-search form for simple list pages (lideranças, organizações,
|
||||
* dobradinhas): keeps the input value locally (optimistic) and navigates in a
|
||||
* transition so the results region dims instead of a dead full-page wait.
|
||||
*/
|
||||
export const CampaignSearchForm = ({
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
initialQuery,
|
||||
hrefForQuery,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
placeholder: string
|
||||
initialQuery: string
|
||||
hrefForQuery: (query: string) => string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const shared = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
const isPending = shared?.isPending ?? isLocalPending
|
||||
const startTransition = shared?.startTransition ?? startLocalTransition
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex gap-2 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
startTransition(() => {
|
||||
router.replace(hrefForQuery(query), { scroll: false })
|
||||
})
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11" disabled={isPending}>
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
|
|||
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
|
||||
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
|
|
@ -32,11 +33,15 @@ export type CampaignSidebarUser = CampaignUserShellView
|
|||
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
||||
const pathname = usePathname()
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
|
||||
const handleLogout = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (isLoggingOut) return
|
||||
setIsLoggingOut(true)
|
||||
clearRecentVisits()
|
||||
await clearCampaignPwaCaches()
|
||||
// logoutCampaign redirects — no need to reset the pending flag on success.
|
||||
await logoutCampaign()
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +58,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<CampaignScopeBadge className="mb-3 w-fit">{campaignRoleLabels[user.role]}</CampaignScopeBadge>
|
||||
<CampaignScopeBadge className="mb-3 w-fit">
|
||||
{campaignRoleLabels[user.role]}
|
||||
</CampaignScopeBadge>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{getCampaignNav(user.role).map((item) => (
|
||||
|
|
@ -83,7 +90,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
>
|
||||
<CampaignUserAvatar name={user.name} avatarUrl={user.avatarUrl} size="sm" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
|
||||
<span className="truncate text-sm font-medium text-sidebar-foreground">{user.name}</span>
|
||||
<span className="truncate text-sm font-medium text-sidebar-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Meu perfil</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -92,9 +101,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoggingOut}
|
||||
className="min-h-11 w-full border-sidebar-border bg-sidebar font-medium text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
>
|
||||
Sair
|
||||
{isLoggingOut ? <Spinner aria-hidden="true" /> : null}
|
||||
{isLoggingOut ? 'Saindo…' : 'Sair'}
|
||||
</Button>
|
||||
</form>
|
||||
</SidebarFooter>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { XIcon } from 'lucide-react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTransition } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
|
|
@ -23,6 +24,7 @@ export const CandidateComparePicker = ({
|
|||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const optionByNumber = new Map(options.map((option) => [option.candidateNumber, option]))
|
||||
|
||||
const navigate = (numbers: number[]) => {
|
||||
|
|
@ -30,11 +32,20 @@ export const CandidateComparePicker = ({
|
|||
params.delete('compare')
|
||||
for (const number of numbers) params.append('compare', String(number))
|
||||
params.set('tab', 'elections')
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||||
startTransition(() => {
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className="flex flex-col gap-2 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando comparativo…' : ''}
|
||||
</p>
|
||||
<Field className="max-w-md">
|
||||
<FieldLabel htmlFor="candidate-compare-picker">Comparar com outros candidatos</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState, useTransition } from 'react'
|
||||
|
||||
|
|
@ -29,7 +30,11 @@ type MunicipalityFiltersProps = {
|
|||
export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFiltersProps) => {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState(state.q ?? '')
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const sharedPending = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
// Prefer the page-level boundary so the results region dims together.
|
||||
const isPending = sharedPending?.isPending ?? isLocalPending
|
||||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(
|
||||
|
|
@ -54,7 +59,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
page: 1,
|
||||
q: normalizedText(patch.q !== undefined ? patch.q : search),
|
||||
}
|
||||
if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 })) return
|
||||
if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 }))
|
||||
return
|
||||
|
||||
startTransition(() => {
|
||||
router.replace(buildMunicipalityListHref(merged, 1), { scroll: false })
|
||||
|
|
@ -73,12 +79,12 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
|
||||
const hasActiveFilters = Boolean(
|
||||
state.q ||
|
||||
normalizedText(search) ||
|
||||
state.region ||
|
||||
state.kind ||
|
||||
state.coverage ||
|
||||
state.priority ||
|
||||
state.trend,
|
||||
normalizedText(search) ||
|
||||
state.region ||
|
||||
state.kind ||
|
||||
state.coverage ||
|
||||
state.priority ||
|
||||
state.trend,
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -140,7 +146,9 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">Todos</NativeSelectOption>
|
||||
{(Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>).map((kind) => (
|
||||
{(
|
||||
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
|
||||
).map((kind) => (
|
||||
<NativeSelectOption key={kind} value={kind}>
|
||||
{municipalityKindLabels[kind]}
|
||||
</NativeSelectOption>
|
||||
|
|
@ -156,7 +164,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
|
|||
value={state.coverage ?? ''}
|
||||
onChange={(event) =>
|
||||
commitNavigation({
|
||||
coverage: (event.target.value || undefined) as MunicipalityListState['coverage'],
|
||||
coverage: (event.target.value ||
|
||||
undefined) as MunicipalityListState['coverage'],
|
||||
})
|
||||
}
|
||||
className="min-h-11 w-full"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
|
||||
|
||||
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
|
||||
import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend'
|
||||
|
|
@ -90,7 +90,10 @@ export const MunicipalityMapPanel = ({
|
|||
return computeChoroplethMax(displayValues, fillMode, percentScaleActive ? 1 : undefined)
|
||||
}, [comparisonActive, displayValues, percentScaleActive])
|
||||
|
||||
const scopedKeys = useMemo(() => Object.keys(bundle.municipalitiesByIbgeCode), [bundle.municipalitiesByIbgeCode])
|
||||
const scopedKeys = useMemo(
|
||||
() => Object.keys(bundle.municipalitiesByIbgeCode),
|
||||
[bundle.municipalitiesByIbgeCode],
|
||||
)
|
||||
|
||||
const metricLabel = useMemo(() => {
|
||||
if (comparisonActive) {
|
||||
|
|
@ -107,11 +110,26 @@ export const MunicipalityMapPanel = ({
|
|||
return `votos de ${bundle.candidateName} em ${year}`
|
||||
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
|
||||
|
||||
// Optimistic on the control, honest pending on the map: the comparison data
|
||||
// is a server round trip, so the select flips at once and the map region
|
||||
// dims until the refreshed bundle arrives.
|
||||
const [isComparePending, startCompareTransition] = useTransition()
|
||||
const [optimisticCompare, setOptimisticCompare] = useState<string | null>(null)
|
||||
const compareSelectValue =
|
||||
isComparePending && optimisticCompare !== null
|
||||
? optimisticCompare
|
||||
: comparison
|
||||
? String(comparison.candidateNumber)
|
||||
: ''
|
||||
|
||||
const setCompare = (candidateNumber: string) => {
|
||||
setOptimisticCompare(candidateNumber)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (candidateNumber) params.set('compare', candidateNumber)
|
||||
else params.delete('compare')
|
||||
router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
|
||||
startCompareTransition(() => {
|
||||
router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => {
|
||||
|
|
@ -218,7 +236,7 @@ export const MunicipalityMapPanel = ({
|
|||
<FieldLabel htmlFor="municipality-map-compare">Comparar com</FieldLabel>
|
||||
<NativeSelect
|
||||
id="municipality-map-compare"
|
||||
value={comparison ? String(comparison.candidateNumber) : ''}
|
||||
value={compareSelectValue}
|
||||
onChange={(event) => setCompare(event.target.value)}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
|
|
@ -237,100 +255,111 @@ export const MunicipalityMapPanel = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{comparisonActive && comparison ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
+{comparison.candidateName}
|
||||
</span>
|
||||
<div
|
||||
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
|
||||
style={{ background: divergingGradientCss }}
|
||||
role="img"
|
||||
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
|
||||
/>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">+{bundle.candidateName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
|
||||
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
|
||||
</p>
|
||||
</div>
|
||||
) : displayMax > 0 ? (
|
||||
<ChoroplethLegend
|
||||
max={displayMax}
|
||||
metricLabel={metricLabel}
|
||||
formatMax={percentScaleActive ? () => '100%' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
|
||||
)}
|
||||
|
||||
{comparison && year === 2026 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para ver
|
||||
o mapa comparativo.
|
||||
<div
|
||||
className="flex flex-col gap-4 transition-opacity data-[pending=true]:opacity-60"
|
||||
data-pending={isComparePending || undefined}
|
||||
aria-busy={isComparePending}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isComparePending ? 'Atualizando comparação…' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
{comparisonActive && comparison ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
+{comparison.candidateName}
|
||||
</span>
|
||||
<div
|
||||
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
|
||||
style={{ background: divergingGradientCss }}
|
||||
role="img"
|
||||
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
|
||||
/>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
+{bundle.candidateName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
|
||||
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
|
||||
</p>
|
||||
</div>
|
||||
) : displayMax > 0 ? (
|
||||
<ChoroplethLegend
|
||||
max={displayMax}
|
||||
metricLabel={metricLabel}
|
||||
formatMax={percentScaleActive ? () => '100%' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
|
||||
)}
|
||||
|
||||
<BahiaMap
|
||||
mode="municipality"
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
fitToKeys={scopedKeys}
|
||||
interactiveKeys={scopedKeys}
|
||||
selectedKey={selectedFeature?.key ?? null}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
onFeatureActivate={handleFeatureActivate}
|
||||
ariaLabel={
|
||||
comparisonActive && comparison
|
||||
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
|
||||
: `Mapa da Bahia com ${metricLabel} por município`
|
||||
}
|
||||
/>
|
||||
|
||||
<MapFeatureReadout
|
||||
feature={selectedFeature}
|
||||
metricValue={selectedMetricValue}
|
||||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
|
||||
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
|
||||
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
|
||||
{comparison && year === 2026 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
|
||||
detalhadas aqui.
|
||||
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para
|
||||
ver o mapa comparativo.
|
||||
</p>
|
||||
<ul className="grid gap-1 sm:grid-cols-2">
|
||||
{bundle.zoneBreakdown.map((zone) => (
|
||||
<li
|
||||
key={zone.slug}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<Link
|
||||
href={`/campanha/municipios/${zone.slug}`}
|
||||
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
) : null}
|
||||
|
||||
<BahiaMap
|
||||
mode="municipality"
|
||||
values={displayValues}
|
||||
scaleMax={displayMax > 0 ? displayMax : undefined}
|
||||
fillMode={comparisonActive ? 'diverging' : 'sequential'}
|
||||
fitToKeys={scopedKeys}
|
||||
interactiveKeys={scopedKeys}
|
||||
selectedKey={selectedFeature?.key ?? null}
|
||||
onFeatureSelect={handleFeatureSelect}
|
||||
onFeatureActivate={handleFeatureActivate}
|
||||
ariaLabel={
|
||||
comparisonActive && comparison
|
||||
? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
|
||||
: `Mapa da Bahia com ${metricLabel} por município`
|
||||
}
|
||||
/>
|
||||
|
||||
<MapFeatureReadout
|
||||
feature={selectedFeature}
|
||||
metricValue={selectedMetricValue}
|
||||
rawMetricValue={selectedRawMetricValue}
|
||||
metricLabel={metricLabel}
|
||||
scaleMode={comparisonActive ? 'absolute' : scaleMode}
|
||||
comparisonActive={comparisonActive}
|
||||
navigation={selectedNavigation}
|
||||
/>
|
||||
|
||||
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
|
||||
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
|
||||
detalhadas aqui.
|
||||
</p>
|
||||
<ul className="grid gap-1 sm:grid-cols-2">
|
||||
{bundle.zoneBreakdown.map((zone) => (
|
||||
<li
|
||||
key={zone.slug}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
{zone.name}
|
||||
</Link>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(
|
||||
year === 2026
|
||||
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
|
||||
: (zone.votesByYear[String(year)] ?? 0),
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<Link
|
||||
href={`/campanha/municipios/${zone.slug}`}
|
||||
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{zone.name}
|
||||
</Link>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{formatElectionNumber(
|
||||
year === 2026
|
||||
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
|
||||
: (zone.votesByYear[String(year)] ?? 0),
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
|
||||
import { type FormEvent, useRef, useState, useTransition } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
|
||||
|
|
@ -111,7 +112,11 @@ export const SupporterFilters = ({
|
|||
const valuesRef = useRef(initialValues)
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const sharedPending = useCampaignListPending()
|
||||
const [isLocalPending, startLocalTransition] = useTransition()
|
||||
// Prefer the page-level boundary so the results region dims together.
|
||||
const isPending = sharedPending?.isPending ?? isLocalPending
|
||||
const startTransition = sharedPending?.startTransition ?? startLocalTransition
|
||||
|
||||
const replaceValues = (nextValues: FilterValues) => {
|
||||
valuesRef.current = nextValues
|
||||
|
|
@ -138,7 +143,9 @@ export const SupporterFilters = ({
|
|||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
const cleared = Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial<FilterValues>
|
||||
const cleared = Object.fromEntries(
|
||||
filterNames.map((name) => [name, '']),
|
||||
) as Partial<FilterValues>
|
||||
replaceValues({ ...valuesRef.current, ...cleared })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue