diff --git a/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md
new file mode 100644
index 00000000..70b7185b
--- /dev/null
+++ b/docs/plans/editar-contato-liderancas-lista-detalhe-impl.md
@@ -0,0 +1,77 @@
+# Impl: Editar nome, e-mail e celular da liderança (lista + detalhe)
+
+Status: aprovado
+Atualizado em: 2026-08-03
+Issue: #349
+Intenção: docs/plans/editar-contato-liderancas-lista-detalhe.md
+Appetite restante: herdado (~1 dia eng)
+
+## Leitura da intenção
+
+- **Outcome:** Staff edita nome, e-mail e celular da liderança in-place (lápis → input focado → blur grava) na lista e no bloco Contato do detalhe, preservando link do nome e copy-on-click de e-mail/celular no modo leitura.
+- **O que NÃO negociar:** só staff no escopo existente; leader lockdown; sem toggle “Editar” da tabela; sem inputs sempre montados; falha fechada em conflito de telefone (sem merge); Contact como join único.
+- **O que reavaliar:** hipótese de JSON route (B32) vs form action (B19 assessores) — form action com discriminador `field` é mais simples para texto debounced/blur e reusa `AdvisorDebouncedTextCell` mechanics sem Popover.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ UI[LeadershipContactFieldControl] --> FA[updateLeadershipContactFormAction]
+ FA --> REC[updateLeadershipContactRecord]
+ REC --> TX[withPayloadTransaction]
+ TX --> C[payload.update contact]
+```
+
+**Opções consideradas:**
+
+- A — JSON route + `useCampaignCellAutosave` (espelho B32 support-status)
+- B — Form action per-field + componente pencil/blur (espelho assessores, UX B153)
+- C — Estender `updateLeadershipWizard` com PATCH parcial
+
+**Recomendação:** B — `updateLeadershipContactFormAction` + `LeadershipContactFieldControl` compartilhado lista/detalhe. Debounce/blur de `AdvisorDebouncedTextCell`; read mode com `CampaignCopyableCell` (e-mail/celular) e `Link` (nome). Um campo em edição por instância do controle.
+
+**Rejeitadas:** A (JSON route é melhor para selects; texto com debounce já tem precedente em form action); C (wizard exige todos os campos); toggle Editar da tabela (anti-goal produto); Popover overlay (anti-goal produto).
+
+### Componentes / mudanças
+
+- **`leadershipContactUpdateSchema`** (`src/lib/schemas/leadership.ts`): discriminated union `field` ∈ name|email|phone; phone nullable (completar seed sem celular).
+- **`updateLeadershipContactRecord`** (`actions/leadership.ts`): extrai lógica de lock/unicidade de `updateLeadershipWizardRecord`; atualiza só o campo pedido no Contact.
+- **`updateLeadershipContactFormAction`** (`liderancas/formActions.ts`): discriminador `field` como assessores.
+- **`LeadershipContactFieldControl`** (`components/campaign/leadership/`): read (link/copy) + pencil `min-h-11` + input inline + pending/erro.
+- **`LeadershipContactSection`** (`components/campaign/leadership/`): bloco Contato no detalhe (três controles).
+- **Lista** (`liderancas/page.tsx`): substituir células name/email/phone pelos controles.
+- **Detalhe** (`liderancas/[id]/page.tsx`): substituir prosa por `LeadershipContactSection`.
+- **Migration:** sem migration.
+- **Access:** staff via `getFreshStaffActor` + `assertMunicipalitiesWithinScope` (mesmo gate do wizard).
+- **UI:** Impeccable B — shape alinhado a `campaignReadCellClassName`; craft com pencil separado do valor; critique mobile hit area.
+
+### Dados → forma
+
+- Forma: célula valor+lápis / bloco Contato com labels — não KPI; affordance de escrita sobre Contact existente.
+
+## Fases verificáveis
+
+1. **Schema+server** — schema, record helper, form action, int tests (update, phone conflict, advisor scope).
+2. **UI** — `LeadershipContactFieldControl`, lista, detalhe.
+3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Merge de Contact duplicado; busca por e-mail/telefone; edição leader; inputs sempre visíveis.
+
+## Riscos e mitigação
+
+- **Conflito telefone:** locks + `assertContactPhoneAvailable`; mensagens em safeMessages.
+- **Refresh durante digitação:** guard `document.activeElement === inputRef` (precedente AdvisorDebouncedTextCell).
+- **Revalidação lista:** `router.refresh()` no cliente; `revalidatePath` só no detalhe (chip writes não revalidam lista).
+
+## Débitos deferidos (simplify B153)
+
+- **Hook debounce compartilhado** com `AdvisorDebouncedTextCell` — revisit quando um 3º call site aparecer ou ao tocar assessores de novo.
+- **`nullableBrazilianMobileInput` em primitives** — duplicação com advisor schema; extrair no próximo touch de phone schemas.
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto
+- [x] Invariantes AGENTS/engineering-standards
+- [x] Testes de domínio previstos (int) onde write paths mudam
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
index e37091db..9d9b741f 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
@@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons'
+import { LeadershipContactSection } from '@/components/campaign/leadership/LeadershipContactSection'
import { LeadershipInternalForm } from '@/components/campaign/leadership/LeadershipInternalForm'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
@@ -80,10 +81,12 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
{leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'}
-
- {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
- {leadership.email ? ` · ${leadership.email}` : ''}
-
+
{leadership.stateDeputies.length > 0 ? (
) : null}
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
index bb34357c..80ed2e0d 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
+++ b/src/app/(campaign)/campanha/(app)/liderancas/formActions.ts
@@ -4,13 +4,17 @@ import { leadershipStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)
import {
setLeadershipMunicipalitiesMembership,
setLeadershipStateDeputyMembership,
+ updateLeadershipContact,
} from '@/app/(campaign)/campanha/actions/leadership'
import {
+ nullableFormText,
repeatedRelationshipFormValues,
requiredFormBoolean,
+ requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import {
+ LEADERSHIP_INVALID_CONTACT_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
@@ -20,15 +24,63 @@ import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
+import {
+ CONTACT_PHONE_AMBIGUOUS_MESSAGE,
+ CONTACT_PHONE_CONFLICT_MESSAGE,
+} from '@/utilities/contactPhoneInvariant'
+
+const INVALID_FIELD_MESSAGE = 'Campo inválido.'
const safeMessages = [
...leadershipStaffEditSafeMessages,
+ LEADERSHIP_INVALID_CONTACT_MESSAGE,
LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_FLOOR_MESSAGE,
LEADERSHIP_MUNICIPALITY_CAP_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
+ CONTACT_PHONE_AMBIGUOUS_MESSAGE,
+ CONTACT_PHONE_CONFLICT_MESSAGE,
+ INVALID_FIELD_MESSAGE,
] as const
+/** Per-field Contact edit for B153 (lista + detalhe). */
+export const updateLeadershipContactFormAction = async (
+ _state: CampaignFormActionState,
+ formData: FormData,
+): Promise =>
+ runCampaignFormAction({
+ execute: async () => {
+ const id = requiredRelationshipFormValue(formData, 'leadershipId')
+ const field = requiredFormText(formData, 'field')
+
+ if (field === 'name') {
+ await updateLeadershipContact({
+ id,
+ field: 'name',
+ name: requiredFormText(formData, 'name'),
+ })
+ } else if (field === 'email') {
+ await updateLeadershipContact({
+ id,
+ field: 'email',
+ email: nullableFormText(formData, 'email') ?? undefined,
+ })
+ } else if (field === 'phone') {
+ await updateLeadershipContact({
+ id,
+ field: 'phone',
+ phone: nullableFormText(formData, 'phone'),
+ })
+ } else {
+ throw new Error(INVALID_FIELD_MESSAGE)
+ }
+
+ return { message: 'Salvo.' }
+ },
+ safeMessages,
+ genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
+ })
+
/** One chip toggle in the "Dobradinhas" column of `/campanha/liderancas` (B31). */
export const setLeadershipStateDeputyMembershipFormAction = async (
_state: CampaignFormActionState,
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
index 507332ce..4bd5950a 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
+import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl'
import { LeadershipFilters } from '@/components/campaign/leadership/LeadershipFilters'
import { LeadershipListSupportStatusControl } from '@/components/campaign/leadership/LeadershipListSupportStatusControl'
import {
@@ -12,7 +13,6 @@ import {
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 {
CampaignListPendingBoundary,
@@ -48,7 +48,7 @@ import {
resolvedPortfolioEntriesById,
type MunicipalityPortfolioIndexEntry,
} from '@/lib/municipalityPortfolio'
-import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
+import { whatsAppHrefForPhone } from '@/lib/phone'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
@@ -144,30 +144,22 @@ const leadershipColumns = ({
mandatory: true,
head: ,
cell: (row) => (
-
- {row.name}
-
+
),
},
{
id: 'email',
label: 'E-mail',
cellClassName: 'max-w-56',
- cell: (row) => ,
+ cell: (row) => (
+
+ ),
},
{
id: 'phone',
label: 'Celular',
cell: (row) => (
-
+
),
},
{
diff --git a/src/app/(campaign)/campanha/actions/leadership.ts b/src/app/(campaign)/campanha/actions/leadership.ts
index 081cc521..40b8712f 100644
--- a/src/app/(campaign)/campanha/actions/leadership.ts
+++ b/src/app/(campaign)/campanha/actions/leadership.ts
@@ -11,12 +11,14 @@ import {
LEADERSHIP_INVALID_CONTACT_MESSAGE,
LEADERSHIP_MUNICIPALITY_SCOPE_MESSAGE,
LEADERSHIP_STAFF_MESSAGE,
+ leadershipContactUpdateSchema,
leadershipCreateSchema,
leadershipInternalUpdateSchema,
leadershipMunicipalitiesMembershipSchema,
leadershipStateDeputyMembershipSchema,
leadershipWizardCreateSchema,
leadershipWizardUpdateSchema,
+ type LeadershipContactUpdateInput,
type LeadershipInternalUpdateInput,
type LeadershipMunicipalitiesMembershipInput,
type LeadershipStateDeputyMembershipInput,
@@ -44,6 +46,40 @@ const getFreshStaffActor = (
req?: PayloadTransactionRequest,
): Promise => reloadStaffActor(payload, actor, LEADERSHIP_STAFF_MESSAGE, req)
+const assertLeadershipContactPhoneWritable = async (
+ payload: Payload,
+ req: PayloadTransactionRequest,
+ contactID: number,
+ phone: string,
+) => {
+ if (payload.db.name !== 'postgres') {
+ throw new Error(POSTGRES_DEDUP_LOCK_MESSAGE)
+ }
+
+ await acquireContactPhoneLocks(payload, req, [phone])
+
+ // Intentional admin bypass: staff scope was checked on the leadership; phone
+ // uniqueness must see every contact with the number, not only visible rows.
+ const contactsWithPhone = await payload.find({
+ collection: 'contact',
+ where: { phone: { equals: phone } },
+ depth: 0,
+ limit: 2,
+ pagination: false,
+ overrideAccess: true,
+ req,
+ })
+
+ if (contactsWithPhone.totalDocs > 1) {
+ throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
+ }
+
+ const phoneOwner = contactsWithPhone.docs[0]
+ if (phoneOwner && phoneOwner.id !== contactID) {
+ await assertContactPhoneAvailable(payload, req, phone, contactID)
+ }
+}
+
/** Advisors may only link leaderships to municipalities they administer. */
const assertMunicipalitiesWithinScope = async (
payload: Payload,
@@ -237,6 +273,73 @@ export const updateLeadershipInternal = async (input: LeadershipInternalUpdateIn
return updateLeadershipInternalRecord(payload, actor, input)
}
+export const updateLeadershipContactRecord = async (
+ payload: Payload,
+ actor: CampaignUser,
+ input: LeadershipContactUpdateInput,
+) => {
+ const data = leadershipContactUpdateSchema.parse(input)
+
+ return withPayloadTransaction(
+ payload,
+ async ({ req }) => {
+ const currentActor = await getFreshStaffActor(payload, actor, req)
+
+ const current = await payload.findByID({
+ collection: 'leadership',
+ id: data.id,
+ depth: 1,
+ user: currentActor,
+ overrideAccess: false,
+ req,
+ })
+
+ const municipalityIDs = uniqueRelationshipIds(current.municipalities)
+ await assertMunicipalitiesWithinScope(payload, currentActor, municipalityIDs, req)
+
+ const contactID = relationshipId(current.contact)
+ if (contactID === null) {
+ throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
+ }
+
+ const contactData: Partial> = {}
+
+ if (data.field === 'name') {
+ contactData.name = data.name
+ } else if (data.field === 'email') {
+ contactData.email = data.email ?? null
+ } else if (data.field === 'phone') {
+ if (data.phone) {
+ await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone)
+ contactData.phone = data.phone
+ } else {
+ contactData.phone = null
+ }
+ }
+
+ // bypass: contact write is staff-scoped via leadership access check above.
+ await payload.update({
+ collection: 'contact',
+ id: contactID,
+ data: contactData,
+ depth: 0,
+ overrideAccess: true,
+ req,
+ })
+
+ return current
+ },
+ { beginFailureMessage: 'Não foi possível atualizar o contato da liderança.' },
+ )
+}
+
+export const updateLeadershipContact = async (input: LeadershipContactUpdateInput) => {
+ const { payload, actor } = await getCampaignActionContext()
+ const leadership = await updateLeadershipContactRecord(payload, actor, input)
+ revalidatePath(`/campanha/liderancas/${leadership.id}`, 'page')
+ return leadership
+}
+
const revalidateLeadershipWizardPaths = (leadershipId: number, municipalitySlug: string) => {
revalidatePath(`/campanha/liderancas/${leadershipId}`, 'page')
revalidatePath(`/campanha/municipios/${municipalitySlug}`, 'page')
@@ -276,28 +379,7 @@ const updateLeadershipWizardRecord = async (
throw new Error(LEADERSHIP_INVALID_CONTACT_MESSAGE)
}
- await acquireContactPhoneLocks(payload, req, [data.phone])
-
- // Intentional admin bypass: staff scope was checked on the leadership; contact
- // PII is updated atomically with the leadership fields in this transaction.
- const contactsWithPhone = await payload.find({
- collection: 'contact',
- where: { phone: { equals: data.phone } },
- depth: 0,
- limit: 2,
- pagination: false,
- overrideAccess: true,
- req,
- })
-
- if (contactsWithPhone.totalDocs > 1) {
- throw new Error(CONTACT_PHONE_AMBIGUOUS_MESSAGE)
- }
-
- const phoneOwner = contactsWithPhone.docs[0]
- if (phoneOwner && phoneOwner.id !== contactID) {
- await assertContactPhoneAvailable(payload, req, data.phone, contactID)
- }
+ await assertLeadershipContactPhoneWritable(payload, req, contactID, data.phone)
// bypass: contact write is staff-scoped via leadership access check above.
await payload.update({
diff --git a/src/components/campaign/leadership/LeadershipContactFieldControl.tsx b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx
new file mode 100644
index 00000000..aab5660e
--- /dev/null
+++ b/src/components/campaign/leadership/LeadershipContactFieldControl.tsx
@@ -0,0 +1,202 @@
+'use client'
+
+import { PencilIcon } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useEffect, useRef, useState, type ChangeEvent } from 'react'
+import { toast } from 'sonner'
+
+import { updateLeadershipContactFormAction } from '@/app/(campaign)/campanha/(app)/liderancas/formActions'
+import {
+ CampaignCopyableCell,
+ campaignReadCellClassName,
+} from '@/components/campaign/shared/CampaignCopyableCell'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Spinner } from '@/components/ui/Spinner'
+import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
+import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
+import { cn } from '@/lib/utils'
+import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
+
+const SAVE_DEBOUNCE_MS = 500
+
+type LeadershipContactField = 'name' | 'email' | 'phone'
+
+type LeadershipContactFieldControlProps = {
+ leadershipId: number
+ field: LeadershipContactField
+ value: string | null
+ className?: string
+}
+
+const fieldLabels: Record = {
+ name: 'Nome',
+ email: 'E-mail',
+ phone: 'Celular',
+}
+
+const inputTypeForField = (field: LeadershipContactField): 'text' | 'email' | 'tel' => {
+ if (field === 'email') return 'email'
+ if (field === 'phone') return 'tel'
+ return 'text'
+}
+
+export const LeadershipContactFieldControl = ({
+ leadershipId,
+ field,
+ value,
+ className,
+}: LeadershipContactFieldControlProps) => {
+ const label = fieldLabels[field]
+ const phoneDisplayValue =
+ field === 'phone' && value ? formatBrazilianPhoneInput(value) : undefined
+ const router = useRouter()
+ const [isEditing, setIsEditing] = useState(false)
+ const [draft, setDraft] = useState(value ?? '')
+ const [isPending, setIsPending] = useState(false)
+ const lastSaved = useRef((value ?? '').trim())
+ const timerRef = useRef | null>(null)
+ const requestId = useRef(0)
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (inputRef.current && document.activeElement === inputRef.current) return
+ const next = value ?? ''
+ setDraft(next)
+ lastSaved.current = next.trim()
+ }, [value, leadershipId, field])
+
+ useEffect(() => {
+ if (!isEditing) return
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, [isEditing])
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [])
+
+ const save = (nextRaw: string) => {
+ const displayNext = nextRaw.trim()
+ if (displayNext === lastSaved.current) {
+ setIsEditing(false)
+ return
+ }
+
+ const formData = new FormData()
+ formData.set('leadershipId', String(leadershipId))
+ formData.set('field', field)
+ formData.set(field, nextRaw)
+
+ const id = ++requestId.current
+ setIsPending(true)
+ void updateLeadershipContactFormAction({}, formData).then((result: CampaignFormActionState) => {
+ if (id !== requestId.current) return
+ setIsPending(false)
+ if (result.status === 'success') {
+ lastSaved.current = displayNext
+ setIsEditing(false)
+ router.refresh()
+ return
+ }
+ toast.error(result.message ?? 'Não foi possível salvar.')
+ })
+ }
+
+ const scheduleSave = (nextRaw: string) => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null
+ save(nextRaw)
+ }, SAVE_DEBOUNCE_MS)
+ }
+
+ const handleBlur = () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+ save(draft)
+ }
+
+ const handleChange = (event: ChangeEvent) => {
+ const next =
+ field === 'phone'
+ ? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value))
+ : event.currentTarget.value
+ setDraft(next)
+ scheduleSave(next)
+ }
+
+ if (isEditing) {
+ return (
+
+ {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setDraft(value ?? '')
+ setIsEditing(false)
+ }
+ }}
+ {...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
+ />
+ {isPending ? (
+
+ ) : null}
+
+ )
+ }
+
+ return (
+
+
+ {field === 'name' ? (
+ value ? (
+
+ {value}
+
+ ) : (
+
+ —
+
+ )
+ ) : (
+
+ )}
+
+
setIsEditing(true)}
+ >
+
+
+
+ )
+}
diff --git a/src/components/campaign/leadership/LeadershipContactSection.tsx b/src/components/campaign/leadership/LeadershipContactSection.tsx
new file mode 100644
index 00000000..24b9ff08
--- /dev/null
+++ b/src/components/campaign/leadership/LeadershipContactSection.tsx
@@ -0,0 +1,43 @@
+'use client'
+
+import { LeadershipContactFieldControl } from '@/components/campaign/leadership/LeadershipContactFieldControl'
+
+type LeadershipContactSectionProps = {
+ leadershipId: number
+ name: string
+ email: string | null
+ phone: string | null
+}
+
+export const LeadershipContactSection = ({
+ leadershipId,
+ name,
+ email,
+ phone,
+}: LeadershipContactSectionProps) => (
+
+
+
+
+
Nome
+
+
+
+
+
+
E-mail
+
+
+
+
+
+
Celular
+
+
+
+
+
+
+)
diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts
index 07e7218c..f8333226 100644
--- a/src/lib/schemas/leadership.ts
+++ b/src/lib/schemas/leadership.ts
@@ -139,3 +139,30 @@ export const leadershipMunicipalitiesMembershipSchema = z.object({
export type LeadershipMunicipalitiesMembershipInput = z.input<
typeof leadershipMunicipalitiesMembershipSchema
>
+
+const leadershipContactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
+
+const nullableLeadershipContactPhone = leadershipContactPhoneInput.transform((value) =>
+ value === '' || value === undefined ? null : value,
+)
+
+/** Per-field Contact write for B153 inline edit (lista + detalhe). */
+export const leadershipContactUpdateSchema = z.discriminatedUnion('field', [
+ z.object({
+ id: positiveRelationshipId,
+ field: z.literal('name'),
+ name: z.string().trim().min(2).max(120),
+ }),
+ z.object({
+ id: positiveRelationshipId,
+ field: z.literal('email'),
+ email: optionalPersistedEmail,
+ }),
+ z.object({
+ id: positiveRelationshipId,
+ field: z.literal('phone'),
+ phone: nullableLeadershipContactPhone,
+ }),
+])
+
+export type LeadershipContactUpdateInput = z.input
diff --git a/tests/int/campaignLeadership.int.spec.ts b/tests/int/campaignLeadership.int.spec.ts
index 2f3dfb1a..a1609f81 100644
--- a/tests/int/campaignLeadership.int.spec.ts
+++ b/tests/int/campaignLeadership.int.spec.ts
@@ -7,6 +7,7 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
import {
createLeadershipRecord,
listMunicipalityLeaderships,
+ updateLeadershipContactRecord,
updateLeadershipInternalRecord,
} from '@/app/(campaign)/campanha/actions/leadership'
import { Contact } from '@/collections/Contact'
@@ -14,12 +15,13 @@ import { Leadership } from '@/collections/Leadership'
import { contactSchema } from '@/lib/schemas/contact'
import { leadershipCreateSchema, leadershipInternalUpdateSchema } from '@/lib/schemas/leadership'
import config from '@/payload.config'
+import { CONTACT_PHONE_CONFLICT_MESSAGE } from '@/utilities/contactPhoneInvariant'
import {
getTestTransactionBackendPID,
waitForAdvisoryLockWaiter,
} from '../helpers/testDatabaseLease'
-import { installCampaignFixtures } from '../helpers/campaignFixtures'
+import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
@@ -651,4 +653,90 @@ describe('campaign leadership domain', () => {
expect(guard).toBeLessThan(firstSchemaChange)
expect(migration).toContain('Refusing to roll back consolidated campaign schema')
})
+
+ it('updates leadership contact fields in place (B153)', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const municipality = await fixtures.getMunicipality()
+ const phone = fixtures.phone()
+ const created = await createLeadershipRecord(payload, coordinator, {
+ municipalities: [municipality.id],
+ name: 'Nome Original',
+ phone,
+ email: 'original@example.com',
+ supportStatus: 'engajado',
+ })
+
+ await updateLeadershipContactRecord(payload, coordinator, {
+ id: created.id,
+ field: 'name',
+ name: 'Nome Corrigido',
+ })
+ await updateLeadershipContactRecord(payload, coordinator, {
+ id: created.id,
+ field: 'email',
+ email: 'corrigido@example.com',
+ })
+ const newPhone = fixtures.phone()
+ await updateLeadershipContactRecord(payload, coordinator, {
+ id: created.id,
+ field: 'phone',
+ phone: newPhone,
+ })
+
+ const contact = await payload.findByID({
+ collection: 'contact',
+ id: relationId(created.contact)!,
+ depth: 0,
+ overrideAccess: true,
+ })
+ expect(contact.name).toBe('Nome Corrigido')
+ expect(contact.email).toBe('corrigido@example.com')
+ expect(contact.phone).toBe(newPhone)
+ })
+
+ it('rejects leadership contact phone that belongs to another person', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const municipality = await fixtures.getMunicipality()
+ const takenPhone = fixtures.phone()
+ await fixtures.createContact({ phone: takenPhone })
+ const leadership = await createLeadershipRecord(payload, coordinator, {
+ municipalities: [municipality.id],
+ name: 'Liderança Sem Conflito',
+ phone: fixtures.phone(),
+ supportStatus: 'engajado',
+ })
+
+ await expect(
+ updateLeadershipContactRecord(payload, coordinator, {
+ id: leadership.id,
+ field: 'phone',
+ phone: takenPhone,
+ }),
+ ).rejects.toThrow(CONTACT_PHONE_CONFLICT_MESSAGE)
+ })
+
+ it('denies leadership contact edits outside advisor municipality scope', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const advisor = await fixtures.createCampaignUser('advisor')
+ const home = await fixtures.getMunicipality()
+ const away = await fixtures.getMunicipality()
+ await fixtures.assignMunicipalityAdvisors(home, [advisor])
+ const leadership = await createLeadershipRecord(payload, coordinator, {
+ municipalities: [away.id],
+ name: 'Fora da Carteira',
+ phone: fixtures.phone(),
+ supportStatus: 'engajado',
+ })
+
+ await expect(
+ updateLeadershipContactRecord(payload, advisor, {
+ id: leadership.id,
+ field: 'name',
+ name: 'Tentativa',
+ }),
+ ).rejects.toThrow()
+ })
})