feat(C139): ficha mobile de contatos em bottom sheet (Fase 5)

Create/edit sheets (ContactFormSheet + ContactFormFields) e cards mobile
(ContactMobileCard/ContactMobileList), FAB de criar (ContactCreateFab,
z-40 sobre o bottom nav) e a ladder updateContactFullFormAction.

- Form sheets com campos controlados + dispatch manual: React 19 reseta
  campos nao controlados apos qualquer action assentada, apagando o que
  o usuario digitou em erro de validacao/conflito de nome; startTransition
  mantem isPending correto e o state.message vira banner role=alert.
- state de acao da sessao anterior e descartado ao reabrir o sheet
  (sessionId), evitando banner/conflito fantasma.
- CreateRow usa o mesmo dispatch manual (mesma classe de bug).
- updateContactFull: escrita atomica da ficha em transacao com gates de
  escopo e invariante de nome (decisao F do plano).
This commit is contained in:
Francisco Solla 2026-08-13 15:06:20 -03:00
parent 621364f41f
commit 86595ca123
10 changed files with 672 additions and 102 deletions

View file

@ -1,6 +1,10 @@
'use server'
import { createContact, updateContactField } from '@/app/(campaign)/campanha/actions/contact'
import {
createContact,
updateContactField,
updateContactFull,
} from '@/app/(campaign)/campanha/actions/contact'
import {
nullableFormText,
repeatedPhoneFormValues,
@ -89,11 +93,17 @@ export const createContactFormAction = async (
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
const phone = nullableFormText(formData, 'phone')
// The desktop row sends one `phone`; the mobile sheet sends the
// repeatable `phones` list (PhonesFieldEditor) — the row wins when both
// are present, but the two forms never send both.
const singlePhone = nullableFormText(formData, 'phone')
const phonesList = repeatedPhoneFormValues(formData, 'phones')
const phones = phonesList.length > 0 ? phonesList : singlePhone ? [singlePhone] : []
const result = await createContact({
name: requiredFormText(formData, 'name'),
email: nullableFormText(formData, 'email') ?? undefined,
phones: phone ? [phone] : [],
phones,
gender: nullableFormText(formData, 'gender') ?? undefined,
state: requiredFormText(formData, 'state'),
city: nullableFormText(formData, 'city') ?? undefined,
postalCode: nullableFormText(formData, 'postalCode') ?? undefined,
@ -103,3 +113,26 @@ export const createContactFormAction = async (
safeMessages: [CONTACT_NAME_CONFLICT_MESSAGE],
genericMessage: 'Não foi possível criar o contato. Verifique os dados e tente novamente.',
})
/** The mobile edit sheet's single atomic ficha write (plan decision F). */
export const updateContactFullFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateContactFull({
id: requiredRelationshipFormValue(formData, 'id'),
name: requiredFormText(formData, 'name'),
email: nullableFormText(formData, 'email') ?? undefined,
phones: repeatedPhoneFormValues(formData, 'phones'),
gender: nullableFormText(formData, 'gender') ?? undefined,
state: requiredFormText(formData, 'state'),
city: nullableFormText(formData, 'city') ?? undefined,
postalCode: nullableFormText(formData, 'postalCode') ?? undefined,
})
return { message: 'Salvo.' }
},
safeMessages: contactCellSafeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})

View file

