Merge pull request #327 from fsolla/cursor/pool-i304-dfa7cbb3-3c18
B137 — Colunas ao lado da omnibox (e fim do “Ordenado por …” residual)
This commit is contained in:
commit
fd29d0ecb4
22 changed files with 322 additions and 128 deletions
59
docs/plans/colunas-ao-lado-omnibox-listas-impl.md
Normal file
59
docs/plans/colunas-ao-lado-omnibox-listas-impl.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# 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)
|
||||
|
|
@ -8,11 +8,15 @@ import Link from 'next/link'
|
|||
import { redirect } from 'next/navigation'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
|
||||
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 +106,12 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
key={buildSupporterFiltersKey(state)}
|
||||
state={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={supporterPickerColumns}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CampaignListResults>{listBody}</CampaignListResults>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -14,6 +15,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import {
|
||||
campaignDemandKindLabels,
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -186,8 +187,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
])
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
|
||||
const isLeadershipVisible =
|
||||
resolveVisibleColumns([{ id: 'leaderships' }], columnVisibility.hiddenColumnIds).length > 0
|
||||
const isLeadershipVisible = !columnVisibility.hiddenColumnIds.includes('leaderships')
|
||||
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
|
|
@ -240,12 +240,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>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
LeadershipFilterHead,
|
||||
LeadershipSortableHead,
|
||||
} from '@/components/campaign/leadership/LeadershipSortableHead'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
|
|
@ -40,7 +41,7 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import {
|
||||
|
|
@ -327,8 +328,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
])
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('liderancas')
|
||||
const isStateDeputyVisible =
|
||||
resolveVisibleColumns([{ id: 'stateDeputies' }], columnVisibility.hiddenColumnIds).length > 0
|
||||
const isStateDeputyVisible = !columnVisibility.hiddenColumnIds.includes('stateDeputies')
|
||||
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
|
|
@ -415,12 +415,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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -14,6 +15,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
|
|||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { organizationKindLabels } from '@/lib/schemas/organization'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import { getPayload } from 'payload'
|
|||
|
||||
import { TerritoryFilters } from '@/components/campaign/municipality/TerritoryFilters'
|
||||
import { TerritoryList } from '@/components/campaign/municipality/TerritoryList'
|
||||
import { territoryListColumns } from '@/components/campaign/municipality/TerritoryListColumns'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -48,7 +51,18 @@ 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={toCampaignColumnPickerColumns(
|
||||
territoryListColumns({ state, regionOptions: regionOptions }),
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
<TerritoryList
|
||||
rows={rows}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,27 +69,20 @@ 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.`}
|
||||
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"
|
||||
columns={territoryListColumns({ state, regionOptions })}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) =>
|
||||
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
|
||||
}
|
||||
rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)}
|
||||
rowClassName={(row) =>
|
||||
row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50'
|
||||
}
|
||||
empty={<TerritoryListEmptyState state={state} />}
|
||||
/>
|
||||
</>
|
||||
<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"
|
||||
columns={territoryListColumns({ state, regionOptions })}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={flattenTerritoryRows(rows)}
|
||||
rowKey={(row) => (row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`)}
|
||||
rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)}
|
||||
rowClassName={(row) =>
|
||||
row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50'
|
||||
}
|
||||
empty={<TerritoryListEmptyState state={state} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,16 +15,11 @@ import {
|
|||
parseCampaignHiddenColumns,
|
||||
serializeCampaignHiddenColumns,
|
||||
toggleHiddenColumn,
|
||||
type CampaignColumnPickerColumn,
|
||||
type CampaignListId,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import { sameIdSet } from '@/lib/sameIdSet'
|
||||
|
||||
type CampaignColumnPickerColumn = {
|
||||
id: string
|
||||
label: string
|
||||
mandatory?: boolean
|
||||
}
|
||||
|
||||
type CampaignColumnPickerProps = {
|
||||
listId: CampaignListId
|
||||
/** Every column of the table, hidden ones included — this is the menu. */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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[]
|
||||
|
|
@ -178,74 +177,57 @@ export const CampaignTable = <Row,>({
|
|||
const visibleColumns = resolveVisibleColumns(columns, columnVisibility?.hiddenColumnIds)
|
||||
|
||||
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}
|
||||
<TableHeader className={headerClassName}>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column) => (
|
||||
<Fragment key={column.id}>
|
||||
{column.head ?? <CampaignTableHead>{column.label}</CampaignTableHead>}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && empty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={visibleColumns.length} className="whitespace-normal">
|
||||
{empty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
id={rowId?.(row)}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
const cell = column.cell(row)
|
||||
const tooltip = column.cellTooltip?.(row)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row)
|
||||
: column.cellClassName
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
<div className={cn('overflow-hidden rounded-xl border', className)}>
|
||||
<Table containerClassName={containerClassName}>
|
||||
{caption ? <TableCaption className="sr-only">{caption}</TableCaption> : null}
|
||||
<TableHeader className={headerClassName}>
|
||||
<TableRow>
|
||||
{visibleColumns.map((column) => (
|
||||
<Fragment key={column.id}>
|
||||
{column.head ?? <CampaignTableHead>{column.label}</CampaignTableHead>}
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && empty ? (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={visibleColumns.length} className="whitespace-normal">
|
||||
{empty}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={rowKey(row)}
|
||||
id={rowId?.(row)}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{visibleColumns.map((column) => {
|
||||
const cell = column.cell(row)
|
||||
const tooltip = column.cellTooltip?.(row)
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={
|
||||
typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row)
|
||||
: column.cellClassName
|
||||
}
|
||||
>
|
||||
{tooltip ? (
|
||||
<CampaignCellTooltip content={tooltip}>{cell}</CampaignCellTooltip>
|
||||
) : (
|
||||
cell
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue