mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #351 from fsolla/cursor/pool-i349-c89de3ae-f9e7
B153 — Editar nome, e-mail e celular da liderança (lista + detalhe)
This commit is contained in:
commit
d9db139719
9 changed files with 608 additions and 42 deletions
77
docs/plans/editar-contato-liderancas-lista-detalhe-impl.md
Normal file
77
docs/plans/editar-contato-liderancas-lista-detalhe-impl.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'}
|
||||
{leadership.email ? ` · ${leadership.email}` : ''}
|
||||
</p>
|
||||
<LeadershipContactSection
|
||||
leadershipId={leadership.id}
|
||||
name={leadership.name}
|
||||
email={leadership.email}
|
||||
phone={leadership.phone}
|
||||
/>
|
||||
{leadership.stateDeputies.length > 0 ? (
|
||||
<StateDeputyChips deputies={leadership.stateDeputies} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -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<CampaignFormActionState> =>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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: <LeadershipSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<Link
|
||||
href={`/campanha/liderancas/${row.id}`}
|
||||
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.name}
|
||||
</Link>
|
||||
<LeadershipContactFieldControl leadershipId={row.id} field="name" value={row.name} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56',
|
||||
cell: (row) => <CampaignCopyableCell value={row.email} label="E-mail" />,
|
||||
cell: (row) => (
|
||||
<LeadershipContactFieldControl leadershipId={row.id} field="email" value={row.email} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
label: 'Celular',
|
||||
cell: (row) => (
|
||||
<CampaignCopyableCell
|
||||
value={row.phone}
|
||||
label="Celular"
|
||||
displayValue={row.phone ? formatBrazilianPhoneInput(row.phone) : undefined}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<LeadershipContactFieldControl leadershipId={row.id} field="phone" value={row.phone} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<CampaignUser> => 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<Pick<Contact, 'name' | 'phone' | 'email'>> = {}
|
||||
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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<LeadershipContactField, string> = {
|
||||
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<ReturnType<typeof setTimeout> | null>(null)
|
||||
const requestId = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
const next =
|
||||
field === 'phone'
|
||||
? formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value))
|
||||
: event.currentTarget.value
|
||||
setDraft(next)
|
||||
scheduleSave(next)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={cn('relative min-w-40', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type={inputTypeForField(field)}
|
||||
value={draft}
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
className={campaignInlineInputClassName}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft(value ?? '')
|
||||
setIsEditing(false)
|
||||
}
|
||||
}}
|
||||
{...(field === 'phone' ? { inputMode: 'numeric' as const } : {})}
|
||||
/>
|
||||
{isPending ? (
|
||||
<Spinner className="pointer-events-none absolute top-1/2 right-2 size-3.5 -translate-y-1/2" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 items-center gap-0.5', className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
{field === 'name' ? (
|
||||
value ? (
|
||||
<Link
|
||||
href={`/campanha/liderancas/${leadershipId}`}
|
||||
className={cn(
|
||||
campaignReadCellClassName,
|
||||
'font-medium text-primary underline-offset-4 hover:underline',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{value}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cn(campaignReadCellClassName, 'text-sm text-muted-foreground')}>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<CampaignCopyableCell
|
||||
value={value}
|
||||
label={label}
|
||||
displayValue={phoneDisplayValue}
|
||||
className={field === 'phone' ? 'tabular-nums' : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
aria-label={`Editar ${label.toLowerCase()}`}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<PencilIcon className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
<section aria-labelledby="leadership-contact-title" className="flex flex-col gap-3">
|
||||
<h2 id="leadership-contact-title" className="text-base font-medium">
|
||||
Contato
|
||||
</h2>
|
||||
<dl className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Nome</dt>
|
||||
<dd>
|
||||
<LeadershipContactFieldControl leadershipId={leadershipId} field="name" value={name} />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">E-mail</dt>
|
||||
<dd>
|
||||
<LeadershipContactFieldControl leadershipId={leadershipId} field="email" value={email} />
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<dt className="text-sm text-muted-foreground">Celular</dt>
|
||||
<dd>
|
||||
<LeadershipContactFieldControl leadershipId={leadershipId} field="phone" value={phone} />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
|
|
@ -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<typeof leadershipContactUpdateSchema>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue