mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/a33b5d8c'
This commit is contained in:
commit
a0cd604f21
9 changed files with 107 additions and 89 deletions
|
|
@ -7,6 +7,7 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md).
|
||||
- **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
|
||||
- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `<body>`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `<ul>` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# Escala e DRY pós-B17 (seletor de colunas)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-28
|
||||
Status: entregue
|
||||
Atualizado em: 2026-07-29
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B17+**)
|
||||
|
||||
**Revisão (2026-07-29):** F1 implementado via condicionamento dos loaders em `dobradinhas/page.tsx` e `liderancas/page.tsx` à leitura prévia do cookie `campaign_columns`. F2 implementado movendo o `CampaignListEmptyState` para a prop `empty` nas tabelas de demandas, organizações e apoiadores.
|
||||
Impeccable: A — N/A na F1 (só loader); a F2 move um bloco já desenhado (`CampaignListEmptyState`) para dentro de um shell existente, sem pixel novo
|
||||
Appetite: ~0,5 dia eng, duas fases independentes. Se a F1 sozinha estourar meio dia, a F2 é cortável.
|
||||
Responsável: —
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -45,32 +45,37 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
if (redirectHref) redirect(redirectHref)
|
||||
const columnVisibility = await readCampaignColumnVisibility('apoiadores')
|
||||
|
||||
const listBody = result.docs.length ? (
|
||||
const listBody = (
|
||||
<>
|
||||
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
|
||||
{result.docs.length > 0 && overview ? (
|
||||
<SupporterListOverview view={overview} now={now} />
|
||||
) : null}
|
||||
<SupporterList
|
||||
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
|
||||
columnVisibility={columnVisibility}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
<CampaignListFooter
|
||||
totalDocs={result.totalDocs}
|
||||
singular="apoiador encontrado"
|
||||
plural="apoiadores encontrados"
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildSupporterListHref(state, page)}
|
||||
/>
|
||||
{result.docs.length > 0 ? (
|
||||
<CampaignListFooter
|
||||
totalDocs={result.totalDocs}
|
||||
singular="apoiador encontrado"
|
||||
plural="apoiadores encontrados"
|
||||
page={state.page}
|
||||
totalPages={result.totalPages}
|
||||
hrefForPage={(page) => buildSupporterListHref(state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<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 (
|
||||
|
|
|
|||
|
|
@ -145,37 +145,36 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<CampaignTable
|
||||
columns={demandColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable
|
||||
columns={demandColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
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>
|
||||
)}
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="demanda"
|
||||
plural="demandas"
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildDemandListHref(state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
|
||||
|
|
@ -180,6 +181,10 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
|
||||
const isLeadershipVisible =
|
||||
resolveVisibleColumns([{ id: 'leaderships' }], columnVisibility.hiddenColumnIds).length > 0
|
||||
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
leadershipOptions,
|
||||
|
|
@ -187,7 +192,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
administeredIds,
|
||||
] = await Promise.all([
|
||||
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
|
||||
loadLeadershipOptions(payload, user),
|
||||
isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]),
|
||||
loadMunicipalityPortfolioIndex(),
|
||||
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
|
||||
])
|
||||
|
|
@ -195,7 +200,6 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('dobradinhas')
|
||||
const { sort, dir } = resolveStateDeputyListSort(state)
|
||||
const sortSummary = formatStateDeputyListSortSummary(sort, dir)
|
||||
const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party }))
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import {
|
||||
resolvedPortfolioEntriesById,
|
||||
|
|
@ -324,6 +325,10 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const columnVisibility = await readCampaignColumnVisibility('liderancas')
|
||||
const isStateDeputyVisible =
|
||||
resolveVisibleColumns([{ id: 'stateDeputies' }], columnVisibility.hiddenColumnIds).length > 0
|
||||
|
||||
const [
|
||||
{ rows, totalDocs, totalPages, filterFacets },
|
||||
stateDeputyOptions,
|
||||
|
|
@ -331,7 +336,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
administeredIds,
|
||||
] = await Promise.all([
|
||||
loadLeadershipListPageData(payload, user, canonicalUrl.state),
|
||||
loadStateDeputyOptions(payload, user),
|
||||
isStateDeputyVisible ? loadStateDeputyOptions(payload, user) : Promise.resolve([]),
|
||||
loadMunicipalityPortfolioIndex(),
|
||||
user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
|
||||
])
|
||||
|
|
@ -360,7 +365,6 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
|
||||
const { sort, dir } = resolveLeadershipListSort(state)
|
||||
const sortSummary = formatLeadershipListSortSummary(sort, dir)
|
||||
const columnVisibility = await readCampaignColumnVisibility('liderancas')
|
||||
const columns = leadershipColumns({
|
||||
state,
|
||||
stateDeputyOptions: stateDeputyOptions.map((option) => ({
|
||||
|
|
|
|||
|
|
@ -105,37 +105,36 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<CampaignTable
|
||||
columns={organizationColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.id}
|
||||
empty={
|
||||
<CampaignListEmptyState
|
||||
icon={SearchXIcon}
|
||||
title="Nenhuma organização cadastrada"
|
||||
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
|
||||
>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{rows.length ? (
|
||||
<>
|
||||
<CampaignTable
|
||||
columns={organizationColumns}
|
||||
columnVisibility={columnVisibility}
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CampaignListEmptyState
|
||||
icon={SearchXIcon}
|
||||
title="Nenhuma organização cadastrada"
|
||||
description="Cadastre sindicatos, associações e movimentos para vincular lideranças e atividades."
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<CampaignListFooter
|
||||
totalDocs={totalDocs}
|
||||
singular="organização"
|
||||
plural="organizações"
|
||||
page={state.page}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildOrganizationListHref(state, page)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { SupporterListItemViewModel } from '@/utilities/supporter/supporter
|
|||
export type SupporterListProps = {
|
||||
supporters: SupporterListItemViewModel[]
|
||||
columnVisibility: CampaignColumnVisibility
|
||||
empty?: React.ReactNode
|
||||
}
|
||||
|
||||
const voteIntentionBadgeVariant = (
|
||||
|
|
@ -138,9 +139,10 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
},
|
||||
]
|
||||
|
||||
export const SupporterList = ({ supporters, columnVisibility }: SupporterListProps) => (
|
||||
export const SupporterList = ({ supporters, columnVisibility, empty }: SupporterListProps) => (
|
||||
<>
|
||||
<div data-view="mobile-cards" className="flex flex-col gap-4 md:hidden">
|
||||
{supporters.length === 0 ? empty : null}
|
||||
{supporters.map((supporter) => (
|
||||
<SupporterCard key={supporter.id} supporter={supporter} />
|
||||
))}
|
||||
|
|
@ -152,6 +154,7 @@ export const SupporterList = ({ supporters, columnVisibility }: SupporterListPro
|
|||
columnVisibility={columnVisibility}
|
||||
rows={supporters}
|
||||
rowKey={(supporter) => supporter.id}
|
||||
empty={empty}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue