B137 — Colunas ao lado da omnibox e fim do Ordenado por residual

Move CampaignColumnPicker to omnibox trailing via CampaignColumnPickerTrailing
on all seven B17 list surfaces (+ organizações). Remove the picker row from
CampaignTable and drop visible sort-summary spans on territórios, lideranças
and dobradinhas (sr-only table captions keep sort context).

Closes #304

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 23:04:14 +00:00
parent e3a54be217
commit d6455b1333
No known key found for this signature in database
22 changed files with 262 additions and 50 deletions

View file

@ -0,0 +1,58 @@
# Impl: B137 — Colunas ao lado da omnibox (e fim do “Ordenado por …” residual)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #304
Intenção: docs/plans/colunas-ao-lado-omnibox-listas.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome:** Em todas as listas com seletor de colunas (B17), o botão **Colunas** fica na mesma fileira da omnibox (`trailing`); dados começam logo abaixo da barra. O span visível **“Ordenado por …”** some em Territórios, Lideranças e Dobradinhas (sort continua na omnibox/header + caption `sr-only`).
- **O que NÃO negociar:** persistência/contrato do picker (cookie B17); `hidden md:flex` no mobile (gate 2026-08-02 opção A); lockdown de liderança; não mover sort para fora da omnibox/header.
- **O que reavaliar:** hipótese de editar só `CampaignTable` — o picker precisa sair da tabela e entrar nos `*Filters` via `trailing`, porque a omnibox já vive nos filtros.
## Abordagem recomendada
**Opções consideradas:**
- A) Manter picker em `CampaignTable` com CSS `absolute` ao lado da omnibox — rejeitada: DOM distinto, frágil entre listas com/sem cards mobile.
- B) Slot `trailing` nos `*Filters` + remover picker de `CampaignTable` — **recomendada**: precedente B127 (`SaveMunicipalityFilterControl`), boundary de pending intacto.
- C) Novo shell `CampaignListToolbar` — rejeitada: twin desnecessário.
**Recomendação:** B — extrair `CampaignColumnPickerTrailing` (wrapper `hidden md:flex` + `CampaignColumnPicker`), helper `toCampaignColumnPickerColumns`, exportar metadados de colunas onde a tabela é dinâmica (municípios/apoiadores/territórios), páginas estáticas mapeiam `demandColumns`/`organizationColumns`/etc.
### Componentes / mudanças
- **`CampaignColumnPickerTrailing`** (`shared/CampaignColumnPickerTrailing.tsx`): client; encapsula breakpoint B17.
- **`toCampaignColumnPickerColumns`** (`lib/campaignColumnVisibility.ts`): puro; mapeia `{id,label,mandatory}`.
- **`CampaignTable`**: remove render do picker; mantém `columnVisibility` só para `resolveVisibleColumns`.
- **`*Filters.tsx` (7 listas)**: prop opcional `trailing?: ReactNode` composta com trailing existente.
- **Páginas/listas**: passam picker no `trailing` dos filtros; removem `<p>Ordenado por…</p>` visível.
- **Exports:** `municipalityListPickerColumns`, `supporterPickerColumns`, `territoryListPickerColumns`.
- **Migration:** sem migration.
### Dados → forma
N/A — só chrome de layout.
## Fases verificáveis
1. **Chassis** — helper + trailing component + remover picker de `CampaignTable`.
2. **Wire-up** — 7 listas + organizações; remover sort span residual.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Picker mobile abaixo de `md` — fora (B17 lacuna).
- Reordenar colunas / export / cenário na barra.
## Riscos e mitigação
- **Picker fora de `CampaignListResults`:** filtros já estão dentro de `CampaignListPendingBoundary` — `useCampaignListTransition` continua correto.
- **Colunas dinâmicas (staff vs leader):** `municipalityListPickerColumns({ isStaffView })` espelha a mesma bifurcação da tabela.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit existentes do picker; sem mudança de access/write)

View file

@ -9,10 +9,11 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SupporterFilters } from '@/components/campaign/supporter/SupporterFilters'
import { SupporterList } from '@/components/campaign/supporter/SupporterList'
import { SupporterList, supporterPickerColumns } from '@/components/campaign/supporter/SupporterList'
import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
@ -102,6 +103,12 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
key={buildSupporterFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={supporterPickerColumns}
/>
}
/>
<CampaignListResults>{listBody}</CampaignListResults>

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { getPayload } from 'payload'
import { DemandFilters } from '@/components/campaign/demand/DemandFilters'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
@ -15,6 +16,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
@ -117,7 +119,15 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
</div>
<CampaignListPendingBoundary>
<DemandFilters state={state} />
<DemandFilters
state={state}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(demandColumns)}
/>
}
/>
<CampaignListResults>
<CampaignTable

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
@ -33,7 +34,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import { resolveVisibleColumns, toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
@ -240,12 +241,15 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
state={state}
partyOptions={partyFilterOptions}
hasNoParty={filterFacets.hasNoParty}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListResults>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>

View file

@ -6,6 +6,7 @@ import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
import {
LeadershipFilterHead,
@ -40,7 +41,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import { resolveVisibleColumns, toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import {
@ -415,12 +416,15 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
municipalityFilterOptions={municipalityFilterOptions}
organizationFilterOptions={organizationFilterOptions}
stateDeputyFilterOptions={stateDeputyFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListResults>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>

View file

@ -9,8 +9,12 @@ import { getPayload } from 'payload'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import {
MunicipalityList,
municipalityListPickerColumns,
} from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListPageChrome } from '@/components/campaign/municipality/MunicipalityListPageChrome'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
@ -101,6 +105,12 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
regionFilterOptions={columnFilterOptions.region}
advisorFilterOptions={columnFilterOptions.advisor}
slugFilterValues={columnFilterOptions.name}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={municipalityListPickerColumns({ isStaffView })}
/>
}
/>
)

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { getPayload } from 'payload'
import { OrganizationFilters } from '@/components/campaign/organization/OrganizationFilters'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
@ -15,6 +16,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -87,7 +89,15 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
</div>
<CampaignListPendingBoundary>
<OrganizationFilters state={state} />
<OrganizationFilters
state={state}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(organizationColumns)}
/>
}
/>
<CampaignListResults>
<CampaignTable

View file

