Merge branch 'cursor/eb00cad1'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 15:33:34 -03:00
commit a04f4dc191
8 changed files with 159 additions and 73 deletions

View file

@ -24,7 +24,7 @@ alwaysApply: false
- **B26 entregue em código (2026-07-25):** registrar sinal a partir da célula "Último sinal" de `/campanha/municipios` — Popover (desktop) / Drawer bottom sheet (mobile) cria `municipalityUpdate` `kind: 'sinal'` (C12) sem sair da fila; submit explícito "Registrar sinal" (exceção do princípio 4); `revalidateMunicipalityListPaths`; sem migration. Plano: [`registrar-sinal-lista-municipios.md`](../../../docs/plans/registrar-sinal-lista-municipios.md).
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
- **B21 entregue (2026-07-25):** nova rota staff `/campanha/territorios` com os 27 Territórios de Identidade no sistema de listas — sort/filtros/busca canônicos na URL, sub-linhas do Metropolitano achatadas sem perder a hierarquia, tabela mobile com primeira coluna sticky e drill para `/campanha/municipios?region=<TI>`. O chrome de header rico foi extraído para `shared/CampaignSortableHead` + `CampaignHeaderFilterPopover`, com os wrappers de municípios migrados sem mudança de contrato; B29/B33 agora só consomem essa peça. O painel de TI saiu do Início por decisão desta sessão. Sem migration/collection/action. Plano: [`pagina-territorios-identidade.md`](../../../docs/plans/pagina-territorios-identidade.md).
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **B28 entregue em código (2026-07-26):** e-mail + celular na lista `/campanha/liderancas` — colunas **E-mail** e **Celular** (copy-on-click, toast, "—" vazio) mais uma coluna de **Ações** com botão WhatsApp (`wa.me`, desabilitado com `aria-label` honesto sem celular válido), ordem final Nome → E-mail → Celular → Status → Municípios → Organizações → Acesso ao app → Ações, no mesmo padrão visual/interativo de `/campanha/assessores` (**B19 ✓**). `whatsAppHrefForPhone` promovido de `AdvisorsTable.tsx` para `src/lib/phone.ts` (pura, client-safe); novo `shared/CampaignCopyableCell.tsx` (+ `campaignReadCellClassName` exportado) nasce **já no 2º call site** — `AdvisorsTable.tsx` migrado no mesmo PR para consumi-lo, eliminando o `copyText`/`READ_CELL_CLASS` duplicados. `leadershipData.ts`: `contactNameAndPhone` virou `contactSummary` (+ `email`), `LeadershipRowViewModel.email` populado sem query nova (`depth: 1` já trazia o `Contact` inteiro), e `loadLeadershipDetail` parou de reparsear `doc.contact` — reusa `row.email` de `toLeadershipRows`. Sem migration/collection/Consent/URL novo; access inalterado. Gate verde: tsc/lint/format/check:cycles limpos, 393 unit+int, build produção ok; `pnpm exec knip` mantém o erro pré-existente ao carregar `payload.config.ts` (P3, não relacionado); `pnpm test:e2e` reproduziu só o flake pré-existente já ledgerado em `campaignMunicipalities.e2e.spec.ts` (checkbox de consentimento do `LeaderContactsPanel` antes da hidratação) — nenhuma rota tocada por este item falhou. Aikido 0 achados nos 5 arquivos novos/editados. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).
- **Fill-in registrado (2026-07-25):** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + `?kind=` do contrato URL; schema/catálogo `kind` intacto (mapa, B8, dossiê). Appetite ~0,25–0,5d; Janela 1; cortável; Impeccable B. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
- **Pass 2 — Consolidação de engenharia entregue (2026-07-25, W0–W5 + D3):** tudo que foi one-shotted desde o Pass 1 virou infraestrutura compartilhada e arquitetura documentada. **W1 sistema de listas:** colunas como dado em `shared/CampaignTable.tsx` (seams `mandatory`/`defaultVisible` deixam **B17 trivial**; serialização canônica congelada deixa **B18 trivial**), o god module `municipalityUi.ts` (765 linhas/59 exports) dividido em `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`, 8 superfícies migradas com URLs byte-idênticas (pins do W0 verdes) e **B16+ absorvido** (`useOptimistic` no popover, facet por slugs, hrefs via serializador canônico com pin de byte-igualdade, facets no mesmo `Promise.all`); fora por decisão D5: planos (cards), `TerritoryOverviewTable`, comparação, LeaderContacts, preview de import. **W2 fronteiras:** `slug`/`phone`/`wordStartFilter`/`voteEstimate` → `lib/`, chaves de consent em `lib/campaignConsentKeys.ts`, 21 loaders `server-only`, `lib/campaignRoles.ts` client-safe (sidebar sem o barrel), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em **15 subpastas de domínio**; decisão D1: **sem `src/domains/`**, sem ports-and-adapters (`utilities/` adiado com gatilho: 3º módulo novo de um domínio num mês). **W4a:** `electionInsights.ts` (941 linhas, 2 exports vivos) **deletado** — sobreviventes em `electionFormat.ts`/`voteTrend.ts`; **E10 nasce em módulo novo**. **W4b:** knip `exports`/`types`/`enumMembers` em **ERROR** no CI, 0 findings (kit ui sem peças mortas; `duplicates` off — únicos hits eram os 6 aliases deliberados `canUpdateX = canReadX`). **W4c:** tabs de detalhe extraídas + factory `detailTabUi`. **W4d:** `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções comentadas). **W4e:** tipos de fonte única (cenários, trend, strategy VM nomeado). **W4f:** copy Praça→Município varrida (~70 strings; apoiadores desambiguado: cidade do contato = "Cidade"). **D3:** `submitWhatsapp` resolve `Consent.key='whatsapp-inscricao'` fail-closed; migration `20260725_170000` tagueia o doc vivo — **revisar o SQL antes de push/deploy**. Artefatos novos: `docs/ARCHITECTURE.md` + `.cursor/rules/codebase-map.mdc` (atualizados no mesmo PR que mover coisas). Suites: 46 unit / 48 int / 7 e2e; gates verdes por entrega. Tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2.

View file

@ -1,12 +1,14 @@
# E-mail e celular na lista de lideranças
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B28**)
Impeccable: B — encaixe em `/campanha/liderancas` (colunas + células no `CampaignTable` existente); sem rota nova
Appetite: ~0,5–0,75 dia eng; expor `email` no view model da lista + 2 colunas + células copiáveis + ícone WhatsApp (padrão B19 ✓); sem migration
Responsável: —
**Nota de revisão (2026-07-26):** entregue como especificado — `CampaignCopyableCell` nasceu compartilhado (`shared/`) já no 2º call site (`AdvisorsTable.tsx` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `LeadershipRowViewModel.email` populado sem query nova, e `loadLeadershipDetail` deduplicado (reusa `row.email` de `toLeadershipRows` em vez de reparsear `doc.contact`). Ordem de colunas final: Nome → E-mail → Celular → Status → Municípios → Organizações → Acesso ao app → Ações (WhatsApp), exatamente como travado abaixo. Gate completo (tsc/lint/format/knip/cycles/test/build) verde; `pnpm exec knip` mantém o erro pré-existente ao carregar `payload.config.ts` (P3, não relacionado); `pnpm test:e2e` reproduziu apenas o flake pré-existente já documentado em `campaignMunicipalities.e2e.spec.ts` (checkbox de consentimento do `LeaderContactsPanel` antes da hidratação) — nenhuma falha nas rotas tocadas por este item.
## Design (Impeccable)
Âncoras: `PRODUCT.md` (princípios 2 clareza sob pressão, 8 Feel the action; anti spreadsheet) / `DESIGN.md` (register `product`; Field Desk) · tema `data-theme='campaign'` · precedente de interação [`AdvisorsTable.tsx`](../../src/components/campaign/advisor/AdvisorsTable.tsx) (B19 ✓) · shells `CampaignTable` / `CampaignPageShell`.
@ -120,6 +122,8 @@ Depth check: reusa `CampaignTable`, `phone` helpers, padrão visual do B19; extr
- **Editar e-mail/celular da liderança (detalhe e/ou lista com debounce).** Revisitar quando: (1) produto pedir correção de contato sem `/admin`, **ou** (2) E4R/name-only seed gerar volume de fichas sem telefone que o time precise completar em massa na lista. Entrega mínima futura: action `updateLeadershipContact` + campos na ficha interna; lista in-place só se o 2º gesto se repetir.
- **Incluir e-mail/telefone na busca `q`.** Revisitar quando: ≥1 feedback de uso pedindo achar liderança pelo número.
- **Achado do `/simplify` (2026-07-26): botão de ação WhatsApp por linha duplicado verbatim entre `liderancas/page.tsx` e `AdvisorsTable.tsx`** (mesma estrutura `Button`/`<a>`/disabled — só o `whatsAppHrefForPhone` foi promovido a `lib/phone.ts`, não o JSX do botão). Só 2 call sites hoje — extrair um `CampaignWhatsAppIconButton` agora seria abstração prematura (padrão do repo: 3+ call sites). Revisitar quando um 3º botão de WhatsApp por linha aparecer (candidato natural: `/campanha/apoiadores`).
- **Achado do `/simplify` (2026-07-26): `copyText` (`CampaignCopyableCell.tsx`) duplica a forma de `copyMessage` em `SupporterShareKit.tsx`** (clipboard write + toast sucesso/erro; formatos de UI diferentes — célula vs. botão com label toggle). Pré-existente, não introduzido por este item. Revisitar quando um 3º site de copy-to-clipboard-com-toast aparecer — aí sim vale um `copyToClipboard(value, { successMessage, errorMessage })` em `src/lib/`.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,11 @@
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import { MessageCircleIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
@ -20,6 +21,7 @@ import {
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import {
@ -47,6 +49,24 @@ const leadershipColumns: Array<CampaignTableColumn<LeadershipRowViewModel>> = [
</Link>
),
},
{
id: 'email',
head: <CampaignTableHead>E-mail</CampaignTableHead>,
cellClassName: 'max-w-56',
cell: (row) => <CampaignCopyableCell value={row.email} label="E-mail" />,
},
{
id: 'phone',
head: <CampaignTableHead>Celular</CampaignTableHead>,
cell: (row) => (
<CampaignCopyableCell
value={row.phone}
label="Celular"
formatDisplay={formatBrazilianPhoneInput}
className="tabular-nums"
/>
),
},
{
id: 'supportStatus',
head: <CampaignTableHead>Status</CampaignTableHead>,
@ -73,6 +93,41 @@ const leadershipColumns: Array<CampaignTableColumn<LeadershipRowViewModel>> = [
</Badge>
),
},
{
id: 'actions',
head: (
<CampaignTableHead align="right">
<span className="sr-only">Ações</span>
</CampaignTableHead>
),
cellClassName: 'text-right',
cell: (row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
return whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)
},
},
]
export default async function LeadershipsPage({ searchParams }: LeadershipsPageProps) {

View file

@ -17,6 +17,10 @@ import { toast } from 'sonner'
import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell'
import { AdvisorMunicipalityCell } from '@/components/campaign/advisor/AdvisorMunicipalityCell'
import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton'
import {
CampaignCopyableCell,
campaignReadCellClassName,
} from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -31,10 +35,9 @@ import {
} from '@/components/ui/Table'
import type { AdvisorMunicipalityIndexEntry } from '@/lib/advisorMunicipalityPortfolio'
import {
buildWhatsAppUrl,
formatBrazilianPhoneInput,
normalizeBrazilianPhone,
sanitizeBrazilianPhoneInput,
whatsAppHrefForPhone,
} from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { cn } from '@/lib/utils'
@ -83,30 +86,8 @@ const emptyDraft = (): DraftAdvisor => ({
municipalities: [],
})
const whatsAppHrefForPhone = (phone: string | null): string | null => {
if (!phone || !normalizeBrazilianPhone(phone)) return null
try {
return buildWhatsAppUrl(phone)
} catch {
return null
}
}
const copyText = async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copiado.`)
} catch {
toast.error(`Não foi possível copiar o ${label.toLowerCase()}.`)
}
}
/** Mirrors the inline input box (height, border, padding) so toggling edition never reflows. */
const READ_CELL_CLASS =
'flex min-h-10 w-full items-center rounded-full border border-transparent px-2 text-left'
const displayEmail = (email: string | null): string => {
if (!email || isPlanilhaPlaceholderEmail(email)) return ''
const displayEmail = (email: string | null): string | null => {
if (!email || isPlanilhaPlaceholderEmail(email)) return null
return email
}
@ -321,7 +302,6 @@ export const AdvisorsTable = ({
{rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const emailShown = displayEmail(row.email)
const phoneShown = row.phone ? formatBrazilianPhoneInput(row.phone) : ''
return (
<TableRow key={row.id}>
@ -338,7 +318,7 @@ export const AdvisorsTable = ({
<Link
href={`/campanha/assessores/${row.id}`}
className={cn(
READ_CELL_CLASS,
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
@ -358,18 +338,8 @@ export const AdvisorsTable = ({
placeholderEmailFallback={row.email}
formAction={updateProfileAction}
/>
) : emailShown ? (
<button
type="button"
className={cn(READ_CELL_CLASS, 'underline-offset-4 hover:underline')}
onClick={() => void copyText('E-mail', emailShown)}
>
<span className="truncate">{emailShown}</span>
</button>
) : (
<span className={cn(READ_CELL_CLASS, 'text-sm text-muted-foreground')}>
—
</span>
<CampaignCopyableCell value={emailShown} label="E-mail" />
)}
</TableCell>
<TableCell>
@ -383,21 +353,13 @@ export const AdvisorsTable = ({
ariaLabel={`Celular de ${row.name}`}
formAction={updateProfileAction}
/>
) : phoneShown ? (
<button
type="button"
className={cn(
READ_CELL_CLASS,
'tabular-nums underline-offset-4 hover:underline',
)}
onClick={() => void copyText('Celular', row.phone ?? phoneShown)}
>
<span className="truncate">{phoneShown}</span>
</button>
) : (
<span className={cn(READ_CELL_CLASS, 'text-sm text-muted-foreground')}>
—
</span>
<CampaignCopyableCell
value={row.phone}
label="Celular"
formatDisplay={formatBrazilianPhoneInput}
className="tabular-nums"
/>
)}
</TableCell>
<TableCell>

View file

@ -0,0 +1,64 @@
'use client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
/** Mirrors the inline input box (height, border, padding) so a future editable variant never reflows. */
export const campaignReadCellClassName =
'flex min-h-10 w-full items-center rounded-full border border-transparent px-2 text-left'
const copyText = async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copiado.`)
} catch {
toast.error(`Não foi possível copiar o ${label.toLowerCase()}.`)
}
}
type CampaignCopyableCellProps = {
/** Raw value copied to the clipboard. `null`/empty renders `emptyDisplay` instead of a button. */
value: string | null
/** Used in the toast ("`label` copiado.") and the button's `aria-label`. */
label: string
/** Transforms `value` for display only — the clipboard still gets the raw value (e.g. phone masking). */
formatDisplay?: (value: string) => string
emptyDisplay?: string
className?: string
}
/**
* Click-to-copy read cell for the campaign list system (`CampaignTable`).
* First promoted on B28 (2nd call site after `/campanha/assessores`'s inline
* copy — B19) so the copy/toast/empty-state behavior can't drift between the
* two tables.
*/
export const CampaignCopyableCell = ({
value,
label,
formatDisplay,
emptyDisplay = '—',
className,
}: CampaignCopyableCellProps) => {
if (!value) {
return (
<span className={cn(campaignReadCellClassName, 'text-sm text-muted-foreground', className)}>
{emptyDisplay}
</span>
)
}
const displayValue = formatDisplay ? formatDisplay(value) : value
return (
<button
type="button"
className={cn(campaignReadCellClassName, 'underline-offset-4 hover:underline', className)}
aria-label={`Copiar ${label.toLowerCase()}`}
onClick={() => void copyText(label, value)}
>
<span className="truncate">{displayValue}</span>
</button>
)
}

View file

@ -53,3 +53,9 @@ export const buildWhatsAppUrl = (phone: string, message?: string): string => {
return url.toString()
}
/** `wa.me` href for a row's phone, or `null` when it doesn't normalize — the shared guard behind every per-row WhatsApp button in the campaign lists. */
export const whatsAppHrefForPhone = (phone: string | null): string | null => {
if (!phone || !normalizeBrazilianPhone(phone)) return null
return buildWhatsAppUrl(phone)
}

View file

@ -23,6 +23,7 @@ export type LeadershipRowViewModel = {
contactID: number
name: string
phone: string | null
email: string | null
supportStatus: SupportStatus | null
sector: string | null
municipalityNames: string[]
@ -33,17 +34,18 @@ export type LeadershipRowViewModel = {
updatedAt: string
}
const contactNameAndPhone = (
const contactSummary = (
contact: Leadership['contact'],
): { id: number; name: string; phone: string | null } => {
): { id: number; name: string; phone: string | null; email: string | null } => {
if (typeof contact === 'object' && contact !== null) {
return {
id: contact.id,
name: contact.name ?? 'Contato',
phone: (contact as { phone?: string | null }).phone ?? null,
phone: contact.phone ?? null,
email: contact.email ?? null,
}
}
return { id: Number(contact), name: 'Contato', phone: null }
return { id: Number(contact), name: 'Contato', phone: null, email: null }
}
const namesForIds = async (
@ -95,12 +97,13 @@ const toLeadershipRows = async (
])
return docs.map((doc) => {
const contact = contactNameAndPhone(doc.contact)
const contact = contactSummary(doc.contact)
return {
id: doc.id,
contactID: contact.id,
name: contact.name,
phone: contact.phone,
email: contact.email,
supportStatus: isSupportStatus(doc.supportStatus) ? doc.supportStatus : null,
sector: doc.sector ?? null,
municipalityNames: (doc.municipalities ?? [])
@ -237,7 +240,6 @@ export type LeadershipDetailViewModel = LeadershipRowViewModel & {
organizationIDs: number[]
stateDeputyIDs: number[]
stateDeputies: StateDeputySummary[]
email: string | null
sectorNotes: string | null
notes: string | null
consentNote: string | null
@ -263,12 +265,6 @@ export const loadLeadershipDetail = async (
const [row] = await toLeadershipRows(payload, [doc])
if (!row) return null
const contact = doc.contact
const email =
typeof contact === 'object' && contact !== null
? ((contact as { email?: string | null }).email ?? null)
: null
const stateDeputyIDs = (doc.stateDeputies ?? [])
.map(relationshipId)
.filter((id): id is number => id !== null)
@ -284,7 +280,6 @@ export const loadLeadershipDetail = async (
.filter((id): id is number => id !== null),
stateDeputyIDs,
stateDeputies,
email,
sectorNotes: doc.sectorNotes ?? null,
notes: doc.notes ?? null,
consentNote: doc.consentNote ?? null,