feat(C139): página /campanha/contatos — lista desktop com células editáveis, criação e filtros
This commit is contained in:
parent
e71b062650
commit
621364f41f
19 changed files with 1885 additions and 22 deletions
|
|
@ -166,6 +166,8 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
`${CAMPAIGN_APP}/atualizacoes`,
|
||||
`${CAMPAIGN_APP}/meus-contatos`,
|
||||
`${CAMPAIGN_APP}/contatos`,
|
||||
'src/components/campaign/contacts',
|
||||
'src/utilities/contacts',
|
||||
`${CAMPAIGN_APP}/demandas`,
|
||||
`${CAMPAIGN_APP}/dobradinhas`,
|
||||
`${CAMPAIGN_APP}/organizacoes`,
|
||||
|
|
|
|||
105
src/app/(campaign)/campanha/(app)/contatos/formActions.ts
Normal file
105
src/app/(campaign)/campanha/(app)/contatos/formActions.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
'use server'
|
||||
|
||||
import { createContact, updateContactField } from '@/app/(campaign)/campanha/actions/contact'
|
||||
import {
|
||||
nullableFormText,
|
||||
repeatedPhoneFormValues,
|
||||
requiredFormText,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
|
||||
import {
|
||||
CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
CONTACT_CELL_STAFF_MESSAGE,
|
||||
CONTACT_CREATED_MESSAGE,
|
||||
CONTACT_NAME_CONFLICT_MESSAGE,
|
||||
} from '@/lib/schemas/contact'
|
||||
import {
|
||||
runCampaignFormAction,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const contactCellSafeMessages = [
|
||||
CONTACT_CELL_STAFF_MESSAGE,
|
||||
CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
|
||||
CONTACT_NAME_CONFLICT_MESSAGE,
|
||||
BRAZILIAN_PHONE_DUPLICATE_MESSAGE,
|
||||
] as const
|
||||
|
||||
/** Per-field ficha edit for the contacts page cells (C139, C116 shape). */
|
||||
export const updateContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const id = requiredRelationshipFormValue(formData, 'id')
|
||||
const field = requiredFormText(formData, 'field')
|
||||
|
||||
if (field === 'name') {
|
||||
await updateContactField({ id, field: 'name', name: requiredFormText(formData, 'name') })
|
||||
} else if (field === 'email') {
|
||||
await updateContactField({
|
||||
id,
|
||||
field: 'email',
|
||||
email: nullableFormText(formData, 'email') ?? undefined,
|
||||
})
|
||||
} else if (field === 'phone') {
|
||||
await updateContactField({
|
||||
id,
|
||||
field: 'phone',
|
||||
phone: nullableFormText(formData, 'phone') ?? null,
|
||||
})
|
||||
} else if (field === 'phones') {
|
||||
await updateContactField({
|
||||
id,
|
||||
field: 'phones',
|
||||
phones: repeatedPhoneFormValues(formData, 'phones'),
|
||||
})
|
||||
} else if (field === 'gender') {
|
||||
await updateContactField({
|
||||
id,
|
||||
field: 'gender',
|
||||
gender: requiredFormText(formData, 'gender'),
|
||||
})
|
||||
} else if (field === 'state') {
|
||||
await updateContactField({ id, field: 'state', state: requiredFormText(formData, 'state') })
|
||||
} else if (field === 'city') {
|
||||
await updateContactField({ id, field: 'city', city: requiredFormText(formData, 'city') })
|
||||
} else if (field === 'postalCode') {
|
||||
await updateContactField({
|
||||
id,
|
||||
field: 'postalCode',
|
||||
postalCode: nullableFormText(formData, 'postalCode') ?? undefined,
|
||||
})
|
||||
} else {
|
||||
throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
return { message: 'Salvo.' }
|
||||
},
|
||||
safeMessages: contactCellSafeMessages,
|
||||
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
|
||||
})
|
||||
|
||||
/** The contacts page create ladder (desktop row / mobile sheet — C139). */
|
||||
export const createContactFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
): Promise<CampaignFormActionState> =>
|
||||
runCampaignFormAction({
|
||||
execute: async () => {
|
||||
const phone = nullableFormText(formData, 'phone')
|
||||
const result = await createContact({
|
||||
name: requiredFormText(formData, 'name'),
|
||||
email: nullableFormText(formData, 'email') ?? undefined,
|
||||
phones: phone ? [phone] : [],
|
||||
state: requiredFormText(formData, 'state'),
|
||||
city: nullableFormText(formData, 'city') ?? undefined,
|
||||
postalCode: nullableFormText(formData, 'postalCode') ?? undefined,
|
||||
})
|
||||
return { message: CONTACT_CREATED_MESSAGE, contactID: result.contactID }
|
||||
},
|
||||
safeMessages: [CONTACT_NAME_CONFLICT_MESSAGE],
|
||||
genericMessage: 'Não foi possível criar o contato. Verifique os dados e tente novamente.',
|
||||
})
|
||||
419
src/app/(campaign)/campanha/(app)/contatos/page.tsx
Normal file
419
src/app/(campaign)/campanha/(app)/contatos/page.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
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 { ContactCreateRow } from '@/components/campaign/contacts/ContactCreateRow'
|
||||
import { ContactCreateProvider } from '@/components/campaign/contacts/ContactCreateState'
|
||||
import { ContactFilters } from '@/components/campaign/contacts/ContactFilters'
|
||||
import { ContactPhonesCell } from '@/components/campaign/contacts/ContactPhonesCell'
|
||||
import { ContactSelectCell } from '@/components/campaign/contacts/ContactSelectCell'
|
||||
import { ContactSortableHead } from '@/components/campaign/contacts/ContactSortableHead'
|
||||
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
CampaignListPendingBoundary,
|
||||
CampaignListResults,
|
||||
CampaignTransitionAnchor,
|
||||
} from '@/components/campaign/shared/CampaignListPending'
|
||||
import {
|
||||
CampaignTable,
|
||||
CampaignTableHead,
|
||||
type CampaignTableColumn,
|
||||
} from '@/components/campaign/shared/CampaignTable'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/Empty'
|
||||
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
|
||||
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
|
||||
import { CitiesByState } from '@/lib/cities'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
|
||||
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
import {
|
||||
loadContactListPageData,
|
||||
type ContactRowViewModel,
|
||||
} from '@/utilities/contacts/contactListData'
|
||||
import {
|
||||
buildContactFilterHref,
|
||||
clearContactListFilters,
|
||||
type ContactFilterOption,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import {
|
||||
buildContactListHref,
|
||||
contactGenderLabels,
|
||||
resolveContactListUrl,
|
||||
type ContactGender,
|
||||
type ContactListState,
|
||||
type ContactStateKey,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
import { createContactFormAction, updateContactFormAction } from './formActions'
|
||||
|
||||
export const metadata = campaignPageMetadataFromCatalog('contatos')
|
||||
|
||||
type ContactsPageProps = {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
const genderOptions: ReadonlyArray<{ value: ContactGender; label: string }> = (
|
||||
Object.keys(contactGenderLabels) as ContactGender[]
|
||||
).map((value) => ({ value, label: contactGenderLabels[value] }))
|
||||
|
||||
const stateOptions: ReadonlyArray<{ value: ContactStateKey; label: string }> = (
|
||||
Object.keys(CitiesByState) as ContactStateKey[]
|
||||
).map((value) => ({ value, label: value }))
|
||||
|
||||
const ContactsListEmptyState = ({
|
||||
hasFilters,
|
||||
state,
|
||||
}: {
|
||||
hasFilters: boolean
|
||||
state: ContactListState
|
||||
}) => (
|
||||
<Empty className="min-h-56">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchXIcon aria-hidden="true" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum contato neste recorte</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste a busca e os filtros para ver as fichas da campanha.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{hasFilters ? (
|
||||
<EmptyContent>
|
||||
<CampaignTransitionAnchor
|
||||
href={buildContactFilterHref(clearContactListFilters(state))}
|
||||
replace
|
||||
scroll={false}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
|
||||
>
|
||||
Limpar busca e filtros
|
||||
</CampaignTransitionAnchor>
|
||||
</EmptyContent>
|
||||
) : null}
|
||||
</Empty>
|
||||
)
|
||||
|
||||
const contactsColumns = ({
|
||||
state,
|
||||
canDelete,
|
||||
}: {
|
||||
state: ContactListState
|
||||
canDelete: boolean
|
||||
}): Array<CampaignTableColumn<ContactRowViewModel>> => [
|
||||
{
|
||||
id: 'name',
|
||||
label: 'Nome',
|
||||
mandatory: true,
|
||||
cellClassName: 'max-w-72',
|
||||
head: <ContactSortableHead state={state} sortKey="name" />,
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="id"
|
||||
field="name"
|
||||
value={row.name}
|
||||
label="Nome"
|
||||
formAction={updateContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gender',
|
||||
label: 'Gênero',
|
||||
cellClassName: 'max-w-36',
|
||||
cell: (row) => (
|
||||
<ContactSelectCell
|
||||
recordId={row.contactID}
|
||||
field="gender"
|
||||
value={row.gender}
|
||||
label="Gênero"
|
||||
options={genderOptions}
|
||||
emptyLabel="Sem gênero"
|
||||
formAction={updateContactFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'phone',
|
||||
label: 'Telefone',
|
||||
cellClassName: 'max-w-56',
|
||||
cell: (row) => (
|
||||
<ContactPhonesCell
|
||||
recordId={row.contactID}
|
||||
phones={row.phones}
|
||||
formAction={updateContactFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
label: 'E-mail',
|
||||
cellClassName: 'max-w-56 whitespace-normal',
|
||||
head: <ContactSortableHead state={state} sortKey="email" />,
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="id"
|
||||
field="email"
|
||||
value={row.email}
|
||||
label="E-mail"
|
||||
placeholder="Sem e-mail"
|
||||
formAction={updateContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: 'Cidade',
|
||||
cellClassName: 'max-w-44 whitespace-normal',
|
||||
head: <ContactSortableHead state={state} sortKey="cidade" />,
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="id"
|
||||
field="city"
|
||||
value={row.city}
|
||||
label="Cidade"
|
||||
placeholder="Sem cidade"
|
||||
formAction={updateContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'state',
|
||||
label: 'Estado',
|
||||
cellClassName: 'max-w-24',
|
||||
head: <ContactSortableHead state={state} sortKey="estado" />,
|
||||
cell: (row) => (
|
||||
<ContactSelectCell
|
||||
recordId={row.contactID}
|
||||
field="state"
|
||||
value={row.state}
|
||||
label="Estado"
|
||||
options={stateOptions}
|
||||
formAction={updateContactFormAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'postalCode',
|
||||
label: 'CEP',
|
||||
cellClassName: 'max-w-28',
|
||||
cell: (row) => (
|
||||
<CampaignInlineEditableCell
|
||||
recordId={row.contactID}
|
||||
recordIdField="id"
|
||||
field="postalCode"
|
||||
value={row.postalCode}
|
||||
label="CEP"
|
||||
placeholder="Sem CEP"
|
||||
formAction={updateContactFormAction}
|
||||
permanent
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
head: (
|
||||
<CampaignTableHead align="right">
|
||||
<span className="sr-only">Ações</span>
|
||||
</CampaignTableHead>
|
||||
),
|
||||
cellClassName: 'text-right',
|
||||
cell: (row) => {
|
||||
const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null)
|
||||
return (
|
||||
<div className="inline-flex items-center justify-end gap-1">
|
||||
{whatsAppHref ? (
|
||||
<CampaignHoverTooltip content="WhatsApp">
|
||||
<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>
|
||||
</CampaignHoverTooltip>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<CampaignHoverTooltip content="Apagar">
|
||||
{/* C130 — span wrapper: `DeletePersonButton` is a component and
|
||||
does not forward the tooltip trigger's ref/pointer props, so
|
||||
the hover would never reach the trigger. */}
|
||||
<span>
|
||||
<DeletePersonButton
|
||||
personName={row.name}
|
||||
contactId={row.contactID}
|
||||
vocabulary="contato"
|
||||
/>
|
||||
</span>
|
||||
</CampaignHoverTooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** 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)
|
||||
if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref)
|
||||
|
||||
const [user, payload] = await Promise.all([
|
||||
requireCampaignPageActor({ gate: 'staff' }),
|
||||
getPayload({ config }),
|
||||
])
|
||||
|
||||
const [columnVisibility, listData] = await Promise.all([
|
||||
readCampaignColumnVisibility('contatos'),
|
||||
loadContactListPageData(payload, user, canonicalUrl.state),
|
||||
])
|
||||
|
||||
const resolvedUrl = resolveContactListUrl(rawSearchParams, listData.totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
|
||||
const cityFilterOptions: ContactFilterOption[] = listData.filterFacets.cities.map((city) => ({
|
||||
value: city,
|
||||
label: city,
|
||||
}))
|
||||
|
||||
const canDelete = isCampaignUnrestricted(user)
|
||||
const columns = contactsColumns({ state, canDelete })
|
||||
const hasFilters = Boolean(
|
||||
state.q ||
|
||||
state.genders?.length ||
|
||||
state.states?.length ||
|
||||
state.cities?.length ||
|
||||
state.ausencias?.length ||
|
||||
state.vinculos?.length,
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<ContactCreateProvider>
|
||||
<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)}
|
||||
/>
|
||||
) : null}
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</ContactCreateProvider>
|
||||
</CampaignPageShell>
|
||||
)
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ const assertContactRowEditable = async (
|
|||
if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
|
||||
}
|
||||
|
||||
export const createContactRecord = async (
|
||||
const createContactRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactCreateInput,
|
||||
|
|
@ -96,7 +96,7 @@ export const createContact = async (input: unknown) => {
|
|||
|
||||
const contactFieldData = (
|
||||
data: ContactFieldUpdateInput,
|
||||
): Partial<Pick<Contact, 'name' | 'email' | 'city' | 'postalCode'>> & {
|
||||
): Partial<Pick<Contact, 'name' | 'email' | 'city' | 'postalCode' | 'gender' | 'state'>> & {
|
||||
phones?: { value: string }[]
|
||||
} => {
|
||||
switch (data.field) {
|
||||
|
|
@ -109,6 +109,10 @@ const contactFieldData = (
|
|||
case 'postalCode':
|
||||
// The schema transforms '' → undefined: clearing the CEP stores null.
|
||||
return { postalCode: data.postalCode ?? null }
|
||||
case 'gender':
|
||||
return { gender: data.gender }
|
||||
case 'state':
|
||||
return { state: data.state }
|
||||
case 'phones':
|
||||
return { phones: data.phones.map((value) => ({ value })) }
|
||||
case 'phone':
|
||||
|
|
@ -118,7 +122,7 @@ const contactFieldData = (
|
|||
}
|
||||
}
|
||||
|
||||
export const updateContactFieldRecord = async (
|
||||
const updateContactFieldRecord = async (
|
||||
payload: Payload,
|
||||
actor: CampaignUser,
|
||||
input: ContactFieldUpdateInput,
|
||||
|
|
|
|||
212
src/components/campaign/contacts/ContactCreateRow.tsx
Normal file
212
src/components/campaign/contacts/ContactCreateRow.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActionState, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useContactCreate } from '@/components/campaign/contacts/ContactCreateState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { CitiesByState } from '@/lib/cities'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
|
||||
|
||||
const stateOptions = Object.keys(CitiesByState)
|
||||
|
||||
const rowInputClassName =
|
||||
'border-border/60 bg-background shadow-none focus-visible:ring-1 focus-visible:ring-ring/50'
|
||||
|
||||
type ContactCreateRowProps = {
|
||||
/** The `contatos/formActions` create ladder (`runCampaignFormAction`). */
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
/**
|
||||
* C139 — the create row of the contacts table: an empty ficha at the top of
|
||||
* the list (highlighted background, borderless inputs), mounted by the shared
|
||||
* create store (omnibox button / mobile FAB). Salvar stays disabled until the
|
||||
* name is filled; a conflict (duplicate name) keeps the typed values and
|
||||
* shows the message inside the row; success resets, closes and refreshes.
|
||||
*/
|
||||
export const ContactCreateRow = ({ formAction }: ContactCreateRowProps) => {
|
||||
const router = useRouter()
|
||||
const { open, setOpen } = useContactCreate()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [hasName, setHasName] = useState(false)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [formActionState, submitAction, isPending] = useActionState<
|
||||
CampaignFormActionState,
|
||||
FormData
|
||||
>(formAction, {})
|
||||
const errorMessage =
|
||||
formActionState.status === 'success' ? undefined : firstFormActionMessage(formActionState)
|
||||
|
||||
useEffect(() => {
|
||||
if (formActionState.status === 'success') {
|
||||
toast.success(formActionState.message)
|
||||
formRef.current?.reset()
|
||||
setHasName(false)
|
||||
setPhone('')
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}
|
||||
}, [formActionState, router, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
action={submitAction}
|
||||
data-view="contact-create-row"
|
||||
aria-busy={isPending}
|
||||
className="rounded-xl border border-dashed border-primary/40 bg-primary/5 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex min-w-40 flex-1 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-name"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Nome <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="contact-create-name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="Nome e sobrenome"
|
||||
aria-label="Nome do contato"
|
||||
className={rowInputClassName}
|
||||
onChange={(event) => setHasName(event.currentTarget.value.trim().length >= 2)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-36 flex-1 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-phone"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Telefone
|
||||
</label>
|
||||
<Input
|
||||
id="contact-create-phone"
|
||||
name="phone"
|
||||
inputMode="numeric"
|
||||
placeholder="(71) 9 0000-0000"
|
||||
aria-label="Telefone do contato"
|
||||
className={rowInputClassName}
|
||||
value={phone}
|
||||
onChange={(event) =>
|
||||
setPhone(
|
||||
formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-36 flex-1 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-email"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
E-mail
|
||||
</label>
|
||||
<Input
|
||||
id="contact-create-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="nome@email.com"
|
||||
aria-label="E-mail do contato"
|
||||
className={rowInputClassName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-32 flex-1 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-city"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Cidade
|
||||
</label>
|
||||
<Input
|
||||
id="contact-create-city"
|
||||
name="city"
|
||||
placeholder="Cidade"
|
||||
aria-label="Cidade do contato"
|
||||
className={rowInputClassName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-24 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-state"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
id="contact-create-state"
|
||||
name="state"
|
||||
defaultValue="BA"
|
||||
aria-label="Estado do contato"
|
||||
className={cn(
|
||||
rowInputClassName,
|
||||
'min-h-10 rounded-md border bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||
)}
|
||||
>
|
||||
{stateOptions.map((stateKey) => (
|
||||
<option key={stateKey} value={stateKey}>
|
||||
{stateKey}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex min-w-24 flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="contact-create-postal-code"
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
CEP
|
||||
</label>
|
||||
<Input
|
||||
id="contact-create-postal-code"
|
||||
name="postalCode"
|
||||
inputMode="numeric"
|
||||
maxLength={8}
|
||||
placeholder="00000000"
|
||||
aria-label="CEP do contato"
|
||||
className={rowInputClassName}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button type="submit" disabled={!hasName || isPending} className="min-h-10">
|
||||
{isPending ? <Spinner data-icon="inline-start" /> : null}
|
||||
Salvar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
className="min-h-10"
|
||||
onClick={() => {
|
||||
formRef.current?.reset()
|
||||
setHasName(false)
|
||||
setPhone('')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
Descartar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<p role="alert" className="mt-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
44
src/components/campaign/contacts/ContactCreateState.tsx
Normal file
44
src/components/campaign/contacts/ContactCreateState.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* C139 — the contacts create surface. One tiny client store shared by the
|
||||
* desktop row (inside the table container) and the mobile FAB: opening from
|
||||
* either mounts the same row. The row itself is `ContactCreateRow`.
|
||||
*/
|
||||
type ContactCreateState = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ContactCreateContext = createContext<ContactCreateState | null>(null)
|
||||
|
||||
export const ContactCreateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<ContactCreateContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</ContactCreateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useContactCreate = (): ContactCreateState => {
|
||||
const context = useContext(ContactCreateContext)
|
||||
if (!context) throw new Error('useContactCreate must be used within ContactCreateProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
/** Desktop trailing control of the omnibox (the mobile FAB is Fase 5). */
|
||||
export const ContactCreateButton = () => {
|
||||
const { open, setOpen } = useContactCreate()
|
||||
return (
|
||||
<Button type="button" className="min-h-11" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Novo contato
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
100
src/components/campaign/contacts/ContactFilters.tsx
Normal file
100
src/components/campaign/contacts/ContactFilters.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { ContactCreateButton } from '@/components/campaign/contacts/ContactCreateState'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildContactFilterHref,
|
||||
type ContactFilterOption,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import type { ContactListState } from '@/utilities/contacts/contactListUrl'
|
||||
import {
|
||||
applyContactOmniboxSuggestion,
|
||||
buildContactOmniboxChips,
|
||||
buildContactOmniboxSuggestionSeeds,
|
||||
clearContactOmnibox,
|
||||
filterContactOmniboxSuggestions,
|
||||
removeContactOmniboxChip,
|
||||
type ContactOmniboxAction,
|
||||
} from '@/utilities/contacts/contactOmnibox'
|
||||
|
||||
export const ContactFilters = ({
|
||||
state,
|
||||
cityFilterOptions,
|
||||
trailing,
|
||||
}: {
|
||||
state: ContactListState
|
||||
cityFilterOptions: ContactFilterOption[]
|
||||
trailing?: ReactNode
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: buildContactFilterHref,
|
||||
})
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const chips = useMemo(() => buildContactOmniboxChips(state), [state])
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
() => buildContactOmniboxSuggestionSeeds({ cityFilterOptions }),
|
||||
[cityFilterOptions],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => filterContactOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: ContactOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
navigate(action.state)
|
||||
return
|
||||
}
|
||||
navigate(action.state)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<CampaignListOmnibox
|
||||
id="contact-omnibox"
|
||||
label="Filtrar contatos"
|
||||
placeholder="Buscar contato…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyContactOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onCommitQuery={(text) => {
|
||||
runAction(applyContactOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeContactOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearContactOmnibox(state))
|
||||
}}
|
||||
trailing={
|
||||
<>
|
||||
{trailing}
|
||||
<ContactCreateButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
138
src/components/campaign/contacts/ContactPhonesCell.tsx
Normal file
138
src/components/campaign/contacts/ContactPhonesCell.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type ContactPhonesCellProps = {
|
||||
recordId: number
|
||||
/** Stored order = priority (first is primary) — C112 contract. */
|
||||
phones: readonly string[]
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
const sanitize = (raw: string): string => sanitizeBrazilianPhoneInput(raw)
|
||||
|
||||
/**
|
||||
* C139 — the phone cell of the contacts table: stacked borderless inputs with
|
||||
* the phone mask. Blur/Enter on ANY line commits the WHOLE list (order =
|
||||
* priority), empty lines are discarded before the schema; Escape discards the
|
||||
* draft and "+" appends a line. The commit is the ficha's full `phones` array
|
||||
* (`field: 'phones'`) — a single value is never written alone.
|
||||
*/
|
||||
export const ContactPhonesCell = ({ recordId, phones, formAction }: ContactPhonesCellProps) => {
|
||||
const router = useRouter()
|
||||
const [draft, setDraft] = useState<string[]>(() => phones.map(formatBrazilianPhoneInput))
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const clean = (lines: string[]): string[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const value = sanitize(line)
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const dirty = (lines: string[]): boolean => {
|
||||
const cleaned = clean(lines)
|
||||
if (cleaned.length !== phones.length) return true
|
||||
return cleaned.some((value, index) => value !== sanitize(phones[index] ?? ''))
|
||||
}
|
||||
|
||||
const commit = (lines: string[]) => {
|
||||
if (isPending) return
|
||||
if (!dirty(lines)) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('id', String(recordId))
|
||||
formData.set('field', 'phones')
|
||||
for (const value of clean(lines)) formData.append('phones', value)
|
||||
|
||||
setIsPending(true)
|
||||
void formAction({}, formData)
|
||||
.then((result) => {
|
||||
if (result.status === 'success') {
|
||||
setDraft(clean(lines).map(formatBrazilianPhoneInput))
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.phones?.[0] ?? result.message ?? 'Não foi possível salvar.'
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
toast.error(message)
|
||||
})
|
||||
.catch(() => {
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
toast.error('Não foi possível salvar. Tente novamente.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPending(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{draft.map((line, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
inputMode="numeric"
|
||||
aria-label={`Telefone ${index + 1}${index === 0 ? ' (principal)' : ''}`}
|
||||
aria-busy={isPending}
|
||||
value={line}
|
||||
placeholder="Sem telefone"
|
||||
className={campaignInlineInputClassName}
|
||||
onChange={(event) => {
|
||||
const next = [...draft]
|
||||
next[index] = formatBrazilianPhoneInput(sanitize(event.currentTarget.value))
|
||||
setDraft(next)
|
||||
}}
|
||||
onBlur={() => commit(draft)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
))}
|
||||
{draft.length === 0 ? (
|
||||
<span className="px-1 text-sm text-muted-foreground">Sem telefone</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit min-h-9 gap-1 self-start px-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Adicionar telefone"
|
||||
onClick={() => {
|
||||
setDraft((current) => [...current, ''])
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden />
|
||||
Adicionar telefone
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/campaign/contacts/ContactSelectCell.tsx
Normal file
118
src/components/campaign/contacts/ContactSelectCell.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type ContactSelectField = 'gender' | 'state'
|
||||
|
||||
type ContactSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type ContactSelectCellProps = {
|
||||
recordId: number
|
||||
field: ContactSelectField
|
||||
value: string | null
|
||||
label: string
|
||||
options: readonly ContactSelectOption[]
|
||||
/**
|
||||
* The label of the "empty" option shown when `value` is null (the ficha has
|
||||
* no gender recorded). State is required on the collection, so the empty
|
||||
* option never appears there.
|
||||
*/
|
||||
emptyLabel?: string
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C139 — the gender/state cells of the contacts table: a native select styled
|
||||
* as a borderless text cell. Changing the option commits immediately through
|
||||
* the same per-cell ladder as the text cells (`field: gender|state`); a failed
|
||||
* commit reverts the select and toasts the message (the people-cell error
|
||||
* pattern).
|
||||
*/
|
||||
export const ContactSelectCell = ({
|
||||
recordId,
|
||||
field,
|
||||
value,
|
||||
label,
|
||||
options,
|
||||
emptyLabel = '—',
|
||||
formAction,
|
||||
className,
|
||||
}: ContactSelectCellProps) => {
|
||||
const router = useRouter()
|
||||
const [selected, setSelected] = useState(value ?? '')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const commit = async (next: string) => {
|
||||
if (next === (value ?? '') || next === '') return
|
||||
setIsPending(true)
|
||||
const formData = new FormData()
|
||||
formData.set('id', String(recordId))
|
||||
formData.set('field', field)
|
||||
formData.set(field, next)
|
||||
try {
|
||||
const result = await formAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.[field]?.[0] ?? result.message ?? 'Não foi possível salvar.'
|
||||
setSelected(value ?? '')
|
||||
toast.error(message)
|
||||
} catch {
|
||||
setSelected(value ?? '')
|
||||
toast.error('Não foi possível salvar. Tente novamente.')
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelected(event.currentTarget.value)
|
||||
void commit(event.currentTarget.value)
|
||||
}
|
||||
|
||||
const hasValue = value !== null && value !== ''
|
||||
|
||||
return (
|
||||
<div className={cn('relative inline-flex min-w-0 max-w-full items-center', className)}>
|
||||
<select
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
disabled={isPending}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
'min-h-10 w-full cursor-pointer appearance-none rounded-sm border-transparent bg-transparent pr-6 pl-1 text-sm',
|
||||
'focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||
hasValue ? 'text-foreground' : 'text-muted-foreground',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{!hasValue ? <option value="">{emptyLabel}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1.5 size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/campaign/contacts/ContactSortableHead.tsx
Normal file
49
src/components/campaign/contacts/ContactSortableHead.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
|
||||
import {
|
||||
buildContactSortHref,
|
||||
contactListSortLabels,
|
||||
defaultContactListSortDir,
|
||||
resolveContactListSort,
|
||||
type ContactListSortKey,
|
||||
type ContactListState,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
type ContactSortableHeadProps = {
|
||||
state: ContactListState
|
||||
sortKey: ContactListSortKey
|
||||
children?: ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Sortable header for the contacts table (C139). Filters live in the
|
||||
* omnibox only — no header filter popovers (people precedent, C117). */
|
||||
export const ContactSortableHead = ({
|
||||
state,
|
||||
sortKey,
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: ContactSortableHeadProps) => {
|
||||
const { sort: activeSort, dir } = resolveContactListSort(state)
|
||||
const active = activeSort === sortKey
|
||||
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultContactListSortDir(sortKey)
|
||||
|
||||
return (
|
||||
<CampaignSortableHead
|
||||
active={active}
|
||||
align={align}
|
||||
className={className}
|
||||
dir={dir}
|
||||
href={buildContactSortHref(state, sortKey)}
|
||||
nextDir={nextDir}
|
||||
sortLabel={contactListSortLabels[sortKey]}
|
||||
>
|
||||
{children ?? contactListSortLabels[sortKey]}
|
||||
</CampaignSortableHead>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,6 +37,12 @@ type DeletePersonButtonProps = {
|
|||
* navigate away — the ficha no longer exists.
|
||||
*/
|
||||
deletedHref?: string
|
||||
/**
|
||||
* C139 — the contacts page reuses the C100 dialog with its own vocabulary
|
||||
* ("contato" ficha instead of "pessoa"); the manifest and the cascade are
|
||||
* the same (the delete contract is contactId-based).
|
||||
*/
|
||||
vocabulary?: 'pessoa' | 'contato'
|
||||
}
|
||||
|
||||
type ManifestState =
|
||||
|
|
@ -45,17 +51,23 @@ type ManifestState =
|
|||
| { kind: 'ready'; manifest: PersonDeleteManifest }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
const mapManifestError = (error: unknown): string => {
|
||||
const mapManifestError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) {
|
||||
return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.'
|
||||
}
|
||||
return 'Não foi possível carregar o que será apagado. Tente de novo.'
|
||||
}
|
||||
|
||||
const mapDeleteError = (error: unknown): string => {
|
||||
const mapDeleteError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE) return message
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
return 'Não foi possível apagar a pessoa. Tente de novo.'
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) {
|
||||
return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.'
|
||||
}
|
||||
return vocabulary === 'contato'
|
||||
? 'Não foi possível apagar o contato. Tente de novo.'
|
||||
: 'Não foi possível apagar a pessoa. Tente de novo.'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,6 +81,7 @@ export const DeletePersonButton = ({
|
|||
personName,
|
||||
contactId,
|
||||
deletedHref,
|
||||
vocabulary = 'pessoa',
|
||||
}: DeletePersonButtonProps) => {
|
||||
const router = useRouter()
|
||||
const [manifest, setManifest] = useState<ManifestState>({ kind: 'idle' })
|
||||
|
|
@ -87,11 +100,11 @@ export const DeletePersonButton = ({
|
|||
? { kind: 'ready', manifest: next }
|
||||
: {
|
||||
kind: 'error',
|
||||
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE)),
|
||||
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE), vocabulary),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
setManifest({ kind: 'error', message: mapManifestError(error) })
|
||||
setManifest({ kind: 'error', message: mapManifestError(error, vocabulary) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,12 +114,13 @@ export const DeletePersonButton = ({
|
|||
setDeleteError('')
|
||||
try {
|
||||
const result = await deletePersonAction({ contactId })
|
||||
const deletedCopy = vocabulary === 'contato' ? 'Contato apagado.' : 'Pessoa apagada.'
|
||||
toast.success(
|
||||
result.contactDeleted
|
||||
? 'Pessoa apagada.'
|
||||
? deletedCopy
|
||||
: result.contactAnonymized
|
||||
? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.'
|
||||
: 'Pessoa apagada.',
|
||||
? `${deletedCopy} A ficha foi anonimizada porque ainda é referenciada por participações públicas.`
|
||||
: deletedCopy,
|
||||
)
|
||||
if (deletedHref) {
|
||||
router.push(deletedHref)
|
||||
|
|
@ -114,7 +128,7 @@ export const DeletePersonButton = ({
|
|||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
setDeleteError(mapDeleteError(error))
|
||||
setDeleteError(mapDeleteError(error, vocabulary))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
|
|
@ -129,14 +143,14 @@ export const DeletePersonButton = ({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 text-destructive hover:text-destructive"
|
||||
aria-label={`Apagar pessoa ${personName}`}
|
||||
aria-label={`Apagar ${vocabulary} ${personName}`}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Apagar pessoa</AlertDialogTitle>
|
||||
<AlertDialogTitle>Apagar {vocabulary}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '}
|
||||
<span className="font-medium text-foreground">{personName}</span> com a campanha.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,17 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'ballotName'
|
||||
// C139 — the contacts page adds the ficha's own text cells: `city` and
|
||||
// `postalCode` (same blur-save contract; the placeholder prop lets the cell
|
||||
// spell the empty state, e.g. "Sem cidade").
|
||||
export type CampaignInlineEditableField =
|
||||
| 'name'
|
||||
| 'email'
|
||||
| 'phone'
|
||||
| 'party'
|
||||
| 'ballotName'
|
||||
| 'city'
|
||||
| 'postalCode'
|
||||
|
||||
type CampaignInlineEditableCellProps = {
|
||||
recordId: number
|
||||
|
|
@ -45,6 +55,12 @@ type CampaignInlineEditableCellProps = {
|
|||
* typing edits in the same place (mechanism locked at the C116 gate).
|
||||
*/
|
||||
permanent?: boolean
|
||||
/**
|
||||
* C139 — the empty-state placeholder of the permanent input (default '—').
|
||||
* Cells pass their own copy (e.g. 'Sem cidade') — the ficha's absence is a
|
||||
* state worth spelling, not a dash.
|
||||
*/
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +93,7 @@ export const CampaignInlineEditableCell = ({
|
|||
readBehavior = 'text',
|
||||
saveOnChange = true,
|
||||
permanent = false,
|
||||
placeholder,
|
||||
className,
|
||||
}: CampaignInlineEditableCellProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -269,7 +286,7 @@ export const CampaignInlineEditableCell = ({
|
|||
ref={inputRef}
|
||||
type={inputTypeForField(field)}
|
||||
value={draft}
|
||||
placeholder={isNameLink ? undefined : '—'}
|
||||
placeholder={isNameLink ? undefined : (placeholder ?? '—')}
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
className={inputClassName}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ const CAMPAIGN_LIST_IDS = [
|
|||
// B197 — the advisors surface is a hand-rolled client table (no
|
||||
// `CampaignTable`), but it joined the picker with the same cookie key.
|
||||
'assessores',
|
||||
// C139 — the contacts page (staff) joins the picker with the same key
|
||||
// contract; the ficha fields are the columns.
|
||||
'contatos',
|
||||
] as const
|
||||
|
||||
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
||||
|
|
@ -86,6 +89,9 @@ const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[
|
|||
pessoas: ['email'],
|
||||
apoiadores: ['email'],
|
||||
assessores: ['email'],
|
||||
// C139 — CEP is the least-read ficha field; anyone can re-enable it from
|
||||
// the picker (stored wins over default).
|
||||
contatos: ['postalCode'],
|
||||
}
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
export type StateKey = keyof typeof CitiesByState
|
||||
|
||||
/** Gender enum shared with the leadership collection (same values, C139). */
|
||||
export const contactGenders = personGenders
|
||||
const contactGenders = personGenders
|
||||
|
||||
const contactNameSchema = z
|
||||
.string()
|
||||
|
|
@ -122,6 +122,21 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
|
|||
field: z.literal('phones'),
|
||||
phones: contactPhonesSchema,
|
||||
}),
|
||||
// C139 — the select cells commit the ficha's own enum fields: gender (the
|
||||
// shared person enum) and state (a UF of the cities catalog).
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('gender'),
|
||||
gender: z.enum(contactGenders),
|
||||
}),
|
||||
z.object({
|
||||
id: positiveRelationshipId,
|
||||
field: z.literal('state'),
|
||||
state: z.custom<StateKey>(
|
||||
(value) => typeof value === 'string' && value in CitiesByState,
|
||||
'Estado inválido',
|
||||
),
|
||||
}),
|
||||
// C139 — the contacts page edits the ficha's own fields in place: city and
|
||||
// CEP are free text on the Contact collection itself (no person-join
|
||||
// sibling writes them).
|
||||
|
|
|
|||
|
|
@ -5,5 +5,3 @@
|
|||
* would be a module cycle.
|
||||
*/
|
||||
export const personGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
|
||||
|
||||
export type PersonGender = (typeof personGenders)[number]
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const toContactRowViewModel = (doc: Contact): ContactRowViewModel => ({
|
|||
* the result is intersected with the readable contact ids, so a link never
|
||||
* widens the list.
|
||||
*/
|
||||
export const loadContactVinculos = async (
|
||||
const loadContactVinculos = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
readableContactIDs: ReadonlySet<number>,
|
||||
|
|
|
|||
141
src/utilities/contacts/contactListFilters.ts
Normal file
141
src/utilities/contacts/contactListFilters.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Contacts list filter affordances (C139): Gênero / Estado / Cidade /
|
||||
* Ausência / Vínculo toggles and clear-filters. Multi-select facets are OR
|
||||
* within the facet, like every other list (ausência and vínculo are real ORs
|
||||
* — selecting every member still excludes complete/unlinked fichas).
|
||||
*/
|
||||
import { CitiesByState } from '@/lib/cities'
|
||||
import {
|
||||
buildContactListHref,
|
||||
CONTACT_ABSENCES,
|
||||
CONTACT_VINCULOS,
|
||||
contactAbsenceLabels,
|
||||
contactGenderLabels,
|
||||
contactListStateToRawParams,
|
||||
contactVinculoLabels,
|
||||
parseContactListParams,
|
||||
type ContactGender,
|
||||
type ContactListState,
|
||||
type ContactStateKey,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
export type ContactFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type ContactMultiFilterParam = 'gender' | 'state' | 'city' | 'ausencia' | 'vinculo'
|
||||
|
||||
/** Static seeds: the gender enum of the collection (4 values). */
|
||||
export const contactGenderFilterOptions: ContactFilterOption[] = (
|
||||
Object.keys(contactGenderLabels) as ContactGender[]
|
||||
).map((value) => ({ value, label: contactGenderLabels[value] }))
|
||||
|
||||
/** Static seeds: the 27 UFs of the cities catalog (the select-cell source too). */
|
||||
export const contactStateFilterOptions: ContactFilterOption[] = (
|
||||
Object.keys(CitiesByState) as ContactStateKey[]
|
||||
).map((value) => ({ value, label: value }))
|
||||
|
||||
export const contactAbsenceFilterOptions: ContactFilterOption[] = CONTACT_ABSENCES.map((value) => ({
|
||||
value,
|
||||
label: contactAbsenceLabels[value],
|
||||
}))
|
||||
|
||||
export const contactVinculoFilterOptions: ContactFilterOption[] = CONTACT_VINCULOS.map((value) => ({
|
||||
value,
|
||||
label: contactVinculoLabels[value],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Writes the RAW param and lets `parseContactListParams` validate, so no
|
||||
* branch has to cast a `string[]` into the state's narrow value types.
|
||||
*/
|
||||
const setContactMultiFilterValues = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
values: string[],
|
||||
): ContactListState =>
|
||||
parseContactListParams({
|
||||
...contactListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
[param]: values,
|
||||
})
|
||||
|
||||
const getContactMultiFilterValues = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'gender') return state.genders ?? []
|
||||
if (param === 'state') return state.states ?? []
|
||||
if (param === 'ausencia') return state.ausencias ?? []
|
||||
if (param === 'vinculo') return state.vinculos ?? []
|
||||
return state.cities ?? []
|
||||
}
|
||||
|
||||
const toggleContactMultiFilterValue = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
const current = getContactMultiFilterValues(state, param)
|
||||
return setContactMultiFilterValues(
|
||||
state,
|
||||
param,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
export const toggleContactGenderFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!contactGenderFilterOptions.some((option) => option.value === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'gender', value)
|
||||
}
|
||||
|
||||
export const toggleContactStateFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!(value in CitiesByState)) return state
|
||||
return toggleContactMultiFilterValue(state, 'state', value)
|
||||
}
|
||||
|
||||
export const toggleContactAbsenceFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!CONTACT_ABSENCES.some((absence) => absence === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'ausencia', value)
|
||||
}
|
||||
|
||||
export const toggleContactVinculoFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!CONTACT_VINCULOS.some((vinculo) => vinculo === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'vinculo', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* C139 — city is FREE TEXT on `Contact.city` (maxLength 100), so the toggle
|
||||
* validates structurally (non-empty, ≤ 100 chars) and lets
|
||||
* `parseContactListParams` dedupe — the party facet precedent (C130).
|
||||
*/
|
||||
export const toggleContactCityFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.length > 100) return state
|
||||
return toggleContactMultiFilterValue(state, 'city', normalized)
|
||||
}
|
||||
|
||||
/** Drop every filter and the search; keep the ordering (municipios precedent). */
|
||||
export const clearContactListFilters = (state: ContactListState): ContactListState => ({
|
||||
page: 1,
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const buildContactFilterHref = (next: ContactListState): string =>
|
||||
buildContactListHref(next, 1)
|
||||
277
src/utilities/contacts/contactOmnibox.ts
Normal file
277
src/utilities/contacts/contactOmnibox.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Contacts list omnibox adapter (C139). Pure / client-safe — the sibling of
|
||||
* `peopleOmnibox` for the ficha surface.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
clearContactListFilters,
|
||||
contactAbsenceFilterOptions,
|
||||
contactGenderFilterOptions,
|
||||
contactStateFilterOptions,
|
||||
contactVinculoFilterOptions,
|
||||
toggleContactAbsenceFilter,
|
||||
toggleContactCityFilter,
|
||||
toggleContactGenderFilter,
|
||||
toggleContactStateFilter,
|
||||
toggleContactVinculoFilter,
|
||||
type ContactFilterOption,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import {
|
||||
contactAbsenceLabels,
|
||||
contactGenderLabels,
|
||||
contactListSortOptions,
|
||||
contactListSortPrimaryOptions,
|
||||
contactVinculoLabels,
|
||||
isDefaultContactListSort,
|
||||
resolveContactListSort,
|
||||
type ContactListState,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
export type ContactOmniboxAction =
|
||||
| { kind: 'url'; state: ContactListState }
|
||||
| { kind: 'clear'; state: ContactListState }
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
export const buildContactOmniboxChips = (state: ContactListState): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
|
||||
|
||||
for (const gender of state.genders ?? []) {
|
||||
chips.push({ id: `gender:${gender}`, label: chipLabel('Gênero', contactGenderLabels[gender]) })
|
||||
}
|
||||
|
||||
for (const stateKey of state.states ?? []) {
|
||||
chips.push({ id: `state:${stateKey}`, label: chipLabel('Estado', stateKey) })
|
||||
}
|
||||
|
||||
for (const city of state.cities ?? []) {
|
||||
chips.push({ id: `city:${city}`, label: chipLabel('Cidade', city) })
|
||||
}
|
||||
|
||||
for (const ausencia of state.ausencias ?? []) {
|
||||
chips.push({
|
||||
id: `ausencia:${ausencia}`,
|
||||
label: chipLabel('Ausência', contactAbsenceLabels[ausencia]),
|
||||
})
|
||||
}
|
||||
|
||||
for (const vinculo of state.vinculos ?? []) {
|
||||
chips.push({
|
||||
id: `vinculo:${vinculo}`,
|
||||
label: chipLabel('Vínculo', contactVinculoLabels[vinculo]),
|
||||
})
|
||||
}
|
||||
|
||||
if (!isDefaultContactListSort(state)) {
|
||||
const { sort, dir } = resolveContactListSort(state)
|
||||
const option = contactListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
|
||||
chips.push({
|
||||
id: 'sort',
|
||||
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildContactOmniboxSuggestionSeeds = ({
|
||||
cityFilterOptions,
|
||||
}: {
|
||||
cityFilterOptions: readonly ContactFilterOption[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
for (const option of contactGenderFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `gender:${option.value}`,
|
||||
group: 'Gênero',
|
||||
label: option.label,
|
||||
keywords: ['genero', 'sexo'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactStateFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `state:${option.value}`,
|
||||
group: 'Estado',
|
||||
label: option.label,
|
||||
keywords: ['estado', 'uf'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of cityFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `city:${option.value}`,
|
||||
group: 'Cidade',
|
||||
label: option.label,
|
||||
keywords: ['cidade', 'municipio'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactAbsenceFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `ausencia:${option.value}`,
|
||||
group: 'Ausência',
|
||||
label: option.label,
|
||||
keywords: [
|
||||
'sem',
|
||||
'ausencia',
|
||||
'faltando',
|
||||
'faltante',
|
||||
'vazio',
|
||||
'incompleto',
|
||||
'incompleta',
|
||||
],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactVinculoFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `vinculo:${option.value}`,
|
||||
group: 'Vínculo',
|
||||
label: option.label,
|
||||
keywords: ['vinculo', 'ligacao', 'lideranca', 'dobradinha', 'assessor', 'equipe'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// C125 — primary-direction seeds only, visible on an empty query (the mobile
|
||||
// cards have no sortable headers, so the omnibox group is the only sort
|
||||
// surface below `md` — same rationale as people).
|
||||
for (const option of contactListSortPrimaryOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `sort:${option.key}|${option.dir}`,
|
||||
group: 'Ordenação',
|
||||
label: option.label,
|
||||
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
export const filterContactOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildContactOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
|
||||
|
||||
export const applyContactOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: ContactListState
|
||||
suggestionId: string
|
||||
}): ContactOmniboxAction => {
|
||||
if (suggestionId.startsWith('q:')) {
|
||||
const q = suggestionId.slice(2)
|
||||
return { kind: 'url', state: { ...state, page: 1, q: q || undefined } }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('gender:')) {
|
||||
return { kind: 'url', state: toggleContactGenderFilter(state, suggestionId.slice(7)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('state:')) {
|
||||
return { kind: 'url', state: toggleContactStateFilter(state, suggestionId.slice(6)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('city:')) {
|
||||
return { kind: 'url', state: toggleContactCityFilter(state, suggestionId.slice(5)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('ausencia:')) {
|
||||
return { kind: 'url', state: toggleContactAbsenceFilter(state, suggestionId.slice(9)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('vinculo:')) {
|
||||
return { kind: 'url', state: toggleContactVinculoFilter(state, suggestionId.slice(8)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('sort:')) {
|
||||
const raw = suggestionId.slice(5)
|
||||
const [key, dir] = raw.split('|')
|
||||
const option = contactListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
|
||||
if (!option) return { kind: 'url', state }
|
||||
const next = { ...state, page: 1, sort: option.key, dir: option.dir }
|
||||
if (isDefaultContactListSort(next)) {
|
||||
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
|
||||
}
|
||||
return { kind: 'url', state: next }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const removeContactOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: ContactListState
|
||||
chipId: string
|
||||
}): ContactOmniboxAction => {
|
||||
if (chipId === 'q') return { kind: 'url', state: { ...state, page: 1, q: undefined } }
|
||||
|
||||
if (chipId.startsWith('gender:')) {
|
||||
return { kind: 'url', state: toggleContactGenderFilter(state, chipId.slice(7)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('state:')) {
|
||||
return { kind: 'url', state: toggleContactStateFilter(state, chipId.slice(6)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('city:')) {
|
||||
return { kind: 'url', state: toggleContactCityFilter(state, chipId.slice(5)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('ausencia:')) {
|
||||
return { kind: 'url', state: toggleContactAbsenceFilter(state, chipId.slice(9)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('vinculo:')) {
|
||||
return { kind: 'url', state: toggleContactVinculoFilter(state, chipId.slice(8)) }
|
||||
}
|
||||
|
||||
if (chipId === 'sort') {
|
||||
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const clearContactOmnibox = (state: ContactListState): ContactOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: clearContactListFilters(state),
|
||||
})
|
||||
204
tests/unit/contactListFilters.unit.spec.ts
Normal file
204
tests/unit/contactListFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildContactFilterHref,
|
||||
clearContactListFilters,
|
||||
contactAbsenceFilterOptions,
|
||||
contactGenderFilterOptions,
|
||||
contactStateFilterOptions,
|
||||
contactVinculoFilterOptions,
|
||||
toggleContactAbsenceFilter,
|
||||
toggleContactCityFilter,
|
||||
toggleContactGenderFilter,
|
||||
toggleContactStateFilter,
|
||||
toggleContactVinculoFilter,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import { parseContactListParams } from '@/utilities/contacts/contactListUrl'
|
||||
import {
|
||||
applyContactOmniboxSuggestion,
|
||||
buildContactOmniboxChips,
|
||||
buildContactOmniboxSuggestionSeeds,
|
||||
filterContactOmniboxSuggestions,
|
||||
removeContactOmniboxChip,
|
||||
} from '@/utilities/contacts/contactOmnibox'
|
||||
|
||||
describe('contacts list filter affordances', () => {
|
||||
it('offers the static seeds: 4 genders, 27 states, 2 absences, 4 vinculos', () => {
|
||||
expect(contactGenderFilterOptions.map((option) => option.value)).toEqual([
|
||||
'feminino',
|
||||
'masculino',
|
||||
'outro',
|
||||
'nao_informado',
|
||||
])
|
||||
expect(contactStateFilterOptions).toHaveLength(27)
|
||||
expect(contactStateFilterOptions[0]).toEqual({ value: 'AC', label: 'AC' })
|
||||
expect(contactAbsenceFilterOptions.map((option) => option.value)).toEqual([
|
||||
'sem_telefone',
|
||||
'sem_email',
|
||||
])
|
||||
expect(contactVinculoFilterOptions.map((option) => option.value)).toEqual([
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'assessores',
|
||||
'equipe',
|
||||
])
|
||||
})
|
||||
|
||||
it('toggles gender and state, validating the value', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(toggleContactGenderFilter(base, 'feminino')).toEqual({
|
||||
page: 1,
|
||||
genders: ['feminino'],
|
||||
})
|
||||
expect(toggleContactGenderFilter(base, 'unknown')).toEqual(base)
|
||||
const withGender = toggleContactGenderFilter(base, 'feminino')
|
||||
expect(toggleContactGenderFilter(withGender, 'feminino')).toEqual(base)
|
||||
expect(toggleContactStateFilter(base, 'BA').states).toEqual(['BA'])
|
||||
expect(toggleContactStateFilter(base, 'XX')).toEqual(base)
|
||||
})
|
||||
|
||||
it('toggles absence and vinculo as real ORs (never a no-op umbrella)', () => {
|
||||
const base = parseContactListParams({})
|
||||
const bothAbsences = toggleContactAbsenceFilter(
|
||||
toggleContactAbsenceFilter(base, 'sem_telefone'),
|
||||
'sem_email',
|
||||
)
|
||||
expect(bothAbsences.ausencias).toEqual(['sem_telefone', 'sem_email'])
|
||||
expect(toggleContactAbsenceFilter(base, 'nope')).toEqual(base)
|
||||
const bothVinculos = toggleContactVinculoFilter(
|
||||
toggleContactVinculoFilter(base, 'liderancas'),
|
||||
'equipe',
|
||||
)
|
||||
expect(bothVinculos.vinculos).toEqual(['liderancas', 'equipe'])
|
||||
expect(toggleContactVinculoFilter(base, 'nope')).toEqual(base)
|
||||
})
|
||||
|
||||
it('toggles city as free text with structural validation', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(toggleContactCityFilter(base, ' Feira de Santana ').cities).toEqual([
|
||||
'Feira de Santana',
|
||||
])
|
||||
expect(toggleContactCityFilter(base, ' ')).toEqual(base)
|
||||
expect(toggleContactCityFilter(base, 'x'.repeat(101))).toEqual(base)
|
||||
})
|
||||
|
||||
it('clears filters keeping the sort, and builds filter hrefs pinned to page 1', () => {
|
||||
const filtered = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
state: 'BA',
|
||||
city: 'Feira de Santana',
|
||||
ausencia: 'sem_email',
|
||||
vinculo: 'liderancas',
|
||||
sort: 'cidade',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
})
|
||||
const cleared = clearContactListFilters(filtered)
|
||||
expect(cleared).toEqual({ page: 1, sort: 'cidade', dir: 'desc' })
|
||||
expect(buildContactFilterHref(cleared)).toContain('sort=cidade')
|
||||
expect(buildContactFilterHref(cleared)).toContain('dir=desc')
|
||||
// Canonical hrefs omit default page=1, so a filtered href never carries
|
||||
// a page param at all — filters always land back on page 1.
|
||||
expect(buildContactFilterHref(filtered)).not.toContain('page=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contacts list omnibox adapter', () => {
|
||||
it('builds one chip per active filter dimension', () => {
|
||||
const state = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
state: ['BA', 'SP'],
|
||||
city: 'Feira de Santana',
|
||||
ausencia: 'sem_telefone',
|
||||
vinculo: 'liderancas',
|
||||
sort: 'cidade',
|
||||
dir: 'desc',
|
||||
})
|
||||
const chips = buildContactOmniboxChips(state)
|
||||
expect(chips.map((chip) => chip.id)).toEqual([
|
||||
'q',
|
||||
'gender:feminino',
|
||||
'state:BA',
|
||||
'state:SP',
|
||||
'city:Feira de Santana',
|
||||
'ausencia:sem_telefone',
|
||||
'vinculo:liderancas',
|
||||
'sort',
|
||||
])
|
||||
expect(chips[0]).toEqual({ id: 'q', label: 'Busca: ana' })
|
||||
expect(chips[1]).toEqual({ id: 'gender:feminino', label: 'Gênero: Feminino' })
|
||||
})
|
||||
|
||||
it('omits the sort chip on the default sort', () => {
|
||||
const chips = buildContactOmniboxChips(parseContactListParams({ q: 'ana' }))
|
||||
expect(chips.map((chip) => chip.id)).toEqual(['q'])
|
||||
})
|
||||
|
||||
it('filters suggestions by group, label and keyword, capping per group', () => {
|
||||
const seeds = buildContactOmniboxSuggestionSeeds({
|
||||
cityFilterOptions: [
|
||||
{ value: 'Feira de Santana', label: 'Feira de Santana' },
|
||||
{ value: 'Salvador', label: 'Salvador' },
|
||||
],
|
||||
})
|
||||
const byLabel = filterContactOmniboxSuggestions(seeds, 'Salvador')
|
||||
expect(byLabel.map((suggestion) => suggestion.id)).toContain('city:Salvador')
|
||||
const byKeyword = filterContactOmniboxSuggestions(seeds, 'genero')
|
||||
// The omnibox always surfaces the q suggestion for a non-empty query
|
||||
// (group 'Busca'); the keyword-matched seeds are the Gênero shortcuts.
|
||||
expect(byKeyword.some((suggestion) => suggestion.group === 'Busca')).toBe(true)
|
||||
const genderMatches = byKeyword.filter((suggestion) => suggestion.group === 'Gênero')
|
||||
expect(genderMatches.map((suggestion) => suggestion.id)).toEqual([
|
||||
'gender:feminino',
|
||||
'gender:masculino',
|
||||
'gender:outro',
|
||||
'gender:nao_informado',
|
||||
])
|
||||
const emptyQuery = filterContactOmniboxSuggestions(seeds, '')
|
||||
// Empty query shows only the dimension shortcuts; data-driven city seeds are hidden.
|
||||
expect(emptyQuery.some((suggestion) => suggestion.id.startsWith('city:'))).toBe(false)
|
||||
expect(emptyQuery.some((suggestion) => suggestion.group === 'Ordenação')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies suggestions: q, facets (toggle), sort (with default collapse)', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'q:ana' })).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, q: 'ana' },
|
||||
})
|
||||
expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'gender:feminino' })).toEqual(
|
||||
{ kind: 'url', state: { page: 1, genders: ['feminino'] } },
|
||||
)
|
||||
const sortApplied = applyContactOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'sort:cidade|desc',
|
||||
})
|
||||
expect(sortApplied).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, sort: 'cidade', dir: 'desc' },
|
||||
})
|
||||
const sortToggled = applyContactOmniboxSuggestion({
|
||||
state: { page: 1, sort: 'cidade', dir: 'desc' },
|
||||
suggestionId: 'sort:cidade|desc',
|
||||
})
|
||||
expect(sortToggled).toEqual({ kind: 'url', state: { page: 1, sort: 'cidade', dir: 'desc' } })
|
||||
})
|
||||
|
||||
it('removes chips by id and clears everything preserving the sort', () => {
|
||||
const state = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
city: 'Salvador',
|
||||
sort: 'cidade',
|
||||
})
|
||||
const removed = removeContactOmniboxChip({ state, chipId: 'city:Salvador' })
|
||||
expect(removed).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, q: 'ana', genders: ['feminino'], sort: 'cidade' },
|
||||
})
|
||||
const cleared = applyContactOmniboxSuggestion({ state, suggestionId: 'clear:all' })
|
||||
expect(cleared.kind).toBe('url')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue