Merge branch 'cursor/3a35211d' (B45: catálogo de ações do Início por persona)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 14:44:46 -03:00
commit 60dcbb23c8
13 changed files with 327 additions and 23 deletions

View file

@ -7,10 +7,11 @@ alwaysApply: false
## Status
- **B46–B55 registrados no roadmap (2026-07-29, sem código):** thumb-zone do Início (**B46**) + busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 → B46 → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only; clique = rotas de detalhe existentes; TIs no grupo Municípios.
- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). **Sem** montar catálogo em `/campanha` — B45. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md). Desbloqueia **B45**.
- **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md). Próximo: **B46** thumb-zone → **B47–B55** busca global.
- **B46–B55 registrados no roadmap (2026-07-29, sem código):** thumb-zone do Início (**B46**) + busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only; clique = rotas de detalhe existentes; TIs no grupo Municípios.
- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md).
- **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**.
- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → **B45** catálogo → **B46** thumb-zone → **B47–B55** busca global (wizards depois).
- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → **B46** thumb-zone → **B47–B55** busca global (wizards depois).
- **E12+ entregue em código (2026-07-29):** escala/DRY pós-E12 — F1 forçada: `src/lib/metropolitanoTerritoryPeers.ts` unifica constantes, filtros Salvador vs demais RMS e `catalogPeersForSlug` (rollup E17/E12, `slugsForMetropolitanoSubRow`, benchmark T4). F2 não entrou — `benchmarkBySlug` já memoiza por slug. Spec `metropolitanoTerritoryPeers.unit.spec.ts`. Sem migration/UI. Plano: [`escala-dry-pos-e12.md`](../../../docs/plans/escala-dry-pos-e12.md).
- **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).

View file

@ -1,7 +1,7 @@
# Catálogo de ações do Início por persona
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: catálogo em `src/lib/campaignHomeActions.ts`; mount via `CampaignHomeActions` (client island — RSC não passa ícones Lucide); href de municípios sem assessor resolvido no server; e2e `campaignHomeActions.e2e.spec.ts`.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B45 — chassis UX-1)
Impeccable: B — mount da strip B44 no Início com catálogo por role
Appetite: ~0,5 dia eng; módulo client-safe + page wire-up; 2 atalhos com href; resto inerte

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,24 @@
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { buildMunicipalityListHref } from '@/utilities/municipality/municipalityListUrl'
export const dynamic = 'force-dynamic'
export default async function CampaignHomePage() {
await requireCampaignPageActor()
const user = await requireCampaignPageActor()
return <CampaignPageShell aria-label="Início">{null}</CampaignPageShell>
const uncoveredMunicipalitiesHref = isStaffCampaignRole(user.role)
? buildMunicipalityListHref({ page: 1, coverage: 'sem_assessor', sort: 'votos' }, 1)
: undefined
return (
<CampaignPageShell aria-label="Início">
<CampaignHomeActions
role={user.role}
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
/>
</CampaignPageShell>
)
}

View file

