mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Merge branch 'cursor/eb00cad1'
This commit is contained in:
commit
a04f4dc191
8 changed files with 159 additions and 73 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
64
src/components/campaign/shared/CampaignCopyableCell.tsx
Normal file
64
src/components/campaign/shared/CampaignCopyableCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue