feat(C139): página /campanha/contatos — lista desktop com células editáveis, criação e filtros

This commit is contained in:
Francisco Solla 2026-08-13 14:29:11 -03:00
parent e71b062650
commit 621364f41f
19 changed files with 1885 additions and 22 deletions

View file

@ -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`,

View 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.',
})

View 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>
)
}

View file

@ -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,

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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.

View file

@ -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}

View file

@ -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 =>

View file

@ -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).

View file

@ -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]

View file

@ -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>,

View 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)

View 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),
})

View 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')
})
})