@ -17,6 +17,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
import { useCampaignLongPress } from '@/lib/campaignLongPress'
import { cn } from '@/lib/utils'
export type CampaignHomeActionButtonProps = {
label: string
@ -69,6 +70,7 @@ export const CampaignHomeActionButton = ({
)
const useLongPress = Boolean(description) && isCoarsePointer && !disabled
const isInert = !disabled && !href && !onClick
const longPress = useCampaignLongPress({
enabled: useLongPress,
onLongPress: openDescription,
@ -76,7 +78,7 @@ export const CampaignHomeActionButton = ({
})
const pointerHandlers = useLongPress ? longPress : undefined
const className = actionControlClassName
const className = cn(actionControlClassName, isInert && 'cursor-default')
const aria = {
'aria-label': label,
'aria-describedby': drawerOpen && useLongPress ? descriptionId : undefined,

View file

@ -16,7 +16,7 @@ export const CampaignHomeActionStrip = ({
className,
}: {
children?: ReactNode
actions?: readonly CampaignHomeActionButtonProps[]
actions?: readonly (CampaignHomeActionButtonProps & { id?: string })[]
ariaLabel?: string
className?: string
}) => (
@ -30,9 +30,9 @@ export const CampaignHomeActionStrip = ({
>
<ul role="list" className="m-0 flex min-w-max list-none snap-x snap-proximity gap-6 p-0 pb-1">
{children}
{actions?.map((action) => (
<li key={action.href ?? action.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...action} />
{actions?.map(({ id, ...button }) => (
<li key={id ?? button.href ?? button.label} className="m-0 list-none p-0">
<CampaignHomeActionButton {...button} />
</li>
))}
</ul>

View file

@ -0,0 +1,28 @@
'use client'
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import type { CampaignRole } from '@/lib/campaignRoles'
const homeActionsHeadingId = 'campaign-home-actions-heading'
export const CampaignHomeActions = ({
role,
uncoveredMunicipalitiesHref,
}: {
role: CampaignRole
uncoveredMunicipalitiesHref?: string
}) => {
const actions = toHomeActionButtonProps(homeActionsForRole(role), uncoveredMunicipalitiesHref)
if (actions.length === 0) return null
return (
<section aria-labelledby={homeActionsHeadingId} className="w-full">
<h2 id={homeActionsHeadingId} className="text-base font-semibold text-foreground">
O que você quer fazer?
</h2>
<CampaignHomeActionStrip actions={actions} className="mt-4 w-full" />
</section>
)
}

View file

@ -13,6 +13,7 @@ import {
type LucideIcon,
} from 'lucide-react'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
@ -59,7 +60,7 @@ const staffSecondaryNav: CampaignNavItem[] = [
const leaderNav: CampaignNavItem[] = [
{ title: 'Início', href: '/campanha', icon: HomeIcon },
{ title: 'Meus contatos', href: '/campanha/contatos', icon: UsersIcon },
{ title: 'Meus contatos', href: LEADER_CONTACTS_HOME, icon: UsersIcon },
]
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => {

View file

@ -0,0 +1,132 @@
import type { LucideIcon } from 'lucide-react'
import {
BarChart3,
Handshake,
Inbox,
Megaphone,
TrendingUp,
UserPlus,
UserRoundSearch,
Users,
} from 'lucide-react'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
type CampaignHomeActionId =
| 'update-votes'
| 'register-signal'
| 'change-trend'
| 'update-leadership'
| 'register-demand'
| 'uncovered-municipalities'
| 'register-supporter'
| 'my-contacts'
export type CampaignHomeAction = {
id: CampaignHomeActionId
label: string
icon: LucideIcon
description: string
}
const staffHomeActionsCoordinator: readonly CampaignHomeAction[] = [
{
id: 'update-votes',
label: 'Atualizar votos de um município',
icon: BarChart3,
description: 'Atualizar a projeção (média, pessimista, otimista) de um município',
},
{
id: 'register-signal',
label: 'Registrar o que aconteceu',
icon: Megaphone,
description: 'Anotar sinal urgente: invasão, perda de apoio, novo apoio, dificuldade',
},
{
id: 'change-trend',
label: 'Mudar tendência',
icon: TrendingUp,
description: 'Favorável, neutra ou desfavorável — com o porquê',
},
{
id: 'update-leadership',
label: 'Atualizar liderança',
icon: Handshake,
description: 'Trocar quem coordena, status de apoio ou votos que a pessoa declara',
},
{
id: 'register-demand',
label: 'Registrar pedido',
icon: Inbox,
description: 'Demanda de material, transporte, diária ou outro pedido do município',
},
{
id: 'uncovered-municipalities',
label: 'Ver quem ainda não está coberto',
icon: UserRoundSearch,
description: 'Abrir a lista de municípios sem assessor (atalho — não é wizard)',
},
]
const portfolioScopeSuffix = ' nos municípios da sua carteira'
const staffHomeActionsAdvisor: readonly CampaignHomeAction[] = staffHomeActionsCoordinator.map(
(action) => {
if (action.id === 'uncovered-municipalities') {
return {
...action,
description:
'Abrir a lista de municípios sem assessor da sua carteira (atalho — não é wizard)',
}
}
return {
...action,
description: `${action.description}${portfolioScopeSuffix}`,
}
},
)
const leaderHomeActions: readonly CampaignHomeAction[] = [
{
id: 'register-supporter',
label: 'Cadastrar apoiador',
icon: UserPlus,
description: 'Registrar alguém da sua rede que apoia o Solla',
},
{
id: 'my-contacts',
label: 'Ver meus contatos',
icon: Users,
description: 'Abrir a lista dos apoiadores que você cadastrou',
},
]
export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => {
if (role === 'leader') return leaderHomeActions
if (!isStaffCampaignRole(role)) return []
if (role === 'advisor') return staffHomeActionsAdvisor
return staffHomeActionsCoordinator
}
export type ResolvedCampaignHomeAction = CampaignHomeAction & {
href?: string
}
export const toHomeActionButtonProps = (
actions: readonly CampaignHomeAction[],
uncoveredMunicipalitiesHref?: string,
): ResolvedCampaignHomeAction[] =>
actions.map((action) => {
let href: string | undefined
if (action.id === 'my-contacts') {
href = LEADER_CONTACTS_HOME
} else if (action.id === 'uncovered-municipalities') {
href = uncoveredMunicipalitiesHref
}
return {
...action,
...(href ? { href } : {}),
}
})

2
src/lib/campaignPaths.ts Normal file
View file

@ -0,0 +1,2 @@
/** Client-safe `/campanha` route paths shared by nav, gates, and home actions. */
export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const

View file

@ -2,6 +2,7 @@ import 'server-only'
import { redirect } from 'next/navigation'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { getCampaignUser } from '@/utilities/campaignAuth'
@ -10,7 +11,7 @@ export type CampaignPageActor = NonNullable<Awaited<ReturnType<typeof getCampaig
export type CampaignPageGate = 'staff' | 'unrestricted' | 'noLeader'
export const LEADER_CONTACTS_HOME = '/campanha/contatos'
export { LEADER_CONTACTS_HOME }
/** Staff dashboard (B43 — moved off blank `/campanha`). */
export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'

View file

@ -0,0 +1,60 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
const staffActionLabels = [
'Atualizar votos de um município',
'Registrar o que aconteceu',
'Mudar tendência',
'Atualizar liderança',
'Registrar pedido',
'Ver quem ainda não está coberto',
] as const
test.describe('Início — catálogo de ações (B45)', () => {
test('staff sees six home actions and can open municipalities without coverage', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await expect(
page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }),
).toBeVisible()
for (const label of staffActionLabels) {
const link = page.getByRole('link', { name: label, exact: true })
const button = page.getByRole('button', { name: label, exact: true })
await expect(link.or(button)).toBeVisible()
}
await page.getByRole('link', { name: 'Ver quem ainda não está coberto', exact: true }).click()
await page.waitForURL(/\/campanha\/municipios/)
const url = new URL(page.url())
expect(url.searchParams.get('coverage')).toBe('sem_assessor')
expect(url.searchParams.get('sort')).toBe('votos')
})
test('leader sees two home actions and can open contacts', async ({ campaign, page }) => {
const { fixtures } = campaign
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança'),
})
await campaign.login(page, leader.email!, leader.password)
await expect(
page.getByRole('heading', { name: 'O que você quer fazer?', exact: true }),
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Cadastrar apoiador', exact: true }),
).toBeVisible()
await expect(page.getByRole('link', { name: 'Ver meus contatos', exact: true })).toBeVisible()
await page.getByRole('link', { name: 'Ver meus contatos', exact: true }).click()
await page.waitForURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: /Meus contatos/ })).toBeVisible()
})
})

View file

@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { homeActionsForRole, toHomeActionButtonProps } from '@/lib/campaignHomeActions'
import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
const staffActionIds = [
'update-votes',
'register-signal',
'change-trend',
'update-leadership',
'register-demand',
'uncovered-municipalities',
] as const
const leaderActionIds = ['register-supporter', 'my-contacts'] as const
describe('homeActionsForRole', () => {
it.each(['coordinator', 'candidate'] as const)('returns six staff actions for %s', (role) => {
const ids = homeActionsForRole(role).map((action) => action.id)
expect(ids).toEqual([...staffActionIds])
})
it('returns six staff actions for advisor with portfolio-scoped descriptions', () => {
const actions = homeActionsForRole('advisor')
expect(actions.map((action) => action.id)).toEqual([...staffActionIds])
expect(
actions.some((action) => action.description.includes('nos municípios da sua carteira')),
).toBe(true)
const uncovered = actions.find((action) => action.id === 'uncovered-municipalities')
expect(uncovered?.description).toContain('sua carteira')
expect(uncovered?.description).not.toContain('nos municípios da sua carteira nos municípios')
})
it('returns two leader actions without municipality staff ids', () => {
const actions = homeActionsForRole('leader')
expect(actions.map((action) => action.id)).toEqual([...leaderActionIds])
for (const staffId of staffActionIds) {
expect(actions.some((action) => action.id === staffId)).toBe(false)
}
})
})
describe('toHomeActionButtonProps', () => {
it('wires my-contacts to the leader contacts route', () => {
const [supporter, contacts] = toHomeActionButtonProps(homeActionsForRole('leader'))
expect(supporter.href).toBeUndefined()
expect(contacts.href).toBe(LEADER_CONTACTS_HOME)
})
it('wires uncovered-municipalities when href is provided', () => {
const href = '/campanha/municipios?coverage=sem_assessor&sort=votos'
const props = toHomeActionButtonProps(homeActionsForRole('coordinator'), href)
const uncovered = props.find((action) => action.id === 'uncovered-municipalities')
expect(uncovered?.href).toBe(href)
const inert = props.find((action) => action.id === 'update-votes')
expect(inert?.href).toBeUndefined()
})
it('omits uncovered href when not provided', () => {
const props = toHomeActionButtonProps(homeActionsForRole('coordinator'))
expect(props.find((action) => action.id === 'uncovered-municipalities')?.href).toBeUndefined()
})
})