Pass 2 W1-D2: migra tripé de entidades, demandas, apoiadores, planos e assessores para o sistema de listas

- liderancas/organizacoes/dobradinhas/demandas: CampaignTable + column defs + CampaignListFooter + CampaignListEmptyState; parsers locais de first/page substituidos pelos helpers de campaignListUrl (fecha C8 F3); buildXListHref por modulo (URLs byte-identicas)
- SupporterList: tabela desktop em column defs (cards mobile intactos); apoiadores adota footer + empty state compartilhados
- planos: cards continuam (dominio de cards); adota footer + empty state
- assessores: tabela client bespoke (edit-where-you-see); pagina adota CampaignListFooter; empty state compartilhado no AdvisorsTable
- fora por decisao D5: TerritoryOverviewTable (client-sort, dataset <=27), comparacao de candidatos, LeaderContacts, preview de import
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 14:28:01 -03:00
parent 03bf54dea2
commit 23e1f1e83d
14 changed files with 574 additions and 593 deletions

View file

@ -8,21 +8,14 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { SupporterFilters } from '@/components/campaign/SupporterFilters'
import { SupporterList } from '@/components/campaign/SupporterList'
import { SupporterListOverview } from '@/components/campaign/SupporterListOverview'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
@ -57,35 +50,25 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
<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>
<CampaignListFooter
totalDocs={result.totalDocs}
singular="apoiador encontrado"
plural="apoiadores encontrados"
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</>
) : (
<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>
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhum apoiador encontrado"
description="Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</CampaignListEmptyState>
)
return (

View file

@ -6,7 +6,7 @@ import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import { AdvisorsTable } from '@/components/campaign/AdvisorsTable'
@ -73,16 +73,14 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
/>
{totalDocs > 0 ? (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'assessor' : 'assessores'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => advisorListHrefForPage(state, page)}
/>
</div>
<CampaignListFooter
totalDocs={totalDocs}
singular="assessor"
plural="assessores"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => advisorListHrefForPage(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>

View file

@ -5,30 +5,20 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignFilterChips } from '@/components/campaign/CampaignFilterChips'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
@ -37,7 +27,12 @@ import {
} from '@/lib/schemas/campaignDemand'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadDemandListPageData, parseDemandListParams } from '@/utilities/campaignDemandData'
import {
buildDemandListHref,
loadDemandListPageData,
parseDemandListParams,
type DemandRowViewModel,
} from '@/utilities/campaignDemandData'
type DemandsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -56,6 +51,52 @@ const statusVariant: Record<
const dateFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' })
const demandColumns: Array<CampaignTableColumn<DemandRowViewModel>> = [
{
id: 'title',
mandatory: true,
head: <CampaignTableHead>Demanda</CampaignTableHead>,
cellClassName: 'max-w-64 whitespace-normal',
cell: (row) => (
<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>
),
},
{
id: 'kind',
head: <CampaignTableHead>Tipo</CampaignTableHead>,
cell: (row) => campaignDemandKindLabels[row.kind],
},
{
id: 'municipality',
head: <CampaignTableHead>Praça</CampaignTableHead>,
cellClassName: 'text-muted-foreground',
cell: (row) => row.municipalityName,
},
{
id: 'requester',
head: <CampaignTableHead>Solicitante</CampaignTableHead>,
cellClassName: 'text-muted-foreground',
cell: (row) => row.requesterName ?? '—',
},
{
id: 'status',
head: <CampaignTableHead>Status</CampaignTableHead>,
cell: (row) => (
<Badge variant={statusVariant[row.status]}>{campaignDemandStatusLabels[row.status]}</Badge>
),
},
{
id: 'createdAt',
head: <CampaignTableHead>Aberta em</CampaignTableHead>,
cell: (row) => dateFormatter.format(new Date(row.createdAt)),
},
]
export default async function DemandsPage({ searchParams }: DemandsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -69,86 +110,8 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
state,
)
const hrefFor = (status?: CampaignDemandStatus, page = 1) => {
const params = new URLSearchParams()
if (status) params.set('status', status)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
const query = params.toString()
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>
)
const hrefForStatus = (status?: CampaignDemandStatus) =>
buildDemandListHref({ ...state, status }, 1)
return (
<CampaignPageShell>
@ -175,16 +138,43 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
<CampaignFilterChips
ariaLabel="Filtrar por status"
chips={[
{ href: hrefFor(undefined), label: 'Todas', active: state.status === undefined },
{ href: hrefForStatus(undefined), label: 'Todas', active: state.status === undefined },
...campaignDemandStatuses.map((status) => ({
href: hrefFor(status),
href: hrefForStatus(status),
label: campaignDemandStatusLabels[status],
active: state.status === status,
})),
]}
/>
<CampaignListResults>{listBody}</CampaignListResults>
<CampaignListResults>
{rows.length ? (
<>
<CampaignTable columns={demandColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="demanda"
plural="demandas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildDemandListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={InboxIcon}
title="Nenhuma demanda por aqui"
description="Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação."
>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</CampaignListEmptyState>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)

View file

@ -1,44 +1,70 @@
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'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/CampaignTable'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
buildStateDeputyListHref,
loadStateDeputyListPageData,
parseStateDeputyListParams,
type StateDeputyRowViewModel,
} from '@/utilities/stateDeputyData'
type StateDeputiesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> = [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cell: (row) => (
<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>
),
},
{
id: 'party',
head: <CampaignTableHead>Partido</CampaignTableHead>,
cellClassName: 'text-muted-foreground',
cell: (row) => row.party ?? '—',
},
{
id: 'municipalities',
head: <CampaignTableHead>Municípios</CampaignTableHead>,
cellClassName: 'tabular-nums',
cell: (row) => row.municipalityCount,
},
{
id: 'leaderships',
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
cellClassName: 'tabular-nums',
cell: (row) => row.leadershipCount,
},
]
export default async function StateDeputiesPage({ searchParams }: StateDeputiesPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -48,14 +74,6 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
const state = parseStateDeputyListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadStateDeputyListPageData(payload, user, state)
const hrefForPage = (page: number) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/dobradinhas?${query}` : '/campanha/dobradinhas'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -85,66 +103,29 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<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>
<CampaignTable columns={stateDeputyColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="dobradinha"
plural="dobradinhas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildStateDeputyListHref(state, page)}
/>
</>
) : (
<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>
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma dobradinha cadastrada"
description="Cadastre deputados estaduais parceiros para vincular a municípios e lideranças."
>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</CampaignListEmptyState>
)}
</CampaignListResults>
</CampaignListPendingBoundary>

View file

@ -1,43 +1,80 @@
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'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/CampaignTable'
import { SupportStatusBadge } from '@/components/campaign/SupportStatusBadge'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { loadLeadershipListPageData, parseLeadershipListParams } from '@/utilities/leadershipData'
import {
buildLeadershipListHref,
loadLeadershipListPageData,
parseLeadershipListParams,
type LeadershipRowViewModel,
} from '@/utilities/leadershipData'
type LeadershipsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const leadershipColumns: Array<CampaignTableColumn<LeadershipRowViewModel>> = [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cell: (row) => (
<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>
),
},
{
id: 'supportStatus',
head: <CampaignTableHead>Status</CampaignTableHead>,
cell: (row) => (row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'),
},
{
id: 'municipalities',
head: <CampaignTableHead>Praças</CampaignTableHead>,
cellClassName: 'max-w-64 whitespace-normal text-muted-foreground',
cell: (row) => row.municipalityNames.join(', ') || '—',
},
{
id: 'organizations',
head: <CampaignTableHead>Organizações</CampaignTableHead>,
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (row) => row.organizationNames.join(', ') || '—',
},
{
id: 'appAccess',
head: <CampaignTableHead>Acesso ao app</CampaignTableHead>,
cell: (row) => (
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
</Badge>
),
},
]
export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -47,14 +84,6 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
const state = parseLeadershipListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadLeadershipListPageData(payload, user, state)
const hrefForPage = (page: number) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/liderancas?${query}` : '/campanha/liderancas'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -83,83 +112,29 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
<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>
<CampaignTable columns={leadershipColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="liderança"
plural="lideranças"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildLeadershipListHref(state, page)}
/>
</>
) : (
<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>
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma liderança encontrada"
description="Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas Praças."
>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</CampaignListEmptyState>
)}
</CampaignListResults>
</CampaignListPendingBoundary>

View file

@ -1,46 +1,71 @@
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'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
buildOrganizationListHref,
loadOrganizationListPageData,
parseOrganizationListParams,
type OrganizationRowViewModel,
} from '@/utilities/organizationData'
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
const organizationColumns: Array<CampaignTableColumn<OrganizationRowViewModel>> = [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cell: (row) => (
<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>
),
},
{
id: 'kind',
head: <CampaignTableHead>Tipo</CampaignTableHead>,
cell: (row) => <Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>,
},
{
id: 'municipalities',
head: <CampaignTableHead>Praças de atuação</CampaignTableHead>,
cellClassName: 'max-w-64 whitespace-normal text-muted-foreground',
cell: (row) => row.municipalityNames.join(', ') || '—',
},
{
id: 'leaderships',
head: <CampaignTableHead>Lideranças</CampaignTableHead>,
cellClassName: 'tabular-nums',
cell: (row) => row.leadershipCount,
},
]
export default async function OrganizationsPage({ searchParams }: OrganizationsPageProps) {
const rawSearchParams = await searchParams
const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })])
@ -50,15 +75,6 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
const state = parseOrganizationListParams(rawSearchParams)
const { rows, totalDocs, totalPages } = await loadOrganizationListPageData(payload, user, state)
const hrefForPage = (page: number) => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
const query = params.toString()
return query ? `/campanha/organizacoes?${query}` : '/campanha/organizacoes'
}
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -88,71 +104,29 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
<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>
<CampaignTable columns={organizationColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="organização"
plural="organizações"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildOrganizationListHref(state, page)}
/>
</>
) : (
<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>
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma organização cadastrada"
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de Ação."
>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</CampaignListEmptyState>
)}
</CampaignListResults>
</CampaignListPendingBoundary>

