diff --git a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts index e26c8365..bc937aa8 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts @@ -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 => 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx index e56629e1..7a1db262 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -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 -}) => ( - -) - 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 ( - - - } - /> + + + + } + /> - - } - /> - - row.contactID} - empty={} - /> - {listData.rows.length ? ( - buildContactListHref(state, page)} + + } /> - ) : null} - - + + row.contactID} + empty={} + /> + {listData.rows.length ? ( + buildContactListHref(state, page)} + /> + ) : null} + + + + ) diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts index 8934c618..e6834451 100644 --- a/src/app/(campaign)/campanha/actions/contact.ts +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -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 => { + 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') +} diff --git a/src/components/campaign/contacts/ContactCreateFab.tsx b/src/components/campaign/contacts/ContactCreateFab.tsx new file mode 100644 index 00000000..5c0bd97f --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateFab.tsx @@ -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 = () => ( +