mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
621364f41f
commit
86595ca123
10 changed files with 672 additions and 102 deletions
|
|
@ -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.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
32
src/components/campaign/contacts/ContactCreateFab.tsx
Normal file
32
src/components/campaign/contacts/ContactCreateFab.tsx
Normal 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."
|
||||
/>
|
||||
)
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
221
src/components/campaign/contacts/ContactFormFields.tsx
Normal file
221
src/components/campaign/contacts/ContactFormFields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
src/components/campaign/contacts/ContactFormSheet.tsx
Normal file
107
src/components/campaign/contacts/ContactFormSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
src/components/campaign/contacts/ContactMobileCard.tsx
Normal file
113
src/components/campaign/contacts/ContactMobileCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/components/campaign/contacts/ContactMobileList.tsx
Normal file
24
src/components/campaign/contacts/ContactMobileList.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue