teqo-backup/src/components/campaign/shared/PhonesFieldEditor.tsx
Francisco Solla 6de8fad8f6 feat(C112): múltiplos telefones por pessoa — Contact.phones array (ordem = prioridade)
- Contact.phone (text único) → phones (array field Payload, subcampo value):
  fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
  rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
  antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
  gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
  (ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
  append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
  tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
  bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
  modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
  apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
  as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
  atualizados, e2e wizard/apoiador ajustados
2026-08-11 10:47:02 -03:00

285 lines
9.2 KiB
TypeScript

'use client'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FormattedInput } from '@/components/FormattedInput'
import { Button } from '@/components/ui/button'
import { FieldError, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/Spinner'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
type PhoneRow = { key: string; value: string }
let rowKey = 0
const nextRowKey = (): string => `phone-${++rowKey}`
const EMPTY_ROW_KEY = 'phone-empty'
const displayValue = (value: string): string => formatBrazilianPhoneInput(value)
/**
* The repeatable phone list editor (C112): one masked input per number, order
* = priority (first = primary), with remove and ↑/↓ reorder — no labels, no
* drag (product cut). In a create/edit form it renders plain inputs named
* `phones` for the form action to collect; with `saveAction` it becomes the
* ficha's inline editor (Salvar/Cancelar + refresh on success).
*/
export const PhonesFieldEditor = ({
name = 'phones',
defaultValues = [],
minRows = 0,
label = 'Celulares',
error,
saveAction,
recordId,
recordIdField,
saveLabel = 'Salvar',
cancelLabel = 'Cancelar',
className,
}: {
name?: string
defaultValues?: string[]
/** Rows below this count cannot be removed (create flows that require a phone). */
minRows?: number
label?: string
error?: string | string[] | undefined
/** Inline mode: submits the full list through the per-surface contact update action. */
saveAction?: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
recordId?: number
recordIdField?: string
saveLabel?: string
cancelLabel?: string
className?: string
}) => {
const router = useRouter()
const [rows, setRows] = useState<PhoneRow[]>(() => {
const initial = defaultValues.map((value) => ({ key: nextRowKey(), value }))
while (initial.length < minRows) initial.push({ key: nextRowKey(), value: '' })
return initial
})
const [isPending, setIsPending] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(!saveAction)
const rowsRef = useRef(rows)
rowsRef.current = rows
const appliedDefaultsRef = useRef<string[]>(defaultValues)
useEffect(() => {
if (!saveAction) return
// Apply the server values only when they actually changed (a post-save
// refresh or an unrelated refresh of the page must not drop what the
// user is typing).
if (defaultValues.join('|') === appliedDefaultsRef.current.join('|')) return
appliedDefaultsRef.current = defaultValues
setRows((current) => {
const existing = new Map(current.map((row) => [row.value, row]))
const next = defaultValues.map((value) => existing.get(value) ?? { key: nextRowKey(), value })
return next.length > 0 || current.length === 0 ? next : current
})
}, [defaultValues, saveAction])
const updateRow = (key: string, value: string): void => {
setRows((current) => current.map((row) => (row.key === key ? { ...row, value } : row)))
setSaveError(null)
}
const addRow = (): void => {
setRows((current) => [...current, { key: nextRowKey(), value: '' }])
setSaveError(null)
}
const removeRow = (key: string): void => {
setRows((current) =>
current.length > minRows ? current.filter((row) => row.key !== key) : current,
)
setSaveError(null)
}
const moveRow = (index: number, direction: -1 | 1): void => {
setRows((current) => {
const target = index + direction
if (target < 0 || target >= current.length) return current
const next = [...current]
;[next[index], next[target]] = [next[target]!, next[index]!]
return next
})
setSaveError(null)
}
const errorId = 'phones-field-error'
const hasError = saveError !== null || (Array.isArray(error) ? error.length > 0 : Boolean(error))
const save = async (): Promise<void> => {
if (!saveAction || recordId === undefined) return
const formData = new FormData()
if (recordIdField) formData.set(recordIdField, String(recordId))
formData.set('field', 'phones')
for (const row of rowsRef.current) {
formData.append(name, row.value)
}
setIsPending(true)
setSaveError(null)
try {
const result = await saveAction({}, formData)
setIsPending(false)
if (result.status === 'success') {
setIsEditing(false)
router.refresh()
return
}
const message =
result.fieldErrors?.phones?.[0] ??
result.fieldErrors?.[name]?.[0] ??
result.fieldErrors?.form?.[0] ??
result.message
const refusal = message ?? 'Não foi possível salvar.'
setSaveError(refusal)
toast.error(refusal)
} catch {
setIsPending(false)
const message = 'Não foi possível salvar. Tente novamente.'
setSaveError(message)
toast.error(message)
}
}
const rowInputs = rows.map((row, index) => (
<div key={row.key} className="flex items-center gap-1.5">
<FormattedInput
name={name}
inputMode="tel"
autoComplete="tel"
aria-label={`Celular ${index + 1}`}
aria-invalid={hasError}
aria-describedby={hasError ? `${errorId}` : undefined}
value={displayValue(row.value)}
format={formatBrazilianPhoneInput}
sanitize={sanitizeBrazilianPhoneInput}
className={campaignInlineInputClassName}
onChange={(event) => updateRow(row.key, event.currentTarget.value)}
/>
{rows.length > 1 ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para cima (torna principal antes dos demais)"
disabled={index === 0}
onClick={() => moveRow(index, -1)}
>
<ArrowUpIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label="Mover para baixo"
disabled={index === rows.length - 1}
onClick={() => moveRow(index, 1)}
>
<ArrowDownIcon className="size-4" aria-hidden="true" />
</Button>
</>
) : null}
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 shrink-0"
aria-label={`Remover celular ${index + 1}`}
disabled={rows.length <= minRows}
onClick={() => removeRow(row.key)}
>
<Trash2Icon className="size-4" aria-hidden="true" />
</Button>
</div>
))
const addButton = (
<Button
type="button"
variant="ghost"
className="h-9 w-fit shrink-0 justify-start px-2 text-sm text-muted-foreground"
aria-label="Adicionar telefone"
onClick={addRow}
>
<PlusIcon className="size-4" aria-hidden="true" />
Adicionar telefone
</Button>
)
if (saveAction && !isEditing) {
return (
<div className={className}>
<ul className="flex flex-col gap-1.5" aria-label={label}>
{(rows.length ? rows : [{ key: EMPTY_ROW_KEY, value: '' }]).map((row, index) => (
<li key={row.key} className="flex items-center gap-1.5 text-sm tabular-nums">
<span className="w-6 text-right text-muted-foreground">{index + 1}.</span>
<span className="min-w-40">
{row.value ? formatBrazilianPhoneInput(row.value) : '—'}
</span>
</li>
))}
</ul>
<Button
type="button"
variant="ghost"
size="icon"
className="mt-1 size-11"
aria-label={`Editar ${label.toLowerCase()}`}
onClick={() => {
setIsEditing(true)
setSaveError(null)
}}
>
<span className="sr-only">Editar</span>
<PlusIcon className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
return (
<div className={className}>
<FieldLabel>{label}</FieldLabel>
<div className="flex flex-col gap-2">
{rowInputs}
{addButton}
</div>
{hasError ? (
<FieldError id={errorId}>
{saveError ?? (Array.isArray(error) ? error[0] : error)}
</FieldError>
) : null}
{saveAction ? (
<div className="mt-2 flex items-center gap-2">
<Button type="button" disabled={isPending} onClick={() => void save()}>
{saveLabel}
</Button>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => {
setIsEditing(false)
setSaveError(null)
}}
>
{cancelLabel}
</Button>
{isPending ? <Spinner className="size-4" aria-label="Salvando telefones" /> : null}
</div>
) : null}
</div>
)
}