@ -3,7 +3,9 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
import { territoryListPickerColumns } from '@/components/campaign/municipality/TerritoryListColumns'
import { TerritoryList } from '@/components/campaign/municipality/TerritoryList'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
CampaignListPendingBoundary,
@ -48,7 +50,16 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
return (
<CampaignPageShell>
<CampaignListPendingBoundary>
<TerritoryFilters state={state} regionOptions={regionOptions} />
<TerritoryFilters
state={state}
regionOptions={regionOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={territoryListPickerColumns}
/>
}
/>
<CampaignListResults>
<TerritoryList
rows={rows}

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -15,7 +15,13 @@ import {
type DemandOmniboxAction,
} from '@/utilities/demand/demandOmnibox'
export const DemandFilters = ({ state }: { state: DemandListState }) => {
export const DemandFilters = ({
state,
trailing,
}: {
state: DemandListState
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildDemandListHref(next, 1),
@ -68,6 +74,7 @@ export const DemandFilters = ({ state }: { state: DemandListState }) => {
onClearAll={() => {
runAction(clearDemandOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -24,11 +24,13 @@ export const LeadershipFilters = ({
municipalityFilterOptions,
organizationFilterOptions,
stateDeputyFilterOptions,
trailing,
}: {
state: LeadershipListState
municipalityFilterOptions: LeadershipFilterOption[]
organizationFilterOptions: LeadershipFilterOption[]
stateDeputyFilterOptions: LeadershipFilterOption[]
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -132,6 +134,7 @@ export const LeadershipFilters = ({
onClearAll={() => {
runAction(clearLeadershipOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { useMunicipalityEstimateScenarioOptional } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { SaveMunicipalityFilterControl } from '@/components/campaign/municipality/SaveMunicipalityFilterControl'
@ -32,6 +32,8 @@ type MunicipalityFiltersProps = {
advisorFilterOptions: MunicipalityFilterOption[]
/** Facet slugs (labeled from the catalog on the client). */
slugFilterValues?: readonly string[]
/** Beside the omnibox (B137): column picker, save bookmark, … */
trailing?: ReactNode
}
export const MunicipalityFilters = ({
@ -40,6 +42,7 @@ export const MunicipalityFilters = ({
regionFilterOptions,
advisorFilterOptions,
slugFilterValues = [],
trailing,
}: MunicipalityFiltersProps) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -133,7 +136,12 @@ export const MunicipalityFilters = ({
onClearAll={() => {
runAction(clearMunicipalityOmnibox(state))
}}
trailing={<SaveMunicipalityFilterControl state={state} />}
trailing={
<>
{trailing}
<SaveMunicipalityFilterControl state={state} />
</>
}
/>
</form>
)

View file

@ -35,6 +35,7 @@ import {
} from '@/components/ui/Empty'
import {
resolveVisibleColumns,
type CampaignColumnPickerColumn,
type CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
@ -412,6 +413,29 @@ const municipalityListColumns = ({
] satisfies Array<MunicipalityColumn>)),
]
export const municipalityListPickerColumns = ({
isStaffView,
}: Pick<MunicipalityListProps, 'isStaffView'>): CampaignColumnPickerColumn[] => {
const base: CampaignColumnPickerColumn[] = [
{ id: 'name', label: municipalityColumnLabels.name, mandatory: true },
{ id: 'region', label: municipalityColumnLabels.region },
{ id: 'votos', label: municipalityColumnLabels.votos },
]
if (!isStaffView) return base
return [
...base,
{ id: 'classe', label: municipalityColumnLabels.classe },
{ id: 'level', label: municipalityColumnLabels.level },
{ id: 'advisors', label: municipalityColumnLabels.advisors },
{ id: 'trend', label: municipalityColumnLabels.trend },
{ id: 'expectedVotes', label: municipalityColumnLabels.expectedVotes },
{ id: 'lastSignal', label: municipalityColumnLabels.lastSignal },
{ id: 'goalCoverage', label: municipalityColumnLabels.goalCoverage },
{ id: 'lastUpdateAt', label: municipalityColumnLabels.lastUpdateAt },
]
}
export const MunicipalityList = (props: MunicipalityListProps) => {
const { municipalities, advisorNamesById, isStaffView, isCoordinator, advisorOptions, state } =
props

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -22,9 +22,11 @@ import {
export const TerritoryFilters = ({
state,
regionOptions,
trailing,
}: {
state: TerritoryListState
regionOptions: TerritoryFilterOption[]
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -81,6 +83,7 @@ export const TerritoryFilters = ({
onClearAll={() => {
runAction(clearTerritoryOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -69,12 +69,8 @@ export const TerritoryList = ({
const sortSummary = formatTerritoryListSortSummary(sort, dir)
return (
<>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Comparação dos Territórios de Identidade. Leitura regional; a alocação é decidida por município.`}
<CampaignTable
caption={`${sortSummary}. Comparação dos Territórios de Identidade. Leitura regional; a alocação é decidida por município.`}
className="overflow-visible"
containerClassName="overflow-x-auto"
headerClassName="[&_th]:sticky [&_th]:top-0 [&_th]:z-10 [&_th]:bg-background [&_th]:shadow-[inset_0_-1px_0_var(--border)] [&_th:first-child]:rounded-tl-xl [&_th:last-child]:rounded-tr-xl [&_tr]:border-b-0"
@ -90,6 +86,5 @@ export const TerritoryList = ({
}
empty={<TerritoryListEmptyState state={state} />}
/>
</>
)
}

View file

@ -4,6 +4,7 @@ import { TerritorySortableHead } from '@/components/campaign/municipality/Territ
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import type { CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
import type { CampaignColumnPickerColumn } from '@/lib/campaignColumnVisibility'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
formatGoalCoverageDeficitShortLabel,
@ -139,6 +140,19 @@ const captureTooltipContent = (row: TerritoryTableRow) => {
)
}
export const territoryListPickerColumns: CampaignColumnPickerColumn[] = [
{ id: 'region', label: territoryListSortLabels.region, mandatory: true },
{ id: 'municipalities', label: territoryListSortLabels.municipalities },
{ id: 'votes2022', label: territoryListSortLabels.votes2022 },
{ id: 'pct', label: territoryListSortLabels.pct },
{ id: 'validVotes2022', label: territoryListSortLabels.validVotes2022 },
{ id: 'estimate2026', label: territoryListSortLabels.estimate2026 },
{ id: 'cobertura', label: territoryListSortLabels.cobertura },
{ id: 'captura', label: territoryListSortLabels.captura },
{ id: 'classe', label: territoryListSortLabels.classe },
{ id: 'coverage', label: territoryListSortLabels.coverage },
]
export const territoryListColumns = ({
state,
regionOptions,

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -18,7 +18,13 @@ import {
type OrganizationOmniboxAction,
} from '@/utilities/organization/organizationOmnibox'
export const OrganizationFilters = ({ state }: { state: OrganizationListState }) => {
export const OrganizationFilters = ({
state,
trailing,
}: {
state: OrganizationListState
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: (next) => buildOrganizationListHref(next, 1),
@ -76,6 +82,7 @@ export const OrganizationFilters = ({ state }: { state: OrganizationListState })
onClearAll={() => {
runAction(clearOrganizationOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -0,0 +1,30 @@
'use client'
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
import type {
CampaignColumnPickerColumn,
CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
type CampaignColumnPickerTrailingProps = {
columnVisibility: CampaignColumnVisibility
columns: readonly CampaignColumnPickerColumn[]
}
/**
* B137 — column picker beside the list omnibox (`trailing`), not stacked above
* the table. Gated at `md:` because municípios and apoiadores hide the table
* below that width and show cards instead (B17).
*/
export const CampaignColumnPickerTrailing = ({
columnVisibility,
columns,
}: CampaignColumnPickerTrailingProps) => (
<div className="hidden md:flex">
<CampaignColumnPicker
listId={columnVisibility.listId}
columns={columns}
hiddenColumnIds={columnVisibility.hiddenColumnIds}
/>
</div>
)

View file

@ -1,7 +1,6 @@
import { Fragment, type ReactNode } from 'react'
import { CampaignCellTooltip } from '@/components/campaign/shared/CampaignCellTooltip'
import { CampaignColumnPicker } from '@/components/campaign/shared/CampaignColumnPicker'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
import {
Table,
@ -140,9 +139,9 @@ export const CampaignTableHead = ({
type CampaignTableProps<Row> = {
columns: Array<CampaignTableColumn<Row>>
/**
* B17 — passing this turns the column picker on. The caller reads the cookie
* (`readCampaignColumnVisibility`) and hands the result over whole: hidden
* columns are never rendered, so they cost nothing in the RSC payload either.
* B17 — hidden column ids from the cookie (`readCampaignColumnVisibility`).
* The picker itself renders in the omnibox `trailing` (B137); this prop only
* filters which columns the table paints.
*/
columnVisibility?: CampaignColumnVisibility
rows: readonly Row[]
@ -179,21 +178,6 @@ export const CampaignTable = <Row,>({
return (
<>
{columnVisibility ? (
// Gated at `md:` because municípios and apoiadores hide this table
// below that width (`className="hidden md:block"`) and show curated
// cards instead — a picker over a table nobody can see is noise. The
// cost is that the five surfaces which DO scroll this table on a phone
// have no picker there; giving them one needs a per-caller seam, not a
// breakpoint (registered as a fill-in in the B17 plan).
<div className="hidden justify-end md:flex">
<CampaignColumnPicker
listId={columnVisibility.listId}
columns={columns.map(({ id, label, mandatory }) => ({ id, label, mandatory }))}
hiddenColumnIds={columnVisibility.hiddenColumnIds}
/>
</div>
) : null}
<div className={cn('overflow-hidden rounded-xl border', className)}>
<Table containerClassName={containerClassName}>
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
@ -23,10 +23,12 @@ export const StateDeputyFilters = ({
state,
partyOptions,
hasNoParty,
trailing,
}: {
state: StateDeputyListState
partyOptions: StateDeputyFilterOption[]
hasNoParty: boolean
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -83,6 +85,7 @@ export const StateDeputyFilters = ({
onClearAll={() => {
runAction(clearStateDeputyOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -1,6 +1,6 @@
'use client'
import { useMemo, useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
@ -20,9 +20,11 @@ import { municipalityComboboxOptions } from '@/utilities/territory/territoryComb
export const SupporterFilters = ({
state,
municipalityOptions,
trailing,
}: {
state: SupporterListState
municipalityOptions: RelationOption[]
trailing?: ReactNode
}) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
@ -109,6 +111,7 @@ export const SupporterFilters = ({
onClearAll={() => {
runAction(clearSupporterOmnibox(state))
}}
trailing={trailing}
/>
</form>
)

View file

@ -5,7 +5,10 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
import {
toCampaignColumnPickerColumns,
type CampaignColumnVisibility,
} from '@/lib/campaignColumnVisibility'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { supporterVoteIntentionLabels } from '@/utilities/supporter/supporterUi'
import type { SupporterListItemViewModel } from '@/utilities/supporter/supporterViewModels'
@ -139,6 +142,8 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
},
]
export const supporterPickerColumns = toCampaignColumnPickerColumns(supporterColumns)
export const SupporterList = ({ supporters, columnVisibility, empty }: SupporterListProps) => (
<>
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">

View file

@ -42,6 +42,18 @@ export type CampaignColumnVisibility = {
hiddenColumnIds: readonly string[]
}
/** Serializable column metadata for `CampaignColumnPicker` (B17 / B137). */
export type CampaignColumnPickerColumn = {
id: string
label: string
mandatory?: boolean
}
export const toCampaignColumnPickerColumns = (
columns: readonly { id: string; label: string; mandatory?: boolean }[],
): CampaignColumnPickerColumn[] =>
columns.map(({ id, label, mandatory }) => ({ id, label, mandatory }))
/**
* `listId:colA~colB|otherList:colC`. Every character here is legal in a
* cookie value (RFC 6265 forbids comma, semicolon, whitespace, quotes and