From 621364f41fba9289c2382bd8682acb66ce1f95e5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:29:11 -0300 Subject: [PATCH] =?UTF-8?q?feat(C139):=20p=C3=A1gina=20/campanha/contatos?= =?UTF-8?q?=20=E2=80=94=20lista=20desktop=20com=20c=C3=A9lulas=20edit?= =?UTF-8?q?=C3=A1veis,=20cria=C3=A7=C3=A3o=20e=20filtros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/lib/e2e-affected-manifest.mjs | 2 + .../campanha/(app)/contatos/formActions.ts | 105 +++++ .../campanha/(app)/contatos/page.tsx | 419 ++++++++++++++++++ .../(campaign)/campanha/actions/contact.ts | 10 +- .../campaign/contacts/ContactCreateRow.tsx | 212 +++++++++ .../campaign/contacts/ContactCreateState.tsx | 44 ++ .../campaign/contacts/ContactFilters.tsx | 100 +++++ .../campaign/contacts/ContactPhonesCell.tsx | 138 ++++++ .../campaign/contacts/ContactSelectCell.tsx | 118 +++++ .../campaign/contacts/ContactSortableHead.tsx | 49 ++ .../campaign/people/DeletePersonButton.tsx | 40 +- .../shared/CampaignInlineEditableCell.tsx | 21 +- src/lib/campaignColumnVisibility.ts | 6 + src/lib/schemas/contact.ts | 17 +- src/lib/schemas/personGenders.ts | 2 - src/utilities/contacts/contactListData.ts | 2 +- src/utilities/contacts/contactListFilters.ts | 141 ++++++ src/utilities/contacts/contactOmnibox.ts | 277 ++++++++++++ tests/unit/contactListFilters.unit.spec.ts | 204 +++++++++ 19 files changed, 1885 insertions(+), 22 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/contatos/formActions.ts create mode 100644 src/app/(campaign)/campanha/(app)/contatos/page.tsx create mode 100644 src/components/campaign/contacts/ContactCreateRow.tsx create mode 100644 src/components/campaign/contacts/ContactCreateState.tsx create mode 100644 src/components/campaign/contacts/ContactFilters.tsx create mode 100644 src/components/campaign/contacts/ContactPhonesCell.tsx create mode 100644 src/components/campaign/contacts/ContactSelectCell.tsx create mode 100644 src/components/campaign/contacts/ContactSortableHead.tsx create mode 100644 src/utilities/contacts/contactListFilters.ts create mode 100644 src/utilities/contacts/contactOmnibox.ts create mode 100644 tests/unit/contactListFilters.unit.spec.ts diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index d5a6ff3a..3b88a5b2 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -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`, diff --git a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts new file mode 100644 index 00000000..e26c8365 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts @@ -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 => + 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx new file mode 100644 index 00000000..e56629e1 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -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> +} + +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 +}) => ( + + + + + Nenhum contato neste recorte + + Ajuste a busca e os filtros para ver as fichas da campanha. + + + {hasFilters ? ( + + + Limpar busca e filtros + + + ) : null} + +) + +const contactsColumns = ({ + state, + canDelete, +}: { + state: ContactListState + canDelete: boolean +}): Array> => [ + { + id: 'name', + label: 'Nome', + mandatory: true, + cellClassName: 'max-w-72', + head: , + cell: (row) => ( + + ), + }, + { + id: 'gender', + label: 'Gênero', + cellClassName: 'max-w-36', + cell: (row) => ( + + ), + }, + { + id: 'phone', + label: 'Telefone', + cellClassName: 'max-w-56', + cell: (row) => ( + + ), + }, + { + id: 'email', + label: 'E-mail', + cellClassName: 'max-w-56 whitespace-normal', + head: , + cell: (row) => ( + + ), + }, + { + id: 'city', + label: 'Cidade', + cellClassName: 'max-w-44 whitespace-normal', + head: , + cell: (row) => ( + + ), + }, + { + id: 'state', + label: 'Estado', + cellClassName: 'max-w-24', + head: , + cell: (row) => ( + + ), + }, + { + id: 'postalCode', + label: 'CEP', + cellClassName: 'max-w-28', + cell: (row) => ( + + ), + }, + { + id: 'actions', + label: 'Ações', + mandatory: true, + head: ( + + Ações + + ), + cellClassName: 'text-right', + cell: (row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null) + return ( +
+ {whatsAppHref ? ( + + + + ) : null} + {canDelete ? ( + + {/* 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. */} + + + + + ) : null} +
+ ) + }, + }, +] + +/** 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 +}) => ( +
    + {rows.length === 0 ? ( +
  • {empty}
  • + ) : ( + rows.map((row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null) + return ( +
  • +
    +

    {row.name}

    +

    + {row.phones[0] + ? formatBrazilianPhoneInput(row.phones[0]) + : row.email + ? row.email + : 'Sem contato registrado'} +

    +
    +
    + {whatsAppHref ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
    +
  • + ) + }) + )} +
+) + +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 ( + + + + + } + /> + + + } + /> + + row.contactID} + empty={} + /> + {listData.rows.length ? ( + buildContactListHref(state, page)} + /> + ) : null} + + + + + ) +} diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts index abd1ec1d..8934c618 100644 --- a/src/app/(campaign)/campanha/actions/contact.ts +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -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> & { +): Partial> & { 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, diff --git a/src/components/campaign/contacts/ContactCreateRow.tsx b/src/components/campaign/contacts/ContactCreateRow.tsx new file mode 100644 index 00000000..093cd807 --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateRow.tsx @@ -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 +} + +/** + * 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(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 ( +
+
+
+ + setHasName(event.currentTarget.value.trim().length >= 2)} + /> +
+
+ + + setPhone( + formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value)), + ) + } + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) +} diff --git a/src/components/campaign/contacts/ContactCreateState.tsx b/src/components/campaign/contacts/ContactCreateState.tsx new file mode 100644 index 00000000..401f6567 --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateState.tsx @@ -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(null) + +export const ContactCreateProvider = ({ children }: { children: ReactNode }) => { + const [open, setOpen] = useState(false) + return ( + + {children} + + ) +} + +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 ( + + ) +} diff --git a/src/components/campaign/contacts/ContactFilters.tsx b/src/components/campaign/contacts/ContactFilters.tsx new file mode 100644 index 00000000..2c870659 --- /dev/null +++ b/src/components/campaign/contacts/ContactFilters.tsx @@ -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 ( +
{ + event.preventDefault() + }} + > + { + runAction(applyContactOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyContactOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeContactOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearContactOmnibox(state)) + }} + trailing={ + <> + {trailing} + + + } + /> + + ) +} diff --git a/src/components/campaign/contacts/ContactPhonesCell.tsx b/src/components/campaign/contacts/ContactPhonesCell.tsx new file mode 100644 index 00000000..d96f9dcf --- /dev/null +++ b/src/components/campaign/contacts/ContactPhonesCell.tsx @@ -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 +} + +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(() => phones.map(formatBrazilianPhoneInput)) + const [isPending, setIsPending] = useState(false) + + const clean = (lines: string[]): string[] => { + const seen = new Set() + 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) => { + if (event.key === 'Escape') { + event.preventDefault() + setDraft(phones.map(formatBrazilianPhoneInput)) + return + } + if (event.key === 'Enter') { + event.preventDefault() + commit(draft) + event.currentTarget.blur() + } + } + + return ( +
+ {draft.map((line, index) => ( + { + const next = [...draft] + next[index] = formatBrazilianPhoneInput(sanitize(event.currentTarget.value)) + setDraft(next) + }} + onBlur={() => commit(draft)} + onKeyDown={handleKeyDown} + /> + ))} + {draft.length === 0 ? ( + Sem telefone + ) : null} + +
+ ) +} diff --git a/src/components/campaign/contacts/ContactSelectCell.tsx b/src/components/campaign/contacts/ContactSelectCell.tsx new file mode 100644 index 00000000..9f963db1 --- /dev/null +++ b/src/components/campaign/contacts/ContactSelectCell.tsx @@ -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 + 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) => { + setSelected(event.currentTarget.value) + void commit(event.currentTarget.value) + } + + const hasValue = value !== null && value !== '' + + return ( +
+ +
+ ) +} diff --git a/src/components/campaign/contacts/ContactSortableHead.tsx b/src/components/campaign/contacts/ContactSortableHead.tsx new file mode 100644 index 00000000..0a05e7f6 --- /dev/null +++ b/src/components/campaign/contacts/ContactSortableHead.tsx @@ -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 ( + + {children ?? contactListSortLabels[sortKey]} + + ) +} diff --git a/src/components/campaign/people/DeletePersonButton.tsx b/src/components/campaign/people/DeletePersonButton.tsx index 92f9e109..4d4f2798 100644 --- a/src/components/campaign/people/DeletePersonButton.tsx +++ b/src/components/campaign/people/DeletePersonButton.tsx @@ -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({ 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}`} > - Apagar pessoa + Apagar {vocabulary} Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '} {personName} com a campanha. diff --git a/src/components/campaign/shared/CampaignInlineEditableCell.tsx b/src/components/campaign/shared/CampaignInlineEditableCell.tsx index 08c587b0..b1e8061f 100644 --- a/src/components/campaign/shared/CampaignInlineEditableCell.tsx +++ b/src/components/campaign/shared/CampaignInlineEditableCell.tsx @@ -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} diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index e17e5da4..55ac7543 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -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 diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index 01d06afe..e30c56fc 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -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( + (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). diff --git a/src/lib/schemas/personGenders.ts b/src/lib/schemas/personGenders.ts index 168b2711..212e12bc 100644 --- a/src/lib/schemas/personGenders.ts +++ b/src/lib/schemas/personGenders.ts @@ -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] diff --git a/src/utilities/contacts/contactListData.ts b/src/utilities/contacts/contactListData.ts index fe8a0c1a..1320897c 100644 --- a/src/utilities/contacts/contactListData.ts +++ b/src/utilities/contacts/contactListData.ts @@ -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, diff --git a/src/utilities/contacts/contactListFilters.ts b/src/utilities/contacts/contactListFilters.ts new file mode 100644 index 00000000..98ace691 --- /dev/null +++ b/src/utilities/contacts/contactListFilters.ts @@ -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) diff --git a/src/utilities/contacts/contactOmnibox.ts b/src/utilities/contacts/contactOmnibox.ts new file mode 100644 index 00000000..bd0a2524 --- /dev/null +++ b/src/utilities/contacts/contactOmnibox.ts @@ -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, + 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), +}) diff --git a/tests/unit/contactListFilters.unit.spec.ts b/tests/unit/contactListFilters.unit.spec.ts new file mode 100644 index 00000000..30e02ca5 --- /dev/null +++ b/tests/unit/contactListFilters.unit.spec.ts @@ -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') + }) +})