View file

@ -10,17 +10,10 @@ import { getPayload } from 'payload'
import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters'
import { ActionPlanList } from '@/components/campaign/ActionPlanList'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import type { ActionPlan } from '@/payload-types'
import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData'
import {
@ -88,35 +81,25 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
<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>
<CampaignListFooter
totalDocs={result.totalDocs}
singular="plano encontrado"
plural="planos encontrados"
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</>
) : (
<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>
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhum plano encontrado"
description="Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo."
>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</CampaignListEmptyState>
)}
</CampaignListResults>
</CampaignListPendingBoundary>

View file

@ -17,15 +17,8 @@ import { toast } from 'sonner'
import { AdvisorDebouncedTextCell } from '@/components/campaign/AdvisorDebouncedTextCell'
import { AdvisorMunicipalityCell } from '@/components/campaign/AdvisorMunicipalityCell'
import { AdvisorPasswordResetButton } from '@/components/campaign/AdvisorPasswordResetButton'
import { CampaignListEmptyState } from '@/components/campaign/CampaignListEmptyState'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { Input } from '@/components/ui/input'
import { Spinner } from '@/components/ui/Spinner'
import {
@ -204,29 +197,22 @@ export const AdvisorsTable = ({
</div>
{showEmpty ? (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
{hasQuery ? <SearchXIcon aria-hidden="true" /> : <UserCogIcon aria-hidden="true" />}
</EmptyMedia>
<EmptyTitle>
{hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
</EmptyTitle>
<EmptyDescription>
{hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'}
</EmptyDescription>
</EmptyHeader>
<CampaignListEmptyState
icon={hasQuery ? SearchXIcon : UserCogIcon}
title={hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
description={
hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'
}
>
{!hasQuery ? (
<EmptyContent>
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
</EmptyContent>
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
) : null}
</Empty>
</CampaignListEmptyState>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[56rem] table-fixed">

View file

@ -0,0 +1,40 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
/**
* The standard campaign list empty state (icon, title, description, optional
* CTA). Rendered inside `CampaignListResults` so it dims with the shared
* transition like the rows it replaces.
*/
export const CampaignListEmptyState = ({
icon: Icon,
title,
description,
children,
}: {
icon: LucideIcon
title: string
description: ReactNode
/** Call-to-action button(s). */
children?: ReactNode
}) => (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
{children ? <EmptyContent>{children}</EmptyContent> : null}
</Empty>
)

View file

@ -1,17 +1,14 @@
import { PhoneIcon } from 'lucide-react'
import Link from 'next/link'
import {
CampaignTable,
CampaignTableHead,
type CampaignTableColumn,
} from '@/components/campaign/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/Table'
import { formatBrazilianPhoneInput } from '@/utilities/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporterUi'
import type { SupporterListItemViewModel } from '@/utilities/supporterViewModels'
@ -85,6 +82,66 @@ const SupporterCard = ({ supporter }: { supporter: SupporterListItemViewModel })
)
}
const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> = [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
cellClassName: 'max-w-52 whitespace-normal',
cell: (supporter) => (
<Link
href={`/campanha/apoiadores/${supporter.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.name}
</Link>
),
},
{
id: 'city',
head: <CampaignTableHead>Município</CampaignTableHead>,
cell: (supporter) => supporter.city ?? '—',
},
{
id: 'municipality',
head: <CampaignTableHead>Praça</CampaignTableHead>,
cellClassName: 'max-w-48 whitespace-normal',
cell: (supporter) =>
supporter.municipalityName ? (
<Link
href={`/campanha/municipios/${supporter.municipalitySlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{supporter.municipalityName}
</Link>
) : (
<span className="text-muted-foreground">Sem Praça vinculada</span>
),
},
{
id: 'voteIntention',
head: <CampaignTableHead>Intenção</CampaignTableHead>,
cell: (supporter) => {
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
return voteIntentionLabel ? (
<Badge variant={voteIntentionBadgeVariant(supporter.voteIntention)}>
{voteIntentionLabel}
</Badge>
) : (
<Badge variant="outline">Sem intenção</Badge>
)
},
},
{
id: 'phone',
head: <CampaignTableHead>Telefone</CampaignTableHead>,
cellClassName: 'tabular-nums',
cell: (supporter) => (supporter.phone ? formatBrazilianPhoneInput(supporter.phone) : '—'),
},
]
export const SupporterList = ({ supporters }: SupporterListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
@ -93,63 +150,11 @@ export const SupporterList = ({ supporters }: SupporterListProps) => (
))}
</div>
<div data-view="desktop-table" className="hidden overflow-hidden rounded-xl border md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Município</TableHead>
<TableHead>Praça</TableHead>
<TableHead>Intenção</TableHead>
<TableHead>Telefone</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{supporters.map((supporter) => {
const voteIntentionLabel = supporter.voteIntention
? supporterVoteIntentionLabels[supporter.voteIntention]
: null
return (
<TableRow key={supporter.id}>
<TableCell className="max-w-52 whitespace-normal">
<Link
href={`/campanha/apoiadores/${supporter.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{supporter.name}
</Link>
</TableCell>
<TableCell>{supporter.city ?? '—'}</TableCell>
<TableCell className="max-w-48 whitespace-normal">
{supporter.municipalityName ? (
<Link
href={`/campanha/municipios/${supporter.municipalitySlug}`}
className="text-primary underline-offset-4 hover:underline"
>
{supporter.municipalityName}
</Link>
) : (
<span className="text-muted-foreground">Sem Praça vinculada</span>
)}
</TableCell>
<TableCell>
{voteIntentionLabel ? (
<Badge variant={voteIntentionBadgeVariant(supporter.voteIntention)}>
{voteIntentionLabel}
</Badge>
) : (
<Badge variant="outline">Sem intenção</Badge>
)}
</TableCell>
<TableCell className="tabular-nums">
{supporter.phone ? formatBrazilianPhoneInput(supporter.phone) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<CampaignTable
className="hidden md:block"
columns={supporterColumns}
rows={supporters}
rowKey={(supporter) => supporter.id}
/>
</>
)

View file

@ -4,6 +4,12 @@ import type { CampaignDemandKind, CampaignDemandStatus } from '@/lib/schemas/cam
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import type { ActionPlan, CampaignDemand, CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildListHref,
firstValue,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export const demandPageSize = 25
@ -26,16 +32,12 @@ export type DemandListState = {
kind?: CampaignDemandKind
}
export const parseDemandListParams = (
searchParams: Record<string, string | string[] | undefined>,
): DemandListState => {
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)
const rawStatus = first(searchParams.status)
const rawKind = first(searchParams.kind)
const rawPage = first(searchParams.page)
export const parseDemandListParams = (searchParams: RawSearchParams): DemandListState => {
const rawStatus = firstValue(searchParams.status)
const rawKind = firstValue(searchParams.kind)
return {
page: rawPage && /^[1-9]\d*$/.test(rawPage) ? Number(rawPage) : 1,
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(campaignDemandStatuses.includes(rawStatus as CampaignDemandStatus)
? { status: rawStatus as CampaignDemandStatus }
: {}),
@ -45,6 +47,17 @@ export const parseDemandListParams = (
}
}
const buildDemandListSearchParams = (state: DemandListState, page = state.page): URLSearchParams => {
const params = new URLSearchParams()
if (state.status) params.set('status', state.status)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
return params
}
export const buildDemandListHref = (state: DemandListState, page: number): string =>
buildListHref(state, buildDemandListSearchParams, '/campanha/demandas', page)
const resolveMunicipalityAndRequesterNames = async (
payload: Payload,
demands: CampaignDemand[],

View file

@ -4,6 +4,13 @@ import type { SupportStatus } from '@/lib/schemas/leadership'
import { isSupportStatus } from '@/lib/schemas/leadership'
import type { CampaignUser, Leadership } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import { loadStateDeputySummaries, type StateDeputySummary } from '@/utilities/stateDeputyData'
@ -162,18 +169,28 @@ export type LeadershipListState = {
}
export const parseLeadershipListParams = (
searchParams: Record<string, string | string[] | undefined>,
searchParams: RawSearchParams,
): LeadershipListState => {
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)
const q = first(searchParams.q)?.trim()
const rawPage = first(searchParams.page)
const q = normalizedText(firstValue(searchParams.q))
return {
page: rawPage && /^[1-9]\d*$/.test(rawPage) ? Number(rawPage) : 1,
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
}
}
const buildLeadershipListSearchParams = (
state: LeadershipListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const buildLeadershipListHref = (state: LeadershipListState, page: number): string =>
buildListHref(state, buildLeadershipListSearchParams, '/campanha/liderancas', page)
export const loadLeadershipListPageData = async (
payload: Payload,
user: CampaignUser,

View file

@ -3,6 +3,13 @@ import type { Payload } from 'payload'
import type { OrganizationKind } from '@/lib/schemas/organization'
import { organizationKinds } from '@/lib/schemas/organization'
import type { CampaignUser, Organization } from '@/payload-types'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import { relationshipId } from '@/utilities/relationship'
export const organizationPageSize = 25
@ -23,15 +30,13 @@ export type OrganizationListState = {
}
export const parseOrganizationListParams = (
searchParams: Record<string, string | string[] | undefined>,
searchParams: RawSearchParams,
): OrganizationListState => {
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)
const q = first(searchParams.q)?.trim()
const rawKind = first(searchParams.kind)
const rawPage = first(searchParams.page)
const q = normalizedText(firstValue(searchParams.q))
const rawKind = firstValue(searchParams.kind)
return {
page: rawPage && /^[1-9]\d*$/.test(rawPage) ? Number(rawPage) : 1,
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
...(organizationKinds.includes(rawKind as OrganizationKind)
? { kind: rawKind as OrganizationKind }
@ -39,6 +44,20 @@ export const parseOrganizationListParams = (
}
}
const buildOrganizationListSearchParams = (
state: OrganizationListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (state.kind) params.set('kind', state.kind)
if (page > 1) params.set('page', String(page))
return params
}
export const buildOrganizationListHref = (state: OrganizationListState, page: number): string =>
buildListHref(state, buildOrganizationListSearchParams, '/campanha/organizacoes', page)
const municipalityNamesByIds = async (payload: Payload, ids: number[]): Promise<Map<number, string>> => {
if (ids.length === 0) return new Map()
const result = await payload.find({

View file

@ -1,6 +1,13 @@
import type { Payload } from 'payload'
import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types'
import {
buildListHref,
firstValue,
normalizedText,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
import { relationshipId } from '@/utilities/relationship'
export const stateDeputyPageSize = 25
@ -20,18 +27,28 @@ export type StateDeputyListState = {
}
export const parseStateDeputyListParams = (
searchParams: Record<string, string | string[] | undefined>,
searchParams: RawSearchParams,
): StateDeputyListState => {
const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value)
const q = first(searchParams.q)?.trim()
const rawPage = first(searchParams.page)
const q = normalizedText(firstValue(searchParams.q))
return {
page: rawPage && /^[1-9]\d*$/.test(rawPage) ? Number(rawPage) : 1,
page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1,
...(q ? { q } : {}),
}
}
const buildStateDeputyListSearchParams = (
state: StateDeputyListState,
page = state.page,
): URLSearchParams => {
const params = new URLSearchParams()
if (state.q) params.set('q', state.q)
if (page > 1) params.set('page', String(page))
return params
}
export const buildStateDeputyListHref = (state: StateDeputyListState, page: number): string =>
buildListHref(state, buildStateDeputyListSearchParams, '/campanha/dobradinhas', page)
export const loadStateDeputyListPageData = async (
payload: Payload,
user: CampaignUser,