@ -2,11 +2,12 @@ import config from '@payload-config'
import { MessageCircleIcon, SearchXIcon } from 'lucide-react'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import type { ReactNode } from 'react'
import { ContactCreateFab } from '@/components/campaign/contacts/ContactCreateFab'
import { ContactCreateRow } from '@/components/campaign/contacts/ContactCreateRow'
import { ContactCreateProvider } from '@/components/campaign/contacts/ContactCreateState'
import { ContactFilters } from '@/components/campaign/contacts/ContactFilters'
import { ContactMobileList } from '@/components/campaign/contacts/ContactMobileList'
import { ContactPhonesCell } from '@/components/campaign/contacts/ContactPhonesCell'
import { ContactSelectCell } from '@/components/campaign/contacts/ContactSelectCell'
import { ContactSortableHead } from '@/components/campaign/contacts/ContactSortableHead'
@ -20,6 +21,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
@ -38,7 +40,7 @@ import {
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { CitiesByState } from '@/lib/cities'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import { whatsAppHrefForPhone } from '@/lib/phone'
import { cn } from '@/lib/utils'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
@ -279,62 +281,6 @@ const contactsColumns = ({
},
]
/** Fase 4 interim mobile list — replaced by the cards/sheets in Fase 5. */
const ContactsMobileList = ({
rows,
canDelete,
empty,
}: {
rows: readonly ContactRowViewModel[]
canDelete: boolean
empty: ReactNode
}) => (
<ul data-view="mobile-list" className="flex flex-col divide-y md:hidden">
{rows.length === 0 ? (
<li className="py-4">{empty}</li>
) : (
rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null)
return (
<li key={row.contactID} className="flex items-center justify-between gap-2 py-3">
<div className="min-w-0">
<p className="truncate font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">
{row.phones[0]
? formatBrazilianPhoneInput(row.phones[0])
: row.email
? row.email
: 'Sem contato registrado'}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{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>
) : null}
{canDelete ? (
<DeletePersonButton
personName={row.name}
contactId={row.contactID}
vocabulary="contato"
/>
) : null}
</div>
</li>
)
})
)}
</ul>
)
export default async function ContactsPage({ searchParams }: ContactsPageProps) {
const rawSearchParams = await searchParams
const canonicalUrl = resolveContactListUrl(rawSearchParams)
@ -373,46 +319,49 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)
return (
<CampaignPageShell>
<ContactCreateProvider>
<CampaignListPendingBoundary>
<ContactFilters
state={state}
cityFilterOptions={cityFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListSheetProvider>
<CampaignListPendingBoundary>
<ContactFilters
state={state}
cityFilterOptions={cityFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListResults>
<ContactsMobileList
rows={listData.rows}
canDelete={canDelete}
empty={<ContactsListEmptyState hasFilters={hasFilters} state={state} />}
/>
<ContactCreateRow formAction={createContactFormAction} />
<CampaignTable
className="hidden md:block"
caption="Uma linha por ficha de contato da campanha. Edite direto na célula; o telefone principal é o primeiro da lista."
columns={columns}
columnVisibility={columnVisibility}
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={<ContactsListEmptyState hasFilters={hasFilters} state={state} />}
/>
{listData.rows.length ? (
<CampaignListFooter
totalDocs={listData.totalDocs}
singular="contato"
plural="contatos"
page={state.page}
totalPages={listData.totalPages}
hrefForPage={(page) => buildContactListHref(state, page)}
<CampaignListResults>
<ContactMobileList
rows={listData.rows}
canDelete={canDelete}
empty={<ContactsListEmptyState hasFilters={hasFilters} state={state} />}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
<ContactCreateRow formAction={createContactFormAction} />
<CampaignTable
className="hidden md:block"
caption="Uma linha por ficha de contato da campanha. Edite direto na célula; o telefone principal é o primeiro da lista."
columns={columns}
columnVisibility={columnVisibility}
rows={listData.rows}
rowKey={(row) => row.contactID}
empty={<ContactsListEmptyState hasFilters={hasFilters} state={state} />}
/>
{listData.rows.length ? (
<CampaignListFooter
totalDocs={listData.totalDocs}
singular="contato"
plural="contatos"
page={state.page}
totalPages={listData.totalPages}
hrefForPage={(page) => buildContactListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
<ContactCreateFab />
</CampaignListSheetProvider>
</ContactCreateProvider>
</CampaignPageShell>
)

View file

@ -9,8 +9,10 @@ import {
CONTACT_CREATE_STAFF_MESSAGE,
contactCreateSchema,
contactFieldUpdateSchema,
contactFullUpdateSchema,
type ContactCreateInput,
type ContactFieldUpdateInput,
type ContactFullUpdateInput,
} from '@/lib/schemas/contact'
import type { CampaignUser, Contact } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
@ -172,3 +174,47 @@ export const updateContactField = async (input: unknown) => {
await updateContactFieldRecord(payload, actor, input as ContactFieldUpdateInput)
revalidatePath('/campanha/contatos', 'page')
}
/**
* C139 — the mobile sheet's single atomic ficha write (plan decision F): one
* transaction, same gate ("edita o que vê") and same name invariant as the
* per-field ladder; every column is written from the form (empties clear).
*/
const updateContactFullRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFullUpdateInput,
): Promise<void> => {
const data = contactFullUpdateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req)
await assertContactRowEditable(payload, currentActor, data.id, req)
await assertContactNameAvailable(payload, req, data.name, data.id)
// Intentional bypass: same gate as the per-field write above.
await payload.update({
collection: 'contact',
id: data.id,
data: {
name: data.name,
email: data.email ?? null,
phones: (data.phones ?? []).map((value) => ({ value })),
gender: data.gender ?? null,
state: data.state,
city: data.city ?? null,
postalCode: data.postalCode ?? null,
},
depth: 0,
overrideAccess: true,
req,
})
})
}
export const updateContactFull = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
await updateContactFullRecord(payload, actor, input as ContactFullUpdateInput)
revalidatePath('/campanha/contatos', 'page')
}

View file

@ -0,0 +1,32 @@
'use client'
import { PlusIcon } from 'lucide-react'
import { createContactFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
/**
* C139 — the mobile create FAB (below `md`, same corner as the quick-actions
* FAB, which the staff contacts page deliberately does not mount): opens the
* create sheet — the same ficha form, empty, with the state defaulting to BA.
*/
export const ContactCreateFab = () => (
<ContactFormSheet
title="Novo contato"
description="Preencha a ficha da campanha"
trigger={<PlusIcon className="size-6" aria-hidden="true" />}
triggerLabel="Novo contato"
triggerClassName="fixed right-4 bottom-5 z-40 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg md:hidden"
formAction={createContactFormAction}
defaults={{
name: '',
email: '',
phones: [],
gender: '',
state: 'BA',
city: '',
postalCode: '',
}}
successMessage="Contato criado."
/>
)

View file

@ -1,7 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useRef, useState } from 'react'
import { startTransition, useActionState, useEffect, useRef, useState, type FormEvent } from 'react'
import { toast } from 'sonner'
import { useContactCreate } from '@/components/campaign/contacts/ContactCreateState'
@ -60,13 +60,22 @@ export const ContactCreateRow = ({ formAction }: ContactCreateRowProps) => {
if (!open) return null
// C139 — manual dispatch (no `action={submitAction}`): React 19 resets
// uncontrolled fields after any settled form action, wiping the typed name
// on a validation/conflict error — the row stays open, so the wipe showed.
// `startTransition` keeps `isPending` correct for the imperative dispatch.
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
startTransition(() => submitAction(new FormData(event.currentTarget)))
}
return (
<form
ref={formRef}
action={submitAction}
onSubmit={handleSubmit}
data-view="contact-create-row"
aria-busy={isPending}
className="rounded-xl border border-dashed border-primary/40 bg-primary/5 p-3"
className="hidden rounded-xl border border-dashed border-primary/40 bg-primary/5 p-3 md:block"
>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1.5">

View file

@ -0,0 +1,221 @@
'use client'
import { startTransition, useState, type FormEvent } from 'react'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { CitiesByState } from '@/lib/cities'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { errorProps } from '@/utilities/campaignFormFields'
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
export type ContactFormDefaults = {
name: string
email: string
phones: string[]
gender: string
state: string
city: string
postalCode: string
}
const stateOptions = Object.keys(CitiesByState)
/**
* C139 — the mobile sheet's ficha form (edit and create share it; the create
* sheet passes empty defaults). Full-bleed fields with dividers; phones use
* `PhonesFieldEditor` in FORM mode (no `saveAction`), so the repeated `phones`
* inputs are collected by the enclosing form. Field errors come from the
* ladder's `fieldErrors` (name/email/phones/gender/state/city/postalCode);
* top-level ladder messages (name conflict, out of scope) render as a banner.
*
* The fields are CONTROLLED and the submit goes through a manual `onSubmit`
* dispatch instead of `<form action={submitAction}>`: React 19 resets
* uncontrolled form fields after any settled form action, wiping typed values
* on validation/conflict errors (the sheet stays open, so the wipe is visible).
* `preventDefault` keeps the native submit event away from that plumbing.
*/
export const ContactFormFields = ({
formId,
state,
sessionId,
submitAction,
defaults,
contactId,
}: {
formId: string
state: CampaignFormActionState
/**
* Bumps on every sheet open; the fields drop the state from any PREVIOUS
* session with it (the sheet wrapper stays mounted, so `useActionState`
* state would otherwise leak a stale conflict banner into a fresh open).
*/
sessionId: number
submitAction: (formData: FormData) => void
defaults: ContactFormDefaults
/** Edit mode: the record being updated (submitted as the `id` field). */
contactId?: string
}) => {
const [lastSessionId, setLastSessionId] = useState(sessionId)
if (lastSessionId !== sessionId) setLastSessionId(sessionId)
const visibleState = lastSessionId === sessionId ? state : {}
const [name, setName] = useState(defaults.name)
const [email, setEmail] = useState(defaults.email)
const [gender, setGender] = useState(defaults.gender)
const [stateValue, setStateValue] = useState(defaults.state)
const [city, setCity] = useState(defaults.city)
const [postalCode, setPostalCode] = useState(defaults.postalCode)
const nameProps = errorProps(visibleState.fieldErrors, 'name', formId)
const emailProps = errorProps(visibleState.fieldErrors, 'email', formId)
const cityProps = errorProps(visibleState.fieldErrors, 'city', formId)
const postalCodeProps = errorProps(visibleState.fieldErrors, 'postalCode', formId)
const stateProps = errorProps(visibleState.fieldErrors, 'state', formId)
const genderProps = errorProps(visibleState.fieldErrors, 'gender', formId)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// `startTransition` keeps `isPending` correct for the imperative dispatch
// (React warns otherwise) without re-entering the `<form action>` plumbing.
startTransition(() => submitAction(new FormData(event.currentTarget)))
}
return (
<form id={formId} onSubmit={handleSubmit} className="flex flex-col divide-y">
{contactId !== undefined ? <input type="hidden" name="id" value={contactId} /> : null}
{visibleState.fieldErrors?.id ? (
<FieldError id={`${formId}-id-error`}>{visibleState.fieldErrors.id[0]}</FieldError>
) : null}
{visibleState.message ? (
<p role="alert" className="py-3 text-sm font-medium text-destructive">
{visibleState.message}
</p>
) : null}
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-name`}>Nome *</FieldLabel>
<Input
id={`${formId}-name`}
name="name"
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nome e sobrenome"
aria-invalid={nameProps.invalid}
aria-describedby={nameProps.describedBy}
/>
{nameProps.error ? (
<FieldError id={nameProps.describedBy}>{nameProps.error}</FieldError>
) : null}
</div>
<div className="py-3">
<PhonesFieldEditor
defaultValues={defaults.phones}
error={visibleState.fieldErrors?.phones}
label="Telefones"
/>
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-email`}>E-mail</FieldLabel>
<Input
id={`${formId}-email`}
name="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="nome@email.com"
aria-invalid={emailProps.invalid}
aria-describedby={emailProps.describedBy}
/>
{emailProps.error ? (
<FieldError id={emailProps.describedBy}>{emailProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-gender`}>Gênero</FieldLabel>
<select
id={`${formId}-gender`}
name="gender"
value={gender}
onChange={(event) => setGender(event.target.value)}
aria-invalid={genderProps.invalid}
aria-describedby={genderProps.describedBy}
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
<option value="">—</option>
{(Object.keys(contactGenderLabels) as (keyof typeof contactGenderLabels)[]).map(
(gender) => (
<option key={gender} value={gender}>
{contactGenderLabels[gender]}
</option>
),
)}
</select>
{genderProps.error ? (
<FieldError id={genderProps.describedBy}>{genderProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-city`}>Cidade</FieldLabel>
<Input
id={`${formId}-city`}
name="city"
value={city}
onChange={(event) => setCity(event.target.value)}
placeholder="Cidade"
aria-invalid={cityProps.invalid}
aria-describedby={cityProps.describedBy}
/>
{cityProps.error ? (
<FieldError id={cityProps.describedBy}>{cityProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-state`}>Estado *</FieldLabel>
<select
id={`${formId}-state`}
name="state"
required
value={stateValue}
onChange={(event) => setStateValue(event.target.value)}
aria-invalid={stateProps.invalid}
aria-describedby={stateProps.describedBy}
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
{stateOptions.map((stateKey) => (
<option key={stateKey} value={stateKey}>
{stateKey}
</option>
))}
</select>
{stateProps.error ? (
<FieldError id={stateProps.describedBy}>{stateProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-postal-code`}>CEP</FieldLabel>
<Input
id={`${formId}-postal-code`}
name="postalCode"
inputMode="numeric"
maxLength={8}
value={postalCode}
onChange={(event) => setPostalCode(event.target.value)}
placeholder="00000000"
aria-invalid={postalCodeProps.invalid}
aria-describedby={postalCodeProps.describedBy}
/>
{postalCodeProps.error ? (
<FieldError id={postalCodeProps.describedBy}>{postalCodeProps.error}</FieldError>
) : null}
</div>
</form>
)
}

View file

@ -0,0 +1,107 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useId, useState, type ReactNode } from 'react'
import { toast } from 'sonner'
import {
ContactFormFields,
type ContactFormDefaults,
} from '@/components/campaign/contacts/ContactFormFields'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Button } from '@/components/ui/button'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/**
* C139 — the mobile ficha sheet (edit card / create FAB): the shared
* `CampaignListSheetProvider` drawer via `CampaignCellEditOverlay`, the whole
* ficha as ONE form (plan decision F) and the custom footer submit. Success
* toasts, closes and refreshes; field errors render inline in the body.
*/
export const ContactFormSheet = ({
title,
description,
trigger,
triggerLabel,
triggerClassName,
formAction,
defaults,
successMessage,
deleteControl,
contactId,
}: {
title: string
description?: string
trigger: ReactNode
triggerLabel: string
triggerClassName?: string
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
defaults: ContactFormDefaults
successMessage: string
/** Rendered under the form, outside it (the destructive dialog, edit only). */
deleteControl?: ReactNode
/** Edit mode: the record being updated (submitted as the `id` field). */
contactId?: string
}) => {
const router = useRouter()
const formId = useId()
const [open, setOpen] = useState(false)
const [openCount, setOpenCount] = useState(0)
const [formActionState, submitAction, isPending] = useActionState<
CampaignFormActionState,
FormData
>(formAction, {})
// Bump on every open so the fields can drop a PREVIOUS session's action
// state (the sheet wrapper stays mounted; useActionState does not reset).
useEffect(() => {
if (open) setOpenCount((count) => count + 1)
}, [open])
useEffect(() => {
if (formActionState.status !== 'success') return
toast.success(formActionState.message ?? successMessage)
setOpen(false)
router.refresh()
}, [formActionState, router, successMessage])
const footer = (
<div className="flex w-full flex-col gap-2">
<Button form={formId} type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" /> : null}
Salvar
</Button>
<DrawerCloseButton disabled={isPending}>Cancelar</DrawerCloseButton>
</div>
)
return (
<CampaignCellEditOverlay
variant="sheet"
open={open}
onOpenChange={setOpen}
title={title}
description={description}
trigger={trigger}
triggerLabel={triggerLabel}
triggerClassName={triggerClassName}
footer={footer}
sheetBodyClassName="gap-4"
>
<ContactFormFields
formId={formId}
state={formActionState}
sessionId={openCount}
submitAction={submitAction}
defaults={defaults}
contactId={contactId}
/>
{deleteControl}
</CampaignCellEditOverlay>
)
}

View file

@ -0,0 +1,113 @@
'use client'
import { MailIcon, MessageCircleIcon, PencilIcon } from 'lucide-react'
import { updateContactFullFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
const editTriggerClassName = 'size-10 rounded-full'
/**
* C139 — the mobile ficha card (below `md`): name + secondary line (primary
* phone ?? e-mail), gender chip + "Cidade · Estado", and the actions row
* (WhatsApp / e-mail / edit / delete). Editing opens the shared ficha sheet.
*/
export const ContactMobileCard = ({
row,
canDelete,
}: {
row: ContactRowViewModel
canDelete: boolean
}) => {
const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null)
const secondary =
row.phones.length > 0
? formatBrazilianPhoneInput(row.phones[0]!)
: (row.email ?? 'Sem contato registrado')
const place = [row.city, row.state].filter(Boolean).join(' · ')
return (
<li className="flex flex-col gap-2 py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{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>
) : null}
{row.email ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a href={`mailto:${row.email}`} aria-label={`Enviar e-mail para ${row.name}`}>
<MailIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : null}
{canDelete ? (
<DeletePersonButton
personName={row.name}
contactId={row.contactID}
vocabulary="contato"
/>
) : null}
<ContactFormSheet
title="Editar contato"
description={row.name}
trigger={<PencilIcon className="size-4" aria-hidden="true" />}
triggerLabel={`Editar contato ${row.name}`}
triggerClassName={editTriggerClassName}
formAction={updateContactFullFormAction}
contactId={String(row.contactID)}
defaults={{
name: row.name,
email: row.email ?? '',
phones: row.phones,
gender: row.gender ?? '',
state: row.state ?? 'BA',
city: row.city ?? '',
postalCode: row.postalCode ?? '',
}}
successMessage="Salvo."
deleteControl={
canDelete ? (
<div className="flex justify-center border-t pt-4">
<DeletePersonButton
personName={row.name}
contactId={row.contactID}
vocabulary="contato"
/>
</div>
) : null
}
/>
</div>
</div>
{row.gender || place ? (
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{row.gender ? (
<Badge variant="outline" className="font-normal">
{contactGenderLabels[row.gender]}
</Badge>
) : null}
{place ? <span>{place}</span> : null}
</div>
) : null}
</li>
)
}

View file

@ -0,0 +1,24 @@
'use client'
import type { ReactNode } from 'react'
import { ContactMobileCard } from '@/components/campaign/contacts/ContactMobileCard'
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
/** C139 — the mobile ficha list (below `md`), replacing the interim one-liner. */
export const ContactMobileList = ({
rows,
canDelete,
empty,
}: {
rows: readonly ContactRowViewModel[]
canDelete: boolean
empty: ReactNode
}) => (
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
{rows.length === 0 ? <li className="py-4">{empty}</li> : null}
{rows.map((row) => (
<ContactMobileCard key={row.contactID} row={row} canDelete={canDelete} />
))}
</ul>
)

View file

@ -153,3 +153,39 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>
const contactGenderFieldSchema = z
.union([z.enum(contactGenders), z.literal('')])
.transform((value) => (value === '' ? undefined : value))
.optional()
const contactCityFieldSchema = z
.string()
.trim()
.transform((value) => (value === '' ? undefined : value))
.refine((value) => value === undefined || (value.length >= 3 && value.length <= 100), {
message: 'Cidade inválida',
})
.optional()
/**
* C139 — the mobile edit sheet is a SINGLE atomic write (plan decision F):
* the whole ficha in one schema, with the same gate/scope/name invariant as
* the per-field ladder; an empty email/CEP/city clears the column (null),
* an empty gender clears the enum.
*/
export const contactFullUpdateSchema = z.object({
id: positiveRelationshipId,
name: contactNameSchema,
email: optionalPersistedEmail,
phones: contactPhonesSchema.optional(),
gender: contactGenderFieldSchema,
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: contactCityFieldSchema,
postalCode: contactPostalCodeSchema,
})
export type ContactFullUpdateInput = z.input<typeof contactFullUpdateSchema>