Merge pull request #741 from fsolla/C139-contatos-pagina-da-entidade-contact-lista-desktop-cards

C139 — Contatos — página da entidade Contact (lista desktop + cards mobile)
This commit is contained in:
Francisco Solla 2026-08-13 19:27:17 +00:00 committed by GitHub
commit d6df8c6360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 4690 additions and 92 deletions

View file

@ -0,0 +1,252 @@
# Impl: C139 — Contatos — página da entidade Contact (lista desktop + cards mobile)
Status: aprovado
Atualizado em: 2026-08-13
Issue: #728
Intenção: docs/plans/contatos-pagina-da-entidade-contact.md
Appetite restante: herdado (~2–3 dias eng)
## Leitura da intenção
- **Outcome:** `/campanha/contatos` é a página da ficha `Contact` — tabela desktop + cards
mobile, omnibox (busca geral por nome/e-mail/qualquer telefone + facets por propriedade e
vínculo), ordenação por nome/cidade/estado/e-mail, edição in-place sem moldura,
criação (linha desktop / sheet mobile) e apagar com alerta de vínculos. Leader não acessa;
`/campanha/pessoas` permanece no ar; nenhuma migration.
- **O que NÃO negociar:** ficha pura (nada de colunas de capacidade/território/papel);
vínculos só como facet; escopo = `canReadContacts` (assessor vê só a carteira, leader
lockdown); nome obrigatório e único na criação com aviso amigável preservando o digitado;
apagar com manifest de entidades linkadas; leader tool move para `/campanha/meus-contatos`
(decisão A travada).
- **O que reavaliar:**
- A hipótese "`updatePersonContact` é o padrão de escopo" precisa de ajuste: a regra C116
("assessor edita só o que tem liderança/dobradinha legível") não cobre fichas que o
assessor vê só por apoiador. A página de fichas usa **"edita o que vê" = ficha legível
pelo `canReadContacts`** — regra mais simples e coerente com o aceite.
- A hipótese "células reusam `CampaignInlineEditableCell`" cobre só texto; gênero/estado
são selects e telefones é lista — precisa de duas células novas + param trization leve
do componente compartilhado.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph Rota
L1[contatos/page.tsx<br/>gate staff] --> D[loadContactListPageData]
D --> F[ContactFilters<br/>omnibox adapter]
D --> T[CampaignTable<br/>células permanent]
D --> C[MobileCards + EditSheet + CreateSheet]
end
subgraph Ações
A1[updateContactField<br/>blur-save célula] --> TX[withPayloadTransaction]
A2[updateContactFull<br/>sheet mobile] --> TX
A3[createContactAction<br/>linha nova / FAB] --> TX
A4[deleteContactAction<br/>manifest C100] --> TX
TX --> N[assertContactNameAvailable<br/>advisory lock contact-name]
TX --> S[assertContactEditable<br/>canReadContacts]
end
subgraph Movida leader
M[meus-contatos/page.tsx<br/>= antigo contatos] --> P[LEADER_CONTACTS_HOME<br/>= /campanha/meus-contatos]
P --> CH[campaignPageChrome<br/>meusContatos novo]
P --> NA[nav leader]
end
```
**Opções consideradas:**
- **A (escolhida): rota nova em `/campanha/contatos` + leader tool movida para
`/campanha/meus-contatos`** — decisão de produto travada no gate; toca 6 constantes/catalogos
- 3 testes, todos mapeados.
- **B: leader tool em subrota (`/campanha/contatos/meus`)** — rejeitada no gate (decisão A).
- **C: dados por `payload.find` único em `contact` com `user` threadado** (sem merge de 3
fontes como pessoas) — escolhida: a lista É a ficha; `canReadContacts` já devolve o escopo
`{ id: { in } }`; vínculos entram só como facets (3 queries `select: { contact: true }`).
- **D: reusar `assertPersonContactEditable` (C116)** — rejeitada: exclui fichas de
apoiadores do escopo do assessor; a página é da ficha, não de capacidades.
- **E: `q` com `'phones.value': { like }` no where** — escolhida (Payload suporta subcampo de
array); fallback verificado na fase tracer: se o adapter falhar, filtra `q` em memória
(a lista é uma fonte só — custo igual).
- **F: edição do sheet mobile como 7 actions blur** — rejeitada: o sheet é um formulário
único; uma action `updateContactFull` (schema completo) é um write atômico.
- **G: telefones da célula por input/blur com semântica "set primary"** (contrato C116) —
rejeitada para a ficha: editar o 2º telefone não deve reordená-lo; a célula commita a
**lista inteira** (`field: 'phones'`, contrato C112 que já existe no schema).
**Recomendação:** A+C+E+G — o chassis de listas do Pass 2 e os contratos de ficha já
existentes cobrem tudo; o trabalho novo é a camada de domínio de contatos (URL state,
loader, ações) + duas células novas + os dois sheets + a movida do leader tool.
### Componentes / mudanças
**Movida do leader tool (fase 1):**
- **`LEADER_CONTACTS_HOME`** (`src/lib/campaignPaths.ts`): `'/campanha/meus-contatos'`.
- **`src/app/(campaign)/campanha/(app)/contatos/page.tsx`** → `meus-contatos/page.tsx`
(conteúdo igual; metadata `meusContatos`).
- **`campaignPageChrome.ts`**: novo `meusContatos` no catálogo (copy atual do leader);
`contatos` ganha copy de staff ('Fichas da campanha — atualize dados e fale com as
pessoas', do draft); regra de path `LEADER_CONTACTS_HOME` → `meusContatos` + regra nova
`/campanha/contatos` → `contatos`.
- **`nav.ts`**: leaderNav usa a constante (automático); staffNav ganha entrada 'Contatos'.
- **`campaignPageActor.ts`**: denyRedirect do leader já usa a constante (automático).
- **`campaignQuickActionMount.ts`**: `isLeaderContactsPath` usa a constante (automático);
`shouldMountQuickActionsFab` passa a retornar `false` para `/campanha/contatos` (staff —
o FAB de criação da página substitui o de ações rápidas; FAB de IA permanece, é montado
à parte).
- **`campaignQuickActionRegistry.ts`**: nada muda (rota leader ≠ contatos staff).
- **`campaignHomeActions.ts` / `campaignNavigationUrls.ts`**: usam a constante — o destino
`leaderContacts` do AI continua válido no caminho novo.
**Domínio de contatos:**
- **`src/utilities/contacts/contactListUrl.ts`** (puro, espelho de `peopleListUrl`):
state `{ page, q?, genders?, states?, cities?, ausencias?, vinculos?, sort?, dir? }`;
params `q|gender|state|city|ausencia|vinculo|sort|dir|page`; parse/canonicalize via
`resolveListUrl`; `ausencia` e `vinculo` com parse **sem** `parseExhaustiveEnumParam`
(selecionar todos ≠ nenhum — mesmo racional do umbrella C125); sort keys
`name|cidade|estado|email` (defaults asc); `contactPageSize = 25`; `buildContactListWhere`
(q por `or: name like / email like / 'phones.value' like`; demais filtros em memória);
labels: gênero (4 valores), UFs (`CitiesByState`), ausências (`sem_telefone|sem_email`),
vínculos (`liderancas|dobradinhas|assessores|equipe`).
- **`src/utilities/contacts/contactListData.ts`** (server-only, espelho de `peopleData`):
`loadContactListPageData(payload, user, state)` — 1 query `contact` (depth 0, limit 0,
`user` + `overrideAccess: false` — o escopo é o `canReadContacts` da própria collection)
- 3 queries de vínculo (`leadership`/`stateDeputy` com `user` threadado; `campaignUser`
com `overrideAccess: true` justificado — mesmo precedente da fonte staff de pessoas —
merge escopado pelo conjunto de contatos legíveis); facets do conjunto escopado com
valores selecionados unidos (padrão `peopleFilterFacetsFromRows`); filtro/sort em memória
(nulls last, tie-break nome→id); paginação em memória.
- **`src/utilities/contacts/contactNameInvariant.ts`**: `assertContactNameAvailable`
(padrão `assertStateDeputyNameAvailable`): normalização = trim + colapso de espaços +
minúsculas; lock `contact-name:{normalized}`; query `name: { like: normalized }` (ILIKE
contains) + conferência exata em memória (cobre fichas com espaços duplicados); throw
`CONTACT_NAME_CONFLICT_MESSAGE` ('Já existe um contato com este nome — confira a lista
antes de salvar.'). Vale para criação E renomeação manual (rabbit hole da intenção).
- **Migration:** nenhuma (schema intocado — valores de enum inalterados).
**Ações (`src/app/(campaign)/campanha/actions/contact.ts`, 'use server):**
- **`updateContactField`** — blur-save das células desktop: schema = `contactFieldUpdateSchema`
estendido com branches `gender|state|city|postalCode` (aditivo, o switch de person.ts
ignora branches novas); `withPayloadTransaction`; `reloadStaffActor`; `assertContactEditable`
(unrestricted passa; advisor: ficha legível com `user` + `overrideAccess: false` — "edita o
que vê"); branch `name` → `assertContactNameAvailable`; write `overrideAccess: true`
(justificado pelo gate + escopo — mesmo precedente do `updatePersonContactRecord`);
revalida `/campanha/contatos` + pessoas/liderancas/dobradinhas.
- **`updateContactFull`** — sheet mobile: schema completo da ficha (zod novo
`contactFullUpdateSchema`), mesmos gate/escopo/invariante; write atômico.
- **`createContactAction`** — linha desktop / sheet mobile: `contactCreateSchema` (name
obrigatório [2..120], email opcional, phones array dedupe, gender opcional, `state`
obrigatório — a collection exige, city/CEP opcionais); `reloadStaffActor` (criação =
equipe, incl. assessor — a ficha sem vínculo nasce invisível para o assessor, comportamento
aceito, ver rabbit holes); `assertContactNameAvailable`; `payload.create` com
`overrideAccess: true` justificado (access.create é admin-only; o gate staff + invariante
são a autorização).
- **Apagar** — reusa o contrato C100: `getContactDeleteManifestAction` / `deleteContactAction`
via `loadPersonDeleteManifest` / `deletePersonRecord` com `reloadUnrestrictedActor`
(coordenador/candidato — botão só para unrestricted, precedente `canDelete` de pessoas).
- **`src/app/(campaign)/campanha/(app)/contatos/formActions.ts`**: ladder
`runCampaignFormAction` + safeMessages (nome conflito, fora de escopo, telefone inválido,
duplicado).
**Access / Consent:** `canReadContacts` (leitura, já existe), `canManageContacts` não muda;
nenhum Consent novo (dados internos de ficha). Locks: `contact-name:{normalized}`.
**UI (Impeccable C — rota nova, shape → craft → critique → polish):**
- **Reuso:** `CampaignListOmnibox` (chips no input), `CampaignSortableHead` (C117),
`CampaignTable` + `CampaignColumnPicker`, `CampaignListPagination`, `CampaignListFooter`,
`CampaignListSheetProvider`, `useCampaignListFilterNavigation`, `CampaignTransitionAnchor`.
- **`CampaignInlineEditableCell`** (compartilhado, mudança aditiva): `placeholder?` prop
(default '—') e `field` union ganha `'city' | 'postalCode'` — as 4 células de texto
(nome/e-mail/cidade/CEP) usam `permanent` com placeholders 'Sem e-mail'/'Sem telefone'
nos vazios (placeholder vem da célula: email/city/CEP sem valor → label pt-BR).
- **`src/components/campaign/contacts/ContactSelectCell.tsx`** (novo, client): select nativo
sem moldura para gênero (4 valores, label do collection alterado) e estado (27 UFs);
onChange commita via `formAction` (`field: gender|state`); erro → reverte + toast.
- **`ContactPhonesCell.tsx`** (novo, client): inputs empilhados sem moldura (máscara
`formatBrazilianPhoneInput`); blur/Enter de qualquer linha commita a **lista inteira**
(`field: 'phones'`, empties descartados antes do schema); "+" adiciona linha; Escape
descarta o draft.
- **`ContactCreateRow.tsx`** (novo, client): linha vazia no topo da tabela (fundo destacado,
inputs sem moldura), Salvar (inativo sem nome) / Descartar; conflito → mensagem na linha
mantendo o digitado; `ContactCreateButton` no trailing do omnibox (desktop) + FAB móvel
(criação; `md:hidden`, mesma posição do quick-actions FAB) — estado compartilhado por um
provider client simples.
- **Mobile:** `ContactMobileCard` (nome, secundária = telefone ?? e-mail, chip gênero +
"Cidade · Estado", ações WhatsApp/e-mail/Apagar) + `ContactEditSheet` (todos os campos
full-bleed com divisórias, `PhonesFieldEditor` form mode para telefones, "Apagar contato"
vermelho, Cancelar/Salvar via footer custom) + `ContactCreateSheet` (mesmo form vazio).
- **`ContactDeleteDialog.tsx`** (novo, client): alerta com manifest do `loadPersonDeleteManifest`
(lideranças com municípios, dobradinhas, pledges, invites, apoiadores, atualizações,
contas, ficha anonimizada) — reusa o tipo e a enumeração do C100.
- **Colunas:** Nome, Gênero, Telefone, E-mail, Cidade, Estado, CEP, Ações (ações = coluna
obrigatória). `CAMPAIGN_LIST_IDS` ganha `'contatos'`; default hidden `['postalCode']`.
- **Chrome/copy:** catálogo `contatos` (staff) + `meusContatos` (leader).
### Dados → forma
- Forma escolhida: valores da ficha direto nas células/inputs (edição in-place); facets como
seeds estáticos + conjuntos data-driven (cidades distintas do escopo); sem agregados —
nenhum KPI na superfície (decisão de produto "Vou apresentar dados? Não").
- Rejeitada: COUNT(\*) FILTER SQL (sem KPI que justifique), agregado por página.
## Fases verificáveis
1. **Movida do leader tool** — constante + rota + chrome + nav + FAB mount; atualiza os
testes que pinam o path (`leaderContactsQuickActions`, `campaignHomeActions`,
`campaignNavigationUrls`, `campaignPageChrome`, e2e `campaignPeople` lockdown). Gate
unit + e2e da área leader verdes.
2. **Tracer do domínio** — `contactListUrl` + `contactListData` + verificação do
`'phones.value'` no adapter Postgres (fallback em memória se falhar) + tests unit puros
(parse/canonical/where/filtro/sort/facets). Gate unit verdes.
3. **Ações** — `contactNameInvariant`, schemas, `actions/contact.ts`, `formActions.ts`,
`Contact` gender label ('Outro' → 'Não binário', sem migration — verificar grep de
testes que pinam a label). Gate unit verdes.
4. **UI desktop** — células (param trization + 2 novas), coluna de ações, create row,
omnibox adapter, column picker. Shape → craft → critique → polish (Impeccable C).
5. **UI mobile** — cards, sheets (edição/criação), FAB, apagar no card/sheet.
6. **Gates + e2e** — `tests/e2e/campaignContacts.e2e.spec.ts` novo (criação com conflito,
edição blur, facet, q por telefone, sort, cards/sheet mobile, apagar com manifest,
escopo assessor) + `pnpm gate:fast` + full local.
## Rabbit holes / Não escopo (engenharia)
- **Não** empurrar filtros de vínculo para o where SQL (facet de joins exige o catálogo
por ficha — 3 queries limit 0 são o padrão de pessoas).
- **Não** criar aggregate SQL de KPI (não há KPI no aceite).
- **Não** dedupe retroativo de nomes nem unicidade no import CSV (rabbit hole de produto
confirmado).
- **Não** edição em massa, filtros salvos (B18 fica disponível), coluna de vínculos.
- **Não** alterar o FAB de IA, a página de Pessoas, listas especializadas.
- Ficha criada por assessor sem vínculo fica invisível para ele próprio (escopo
`canReadContacts` exige vínculo) — comportamento aceito no v1, anotado para produto.
## Riscos e mitigação
- **`'phones.value'` no where do adapter** — testar na fase 2; fallback: `q` em memória
(uma fonte só, custo igual ao de pessoas).
- **`canReadContacts` com `id: in` grande (coordinator = `true`, advisor/leader = lista)** —
mesmo custo do acesso atual da collection; sem mudança de contrato.
- **Write `overrideAccess: true` em `contact`** — sempre precedido do gate staff +
escopo/invariante na mesma transação; comentário de justificativa no local (padrão do
repo).
- **`CampaignInlineEditableCell` compartilhado** — mudança aditiva (prop opcional + union);
nenhum usuário atual quebra; `pnpm test` da suíte de pessoas cobre.
- **Tabela com ~7 inputs por linha × 25 linhas** — precedente de pessoas (células permanent);
inputs sem estado de edição são baratos; verificar com e2e que a página não degrada.
- **Label de gênero 'Outro' → 'Não binário'** — valores de enum inalterados (sem
migration); grep de testes que pinam a label antes.
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (ficha pura, facets, edição onde se vê,
criação com nome único, apagar com manifest, leader fora, Pessoas no ar, sem migration)
- [ ] Invariantes AGENTS/engineering-standards (gate staff por página, tx + locks nas
escritas multi-etapa, `user` threadado na leitura, overrideAccess justificado)
- [ ] Testes de domínio previstos (unit: URL state/where/invariante de nome; int/e2e:
escopo assessor, lockdown leader no caminho novo, conflito de nome, apagar com manifest)
- [ ] `pnpm gate:fast`, `pnpm build` local, Aikido sem novos findings nos arquivos editados
Self-score decision-quality: 4/5 — decisões mapeadas a precedentes verificados no código;
único ponto de validação em runtime é o subcampo de array no where (fallback definido).

View file

@ -0,0 +1,86 @@
# C140 — Formulários de campanha apagam o digitado em erro de validação (React 19 form reset)
Status: rascunho
Atualizado em: 2026-08-13
Issue: #740
Priority: P2
Model: composer-2.5
Impeccable: B — UX (perda de dado digitado em erro); sem fluxo novo
Canvas UI: N/A — não muda layout
Appetite: ~1 dia eng; um outcome verificável
Responsável: —
## Intenção
Formulários de campanha com `useActionState` + `<form action={submitAction}>` (padrão
`action={submitAction}`) sofrem o reset automático do React 19: **após QUALQUER action
assentada (sucesso ou erro), campos não controlados voltam ao defaultValue**. Quando o
formulário fica aberto em erro de validação/conflito, o usuário vê o alerta mas **todo o
digitado some** — silenciosamente.
**Provado empiricamente nesta sessão (C139):** create row desktop e sheets mobile de
contatos — mesmo nó DOM, valor restaurado ao default após submit que retornou erro
(validation e conflito de nome). O fix do C139 trocou para campos controlados + dispatch
manual (`preventDefault` + `startTransition(() => submitAction(new FormData(...)))`), que
mantém `isPending` correto sem reentrar no plumbing de form action.
Restam **~21 formulários** com o padrão antigo na árvore campaign
(`grep 'action={submitAction}' src/components/campaign`): votePledge (2), municipality
(wizard steps, updates, advisors), tour, advisor, leadership (3), organization, stateDeputy,
demand (3), shared wizard. Todos com o mesmo defeito de UX em erro.
## Persona e fluxo
- **Persona:** staff da campanha (coordenador/assessor) preenchendo formulários longos
(liderança, demanda, organização, dobradinha, declaração de votos, wizard de giro).
- **Job principal:** errar um campo, ver o erro, corrigir SÓ o campo errado e salvar.
- **Fluxo desejado:** o erro aparece, os demais campos **permanecem digitados**, o usuário
corrige o mínimo.
- **Anti-goal:** nenhuma mudança de comportamento de sucesso; nenhuma mudança de schemas
de dados; não virar epic de refactor global.
## Objetivo e aceite
- Nenhum form com `useActionState` em `/campanha` apaga o digitado quando a action retorna
erro (fieldErrors ou message).
- O padrão de fix segue o precedente C139: campos controlados **ou** dispatch manual
(`onSubmit` + `preventDefault` + `startTransition`), decidido por formulário conforme o
que o C139 já fez (sheets controladas; create row com dispatch manual + uncontrolled).
- Cobertura por spec e2e ou unit do form mais representativo por família (ex.: um form
longo de criação em erro mantém valores).
- Regressão: os specs existentes das famílias tocadas seguem verdes sem alteração.
## Dados (intenção)
- **Vou apresentar dados?** Não — o "dado" é o comportamento de formulário já provado na
sessão C139 (mesmo nó, valor resetado, 3 formas distintas).
## Direção no codebase (hipótese)
- **Áreas prováveis:** os ~21 forms listados acima em `src/components/campaign/*`
(`grep -rn 'action={submitAction}' src/components/campaign`).
- **Precedente a olhar:** `src/components/campaign/contacts/ContactFormFields.tsx`
(controlado + manual dispatch, comentário documentando o porquê) e
`ContactCreateRow.tsx` (dispatch manual); `ActivityOverlay.tsx` (controlado + manual —
precedente anterior ao C139).
- **Risco de acoplamento:** cada form é independente; o fix é mecânico por arquivo.
Forms com telefones/arrays (`PhonesFieldEditor` FORM mode) precisam conferir se o editor
interno é controlado (não é resetado pelo dispatch manual).
## Já resolvido no simplify/critique (não reabrir)
- Forms do domínio `contacts` (create row, create/edit sheets) — fix C139 nesta entrega,
coberto pelo e2e `campaignContacts`.
## Explicitamente fora
- Forms fora de `/campanha` (frontend público) — não verificados nesta colheita; reavaliar
se o padrão aparecer lá.
- Mudança de comportamento de sucesso ou de schemas — fora de escopo.
- Padronizar TODOS os forms num único helper compartilhado — decisão de engenharia do
item (pode ficar com os dois padrões C139, ou extrair helper se o lote mostrar DRY).
## Dependências
- `C139 (#728)` — a entrega que provou o defeito e fixou o domínio contacts (via `depends`
na Issue).

View file

@ -134,6 +134,14 @@ export const E2E_AFFECTED_MANIFEST = [
],
specs: ['campaignPeople'],
},
{
prefixes: [
`${CAMPAIGN_APP}/contatos`,
'src/components/campaign/contacts',
'src/utilities/contacts',
],
specs: ['campaignContacts'],
},
// Domains without a dedicated e2e family still wake campaign home smoke so
// the affected classifier cannot return mode=none on an unmapped domain dir.
{
@ -164,7 +172,7 @@ export const E2E_AFFECTED_MANIFEST = [
`${CAMPAIGN_APP}/apoiadores`,
`${CAMPAIGN_APP}/assessores`,
`${CAMPAIGN_APP}/atualizacoes`,
`${CAMPAIGN_APP}/contatos`,
`${CAMPAIGN_APP}/meus-contatos`,
`${CAMPAIGN_APP}/demandas`,
`${CAMPAIGN_APP}/dobradinhas`,
`${CAMPAIGN_APP}/organizacoes`,

View file

@ -0,0 +1,138 @@
'use server'
import {
createContact,
updateContactField,
updateContactFull,
} 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 () => {
// The desktop row sends one `phone`; the mobile sheet sends the
// repeatable `phones` list (PhonesFieldEditor) — the row wins when both
// are present, but the two forms never send both.
const singlePhone = nullableFormText(formData, 'phone')
const phonesList = repeatedPhoneFormValues(formData, 'phones')
const phones = phonesList.length > 0 ? phonesList : singlePhone ? [singlePhone] : []
const result = await createContact({
name: requiredFormText(formData, 'name'),
email: nullableFormText(formData, 'email') ?? undefined,
phones,
gender: nullableFormText(formData, 'gender') ?? undefined,
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.',
})
/** The mobile edit sheet's single atomic ficha write (plan decision F). */
export const updateContactFullFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> =>
runCampaignFormAction({
execute: async () => {
await updateContactFull({
id: requiredRelationshipFormValue(formData, 'id'),
name: requiredFormText(formData, 'name'),
email: nullableFormText(formData, 'email') ?? undefined,
phones: repeatedPhoneFormValues(formData, 'phones'),
gender: nullableFormText(formData, 'gender') ?? undefined,
state: requiredFormText(formData, 'state'),
city: nullableFormText(formData, 'city') ?? undefined,
postalCode: nullableFormText(formData, 'postalCode') ?? undefined,
})
return { message: 'Salvo.' }
},
safeMessages: contactCellSafeMessages,
genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.',
})

View file

@ -1,33 +1,368 @@
import config from '@payload-config'
import { MessageCircleIcon, SearchXIcon } from 'lucide-react'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { ContactCreateFab } from '@/components/campaign/contacts/ContactCreateFab'
import { ContactCreateRow } from '@/components/campaign/contacts/ContactCreateRow'
import { ContactCreateProvider } from '@/components/campaign/contacts/ContactCreateState'
import { ContactFilters } from '@/components/campaign/contacts/ContactFilters'
import { ContactMobileList } from '@/components/campaign/contacts/ContactMobileList'
import { ContactPhonesCell } from '@/components/campaign/contacts/ContactPhonesCell'
import { ContactSelectCell } from '@/components/campaign/contacts/ContactSelectCell'
import { ContactSortableHead } from '@/components/campaign/contacts/ContactSortableHead'
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 { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
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 { isCampaignLeader } from '@/utilities/campaignAccess'
import { CitiesByState } from '@/lib/cities'
import { 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 { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
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'
export const dynamic = 'force-dynamic'
import { createContactFormAction, updateContactFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('contatos')
export default async function CampaignContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
type ContactsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
if (!isCampaignLeader(user)) {
redirect('/campanha')
}
const genderOptions: ReadonlyArray<{ value: ContactGender; label: string }> = (
Object.keys(contactGenderLabels) as ContactGender[]
).map((value) => ({ value, label: contactGenderLabels[value] }))
const view = await loadLeaderContactsPageData(payload, user)
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>
)
},
},
]
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 (
<LeaderContactsPanel
municipalityOptions={view.municipalityOptions}
defaultMunicipalityId={view.defaultMunicipalityId}
showMunicipalitySelect={view.showMunicipalitySelect}
registrationConsentConfigured={view.registrationConsentConfigured}
contacts={view.contacts}
/>
<CampaignPageShell>
<ContactCreateProvider>
<CampaignListSheetProvider>
<CampaignListPendingBoundary>
<ContactFilters
state={state}
cityFilterOptions={cityFilterOptions}
trailing={
<CampaignColumnPickerTrailing
columnVisibility={columnVisibility}
columns={toCampaignColumnPickerColumns(columns)}
/>
}
/>
<CampaignListResults>
<ContactMobileList
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>
<ContactCreateFab />
</CampaignListSheetProvider>
</ContactCreateProvider>
</CampaignPageShell>
)
}

View file

@ -0,0 +1,33 @@
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
export const dynamic = 'force-dynamic'
export const metadata = campaignPageMetadataFromCatalog('meusContatos')
export default async function CampaignMeusContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
if (!isCampaignLeader(user)) {
redirect('/campanha')
}
const view = await loadLeaderContactsPageData(payload, user)
return (
<LeaderContactsPanel
municipalityOptions={view.municipalityOptions}
defaultMunicipalityId={view.defaultMunicipalityId}
showMunicipalitySelect={view.showMunicipalitySelect}
registrationConsentConfigured={view.registrationConsentConfigured}
contacts={view.contacts}
/>
)
}

View file

@ -0,0 +1,220 @@
'use server'
import { revalidatePath } from 'next/cache'
import { reorderWithPrimaryPhone } from '@/lib/phone'
import {
CONTACT_CELL_NOT_IN_SCOPE_MESSAGE,
CONTACT_CELL_STAFF_MESSAGE,
CONTACT_CREATE_STAFF_MESSAGE,
contactCreateSchema,
contactFieldUpdateSchema,
contactFullUpdateSchema,
type ContactCreateInput,
type ContactFieldUpdateInput,
type ContactFullUpdateInput,
} from '@/lib/schemas/contact'
import type { CampaignUser, Contact } from '@/payload-types'
import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext'
import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant'
import {
withPayloadTransaction,
type PayloadTransactionRequest,
} from '@/utilities/payloadTransaction'
import type { Payload } from 'payload'
/**
* C139 — the contacts page writes the ficha itself, so the write gate is the
* row's own read visibility: "you edit what you see". The `contact` collection
* access keeps `create`/`update` admin-only (`canManageContacts`), so every
* campaign write bypasses with `overrideAccess: true` AFTER the staff gate and
* (for an advisor) a user-threaded scope re-check — the bypass is justified by
* the gate, never standalone.
*/
const assertContactRowEditable = async (
payload: Payload,
actor: CampaignUser,
contactID: number,
req: PayloadTransactionRequest,
): Promise<void> => {
if (isCampaignUnrestricted(actor)) return
const visible = await payload.find({
collection: 'contact',
where: { id: { equals: contactID } },
depth: 0,
limit: 1,
pagination: false,
user: actor,
overrideAccess: false,
req,
})
if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE)
}
const createContactRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactCreateInput,
): Promise<{ contactID: number }> => {
const data = contactCreateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
await reloadStaffActor(payload, actor, CONTACT_CREATE_STAFF_MESSAGE, req)
await assertContactNameAvailable(payload, req, data.name)
const created = await payload.create({
collection: 'contact',
data: {
name: data.name,
...(data.email ? { email: data.email } : {}),
...(data.phones && data.phones.length > 0
? { phones: data.phones.map((value) => ({ value })) }
: {}),
...(data.gender ? { gender: data.gender } : {}),
state: data.state,
...(data.city ? { city: data.city } : {}),
...(data.postalCode ? { postalCode: data.postalCode } : {}),
},
depth: 0,
// Intentional bypass: the staff gate above is the authorization; the
// collection's `create` access is admin-only by design (C139).
overrideAccess: true,
req,
})
return { contactID: created.id }
})
}
export const createContact = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
const { contactID } = await createContactRecord(payload, actor, input as ContactCreateInput)
revalidatePath('/campanha/contatos', 'page')
return { contactID }
}
const contactFieldData = (
data: ContactFieldUpdateInput,
): Partial<Pick<Contact, 'name' | 'email' | 'city' | 'postalCode' | 'gender' | 'state'>> & {
phones?: { value: string }[]
} => {
switch (data.field) {
case 'name':
return { name: data.name }
case 'email':
return { email: data.email ?? null }
case 'city':
return { city: data.city }
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':
return {
phones: data.phone === null || data.phone === undefined ? [] : [{ value: data.phone }],
}
}
}
const updateContactFieldRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFieldUpdateInput,
): Promise<void> => {
const data = contactFieldUpdateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req)
await assertContactRowEditable(payload, currentActor, data.id, req)
let contactData = contactFieldData(data)
if (data.field === 'name') {
await assertContactNameAvailable(payload, req, data.name, data.id)
} else if (data.field === 'phone') {
// Inline primary-phone edit (C112 shape): the new number goes first and
// the rest shifts up; clearing drops the primary phone.
const current = await payload.findByID({
collection: 'contact',
id: data.id,
depth: 0,
select: { phones: true },
// Intentional bypass: the scope check above established the actor's
// right over this row; the read only resolves the list to reorder.
overrideAccess: true,
req,
})
contactData = {
phones: reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({ value })),
}
}
// Intentional bypass: same gate as the reads above.
await payload.update({
collection: 'contact',
id: data.id,
data: contactData,
depth: 0,
overrideAccess: true,
req,
})
})
}
export const updateContactField = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
await updateContactFieldRecord(payload, actor, input as ContactFieldUpdateInput)
revalidatePath('/campanha/contatos', 'page')
}
/**
* C139 — the mobile sheet's single atomic ficha write (plan decision F): one
* transaction, same gate ("edita o que vê") and same name invariant as the
* per-field ladder; every column is written from the form (empties clear).
*/
const updateContactFullRecord = async (
payload: Payload,
actor: CampaignUser,
input: ContactFullUpdateInput,
): Promise<void> => {
const data = contactFullUpdateSchema.parse(input)
return withPayloadTransaction(payload, async ({ req }) => {
const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req)
await assertContactRowEditable(payload, currentActor, data.id, req)
await assertContactNameAvailable(payload, req, data.name, data.id)
// Intentional bypass: same gate as the per-field write above.
await payload.update({
collection: 'contact',
id: data.id,
data: {
name: data.name,
email: data.email ?? null,
phones: (data.phones ?? []).map((value) => ({ value })),
gender: data.gender ?? null,
state: data.state,
city: data.city ?? null,
postalCode: data.postalCode ?? null,
},
depth: 0,
overrideAccess: true,
req,
})
})
}
export const updateContactFull = async (input: unknown) => {
const { payload, actor } = await getCampaignActionContext()
await updateContactFullRecord(payload, actor, input as ContactFullUpdateInput)
revalidatePath('/campanha/contatos', 'page')
}

View file

@ -162,7 +162,7 @@ export const Contact: CollectionConfig = {
options: [
{ label: 'Feminino', value: 'feminino' },
{ label: 'Masculino', value: 'masculino' },
{ label: 'Outro', value: 'outro' },
{ label: 'Não binário', value: 'outro' },
{ label: 'Não informado', value: 'nao_informado' },
],
},

View file

@ -109,7 +109,7 @@ export const Supporter: CollectionConfig = {
{ label: 'Certo', value: 'certo' },
{ label: 'Tende a certo', value: 'tende_a_certo' },
{ label: 'Indeciso', value: 'indeciso' },
{ label: 'Outro', value: 'outro' },
{ label: 'Não binário', value: 'outro' },
],
},
{

View file

@ -0,0 +1,32 @@
'use client'
import { PlusIcon } from 'lucide-react'
import { createContactFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
/**
* C139 — the mobile create FAB (below `md`, same corner as the quick-actions
* FAB, which the staff contacts page deliberately does not mount): opens the
* create sheet — the same ficha form, empty, with the state defaulting to BA.
*/
export const ContactCreateFab = () => (
<ContactFormSheet
title="Novo contato"
description="Preencha a ficha da campanha"
trigger={<PlusIcon className="size-6" aria-hidden="true" />}
triggerLabel="Novo contato"
triggerClassName="fixed right-4 bottom-5 z-40 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg md:hidden"
formAction={createContactFormAction}
defaults={{
name: '',
email: '',
phones: [],
gender: '',
state: 'BA',
city: '',
postalCode: '',
}}
successMessage="Contato criado."
/>
)

View file

@ -0,0 +1,221 @@
'use client'
import { useRouter } from 'next/navigation'
import { startTransition, useActionState, useEffect, useRef, useState, type FormEvent } 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
// C139 — manual dispatch (no `action={submitAction}`): React 19 resets
// uncontrolled fields after any settled form action, wiping the typed name
// on a validation/conflict error — the row stays open, so the wipe showed.
// `startTransition` keeps `isPending` correct for the imperative dispatch.
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
startTransition(() => submitAction(new FormData(event.currentTarget)))
}
return (
<form
ref={formRef}
onSubmit={handleSubmit}
data-view="contact-create-row"
aria-busy={isPending}
className="hidden rounded-xl border border-dashed border-primary/40 bg-primary/5 p-3 md:block"
>
<div className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1.5">
<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,221 @@
'use client'
import { startTransition, useState, type FormEvent } from 'react'
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
import { FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { CitiesByState } from '@/lib/cities'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { errorProps } from '@/utilities/campaignFormFields'
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
export type ContactFormDefaults = {
name: string
email: string
phones: string[]
gender: string
state: string
city: string
postalCode: string
}
const stateOptions = Object.keys(CitiesByState)
/**
* C139 — the mobile sheet's ficha form (edit and create share it; the create
* sheet passes empty defaults). Full-bleed fields with dividers; phones use
* `PhonesFieldEditor` in FORM mode (no `saveAction`), so the repeated `phones`
* inputs are collected by the enclosing form. Field errors come from the
* ladder's `fieldErrors` (name/email/phones/gender/state/city/postalCode);
* top-level ladder messages (name conflict, out of scope) render as a banner.
*
* The fields are CONTROLLED and the submit goes through a manual `onSubmit`
* dispatch instead of `<form action={submitAction}>`: React 19 resets
* uncontrolled form fields after any settled form action, wiping typed values
* on validation/conflict errors (the sheet stays open, so the wipe is visible).
* `preventDefault` keeps the native submit event away from that plumbing.
*/
export const ContactFormFields = ({
formId,
state,
sessionId,
submitAction,
defaults,
contactId,
}: {
formId: string
state: CampaignFormActionState
/**
* Bumps on every sheet open; the fields drop the state from any PREVIOUS
* session with it (the sheet wrapper stays mounted, so `useActionState`
* state would otherwise leak a stale conflict banner into a fresh open).
*/
sessionId: number
submitAction: (formData: FormData) => void
defaults: ContactFormDefaults
/** Edit mode: the record being updated (submitted as the `id` field). */
contactId?: string
}) => {
const [lastSessionId, setLastSessionId] = useState(sessionId)
if (lastSessionId !== sessionId) setLastSessionId(sessionId)
const visibleState = lastSessionId === sessionId ? state : {}
const [name, setName] = useState(defaults.name)
const [email, setEmail] = useState(defaults.email)
const [gender, setGender] = useState(defaults.gender)
const [stateValue, setStateValue] = useState(defaults.state)
const [city, setCity] = useState(defaults.city)
const [postalCode, setPostalCode] = useState(defaults.postalCode)
const nameProps = errorProps(visibleState.fieldErrors, 'name', formId)
const emailProps = errorProps(visibleState.fieldErrors, 'email', formId)
const cityProps = errorProps(visibleState.fieldErrors, 'city', formId)
const postalCodeProps = errorProps(visibleState.fieldErrors, 'postalCode', formId)
const stateProps = errorProps(visibleState.fieldErrors, 'state', formId)
const genderProps = errorProps(visibleState.fieldErrors, 'gender', formId)
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// `startTransition` keeps `isPending` correct for the imperative dispatch
// (React warns otherwise) without re-entering the `<form action>` plumbing.
startTransition(() => submitAction(new FormData(event.currentTarget)))
}
return (
<form id={formId} onSubmit={handleSubmit} className="flex flex-col divide-y">
{contactId !== undefined ? <input type="hidden" name="id" value={contactId} /> : null}
{visibleState.fieldErrors?.id ? (
<FieldError id={`${formId}-id-error`}>{visibleState.fieldErrors.id[0]}</FieldError>
) : null}
{visibleState.message ? (
<p role="alert" className="py-3 text-sm font-medium text-destructive">
{visibleState.message}
</p>
) : null}
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-name`}>Nome *</FieldLabel>
<Input
id={`${formId}-name`}
name="name"
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Nome e sobrenome"
aria-invalid={nameProps.invalid}
aria-describedby={nameProps.describedBy}
/>
{nameProps.error ? (
<FieldError id={nameProps.describedBy}>{nameProps.error}</FieldError>
) : null}
</div>
<div className="py-3">
<PhonesFieldEditor
defaultValues={defaults.phones}
error={visibleState.fieldErrors?.phones}
label="Telefones"
/>
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-email`}>E-mail</FieldLabel>
<Input
id={`${formId}-email`}
name="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="nome@email.com"
aria-invalid={emailProps.invalid}
aria-describedby={emailProps.describedBy}
/>
{emailProps.error ? (
<FieldError id={emailProps.describedBy}>{emailProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-gender`}>Gênero</FieldLabel>
<select
id={`${formId}-gender`}
name="gender"
value={gender}
onChange={(event) => setGender(event.target.value)}
aria-invalid={genderProps.invalid}
aria-describedby={genderProps.describedBy}
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
<option value="">—</option>
{(Object.keys(contactGenderLabels) as (keyof typeof contactGenderLabels)[]).map(
(gender) => (
<option key={gender} value={gender}>
{contactGenderLabels[gender]}
</option>
),
)}
</select>
{genderProps.error ? (
<FieldError id={genderProps.describedBy}>{genderProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-city`}>Cidade</FieldLabel>
<Input
id={`${formId}-city`}
name="city"
value={city}
onChange={(event) => setCity(event.target.value)}
placeholder="Cidade"
aria-invalid={cityProps.invalid}
aria-describedby={cityProps.describedBy}
/>
{cityProps.error ? (
<FieldError id={cityProps.describedBy}>{cityProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-state`}>Estado *</FieldLabel>
<select
id={`${formId}-state`}
name="state"
required
value={stateValue}
onChange={(event) => setStateValue(event.target.value)}
aria-invalid={stateProps.invalid}
aria-describedby={stateProps.describedBy}
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
{stateOptions.map((stateKey) => (
<option key={stateKey} value={stateKey}>
{stateKey}
</option>
))}
</select>
{stateProps.error ? (
<FieldError id={stateProps.describedBy}>{stateProps.error}</FieldError>
) : null}
</div>
<div className="flex flex-col gap-1.5 py-3">
<FieldLabel htmlFor={`${formId}-postal-code`}>CEP</FieldLabel>
<Input
id={`${formId}-postal-code`}
name="postalCode"
inputMode="numeric"
maxLength={8}
value={postalCode}
onChange={(event) => setPostalCode(event.target.value)}
placeholder="00000000"
aria-invalid={postalCodeProps.invalid}
aria-describedby={postalCodeProps.describedBy}
/>
{postalCodeProps.error ? (
<FieldError id={postalCodeProps.describedBy}>{postalCodeProps.error}</FieldError>
) : null}
</div>
</form>
)
}

View file

@ -0,0 +1,107 @@
'use client'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect, useId, useState, type ReactNode } from 'react'
import { toast } from 'sonner'
import {
ContactFormFields,
type ContactFormDefaults,
} from '@/components/campaign/contacts/ContactFormFields'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Button } from '@/components/ui/button'
import { DrawerCloseButton } from '@/components/ui/Drawer'
import { Spinner } from '@/components/ui/Spinner'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/**
* C139 — the mobile ficha sheet (edit card / create FAB): the shared
* `CampaignListSheetProvider` drawer via `CampaignCellEditOverlay`, the whole
* ficha as ONE form (plan decision F) and the custom footer submit. Success
* toasts, closes and refreshes; field errors render inline in the body.
*/
export const ContactFormSheet = ({
title,
description,
trigger,
triggerLabel,
triggerClassName,
formAction,
defaults,
successMessage,
deleteControl,
contactId,
}: {
title: string
description?: string
trigger: ReactNode
triggerLabel: string
triggerClassName?: string
formAction: (
state: CampaignFormActionState,
formData: FormData,
) => Promise<CampaignFormActionState>
defaults: ContactFormDefaults
successMessage: string
/** Rendered under the form, outside it (the destructive dialog, edit only). */
deleteControl?: ReactNode
/** Edit mode: the record being updated (submitted as the `id` field). */
contactId?: string
}) => {
const router = useRouter()
const formId = useId()
const [open, setOpen] = useState(false)
const [openCount, setOpenCount] = useState(0)
const [formActionState, submitAction, isPending] = useActionState<
CampaignFormActionState,
FormData
>(formAction, {})
// Bump on every open so the fields can drop a PREVIOUS session's action
// state (the sheet wrapper stays mounted; useActionState does not reset).
useEffect(() => {
if (open) setOpenCount((count) => count + 1)
}, [open])
useEffect(() => {
if (formActionState.status !== 'success') return
toast.success(formActionState.message ?? successMessage)
setOpen(false)
router.refresh()
}, [formActionState, router, successMessage])
const footer = (
<div className="flex w-full flex-col gap-2">
<Button form={formId} type="submit" disabled={isPending} className="min-h-11 w-full">
{isPending ? <Spinner data-icon="inline-start" /> : null}
Salvar
</Button>
<DrawerCloseButton disabled={isPending}>Cancelar</DrawerCloseButton>
</div>
)
return (
<CampaignCellEditOverlay
variant="sheet"
open={open}
onOpenChange={setOpen}
title={title}
description={description}
trigger={trigger}
triggerLabel={triggerLabel}
triggerClassName={triggerClassName}
footer={footer}
sheetBodyClassName="gap-4"
>
<ContactFormFields
formId={formId}
state={formActionState}
sessionId={openCount}
submitAction={submitAction}
defaults={defaults}
contactId={contactId}
/>
{deleteControl}
</CampaignCellEditOverlay>
)
}

View file

@ -0,0 +1,113 @@
'use client'
import { MailIcon, MessageCircleIcon, PencilIcon } from 'lucide-react'
import { updateContactFullFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
const editTriggerClassName = 'size-10 rounded-full'
/**
* C139 — the mobile ficha card (below `md`): name + secondary line (primary
* phone ?? e-mail), gender chip + "Cidade · Estado", and the actions row
* (WhatsApp / e-mail / edit / delete). Editing opens the shared ficha sheet.
*/
export const ContactMobileCard = ({
row,
canDelete,
}: {
row: ContactRowViewModel
canDelete: boolean
}) => {
const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null)
const secondary =
row.phones.length > 0
? formatBrazilianPhoneInput(row.phones[0]!)
: (row.email ?? 'Sem contato registrado')
const place = [row.city, row.state].filter(Boolean).join(' · ')
return (
<li className="flex flex-col gap-2 py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : null}
{row.email ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a href={`mailto:${row.email}`} aria-label={`Enviar e-mail para ${row.name}`}>
<MailIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : null}
{canDelete ? (
<DeletePersonButton
personName={row.name}
contactId={row.contactID}
vocabulary="contato"
/>
) : null}
<ContactFormSheet
title="Editar contato"
description={row.name}
trigger={<PencilIcon className="size-4" aria-hidden="true" />}
triggerLabel={`Editar contato ${row.name}`}
triggerClassName={editTriggerClassName}
formAction={updateContactFullFormAction}
contactId={String(row.contactID)}
defaults={{
name: row.name,
email: row.email ?? '',
phones: row.phones,
gender: row.gender ?? '',
state: row.state ?? 'BA',
city: row.city ?? '',
postalCode: row.postalCode ?? '',
}}
successMessage="Salvo."
deleteControl={
canDelete ? (
<div className="flex justify-center border-t pt-4">
<DeletePersonButton
personName={row.name}
contactId={row.contactID}
vocabulary="contato"
/>
</div>
) : null
}
/>
</div>
</div>
{row.gender || place ? (
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
{row.gender ? (
<Badge variant="outline" className="font-normal">
{contactGenderLabels[row.gender]}
</Badge>
) : null}
{place ? <span>{place}</span> : null}
</div>
) : null}
</li>
)
}

View file

@ -0,0 +1,24 @@
'use client'
import type { ReactNode } from 'react'
import { ContactMobileCard } from '@/components/campaign/contacts/ContactMobileCard'
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
/** C139 — the mobile ficha list (below `md`), replacing the interim one-liner. */
export const ContactMobileList = ({
rows,
canDelete,
empty,
}: {
rows: readonly ContactRowViewModel[]
canDelete: boolean
empty: ReactNode
}) => (
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
{rows.length === 0 ? <li className="py-4">{empty}</li> : null}
{rows.map((row) => (
<ContactMobileCard key={row.contactID} row={row} canDelete={canDelete} />
))}
</ul>
)

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

@ -3,6 +3,7 @@ import {
BookOpenIcon,
CalendarDaysIcon,
ContactIcon,
ContactRoundIcon,
EllipsisVerticalIcon,
HandshakeIcon,
HomeIcon,
@ -19,6 +20,7 @@ import {
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_UPDATES_HREF,
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
@ -54,6 +56,7 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Organizações', href: '/campanha/organizacoes', icon: LandmarkIcon },
{ title: 'Dobradinhas', href: '/campanha/dobradinhas', icon: Users2Icon },
{ title: 'Pessoas', href: PEOPLE_NAV_HREF, icon: ContactIcon },
{ title: 'Contatos', href: CAMPAIGN_CONTACTS_HOME, icon: ContactRoundIcon },
{ title: 'Agenda', href: CAMPAIGN_AGENDA_HOME, icon: CalendarDaysIcon },
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },

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

@ -1,7 +1,12 @@
import type { LucideIcon } from 'lucide-react'
import type { Metadata } from 'next'
import { CAMPAIGN_HOME, CAMPAIGN_UPDATES_HREF, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import {
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_HOME,
CAMPAIGN_UPDATES_HREF,
LEADER_CONTACTS_HOME,
} from '@/lib/campaignPaths'
import { normalizePathname } from '@/lib/campaignQuickActionPaths'
import type { CampaignUser } from '@/payload-types'
@ -98,6 +103,10 @@ export const campaignPageChromeCatalog = {
},
contatos: {
title: 'Contatos',
subtitle: 'Fichas da campanha — atualize dados e fale com as pessoas.',
},
meusContatos: {
title: 'Meus contatos',
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
},
perfil: {
@ -251,9 +260,13 @@ const pathRules: PathRule[] = [
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atualizacoes),
},
{
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
match: (pathname) => pathname === CAMPAIGN_CONTACTS_HOME,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.contatos),
},
{
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.meusContatos),
},
{
match: (pathname) => pathname === '/campanha/perfil',
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.perfil),

View file

@ -11,7 +11,12 @@ export const CAMPAIGN_SUPPORTERS_HOME = '/campanha/apoiadores' as const
export const CAMPAIGN_AGENDA_HOME = '/campanha/agenda' as const
export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const
/** Staff page for the `Contact` ficha (C139). */
export const CAMPAIGN_CONTACTS_HOME = '/campanha/contatos' as const
/** Leader's own supporter contact tool (B43) — moved here by C139 so staff
* and leader no longer share the `/campanha/contatos` route. */
export const LEADER_CONTACTS_HOME = '/campanha/meus-contatos' as const
export const CAMPAIGN_PROFILE_HOME = '/campanha/perfil' as const

View file

@ -1,6 +1,6 @@
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
import { isAdvisorsPath } from '@/lib/campaignAdvisorQuickActions'
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { CAMPAIGN_CONTACTS_HOME, CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { isActivityTourComposerPath, isListPath } from '@/lib/campaignQuickActionPaths'
import {
isStaffCampaignRole,
@ -17,6 +17,11 @@ export const isCampaignActionsPath = (pathname: string): boolean =>
export const isLeaderContactsPath = (pathname: string): boolean =>
pathname === LEADER_CONTACTS_HOME || pathname.startsWith(`${LEADER_CONTACTS_HOME}/`)
/** C139 — the staff contacts page owns a create FAB of its own, so the
* quick-actions drawer stays out of it. */
export const isContactsPath = (pathname: string): boolean =>
pathname === CAMPAIGN_CONTACTS_HOME || pathname.startsWith(`${CAMPAIGN_CONTACTS_HOME}/`)
/**
* Whether the quick-actions FAB should mount on this navigation (all viewports).
*/
@ -26,6 +31,7 @@ export const shouldMountQuickActionsFab = (pathname: string, role: CampaignRole)
// B84: E13 compositor already exposes primary CTAs — skip competing drawer chrome.
if (isActivityTourComposerPath(pathname)) return false
if (role === 'leader') return isLeaderContactsPath(pathname)
if (isContactsPath(pathname)) return false
if (isAdvisorsPath(pathname)) return isUnrestrictedCampaignRole(role)
return isStaffCampaignRole(role)
}

View file

@ -7,7 +7,7 @@ import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContex
import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes'
import type { CampaignRole } from '@/lib/campaignRoles'
/** In-page anchors on `/campanha/contatos` — hash navigation from the B89 drawer. */
/** In-page anchors on `/campanha/meus-contatos` — hash navigation from the B89 drawer. */
export const LEADER_CONTACT_FORM_HASH = '#leader-contact-form' as const
export const LEADER_CONTACTS_LIST_HASH = '#leader-contacts-list' as const

View file

@ -1,5 +1,6 @@
import { CitiesByState } from '@/lib/cities'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { personGenders } from '@/lib/schemas/personGenders'
import { z } from 'zod'
import {
@ -10,34 +11,25 @@ import {
export type StateKey = keyof typeof CitiesByState
export const contactSchema = z.object({
name: z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
),
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.transform((v) => (v === '' ? undefined : v))
.optional(),
})
/** Gender enum shared with the leadership collection (same values, C139). */
const contactGenders = personGenders
const contactNameSchema = z
.string()
.trim()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(120, 'Nome deve ter no máximo 100 caracteres')
.regex(
/^(?=.* )[\p{L}\p{M}]+(?:[- ][\p{L}\p{M}]+)*$/u,
'Informe nome e sobrenome. Use apenas letras, no máximo um espaço ou hífen entre termos, e sem espaço no início ou no fim.',
)
const contactPostalCodeSchema = z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.transform((v) => (v === '' ? undefined : v))
.optional()
const contactPhoneInput = z.union([brazilianMobile, z.literal(''), z.null()]).optional()
@ -59,6 +51,55 @@ export const contactPhonesSchema = z.array(brazilianMobile).superRefine((phones,
}
})
export const contactSchema = z.object({
name: contactNameSchema,
email: z.email('Email inválido'),
phone: z
.string()
.trim()
.length(11, 'Telefone celular inválido')
.regex(/^\d{11}$/, 'Telefone celular inválido'),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
postalCode: contactPostalCodeSchema,
})
/**
* C139 — the create form of the contacts page: the ficha fields a staff actor
* fills in one shot (name required and unique — the invariant runs in the
* action under an advisory lock; phones follow the C112 shape).
*/
export const contactCreateSchema = z.object({
name: contactNameSchema,
email: optionalPersistedEmail,
// Optional like the collection: name-only fichas exist (C111); the form
// sends [] when the actor typed no phone.
phones: contactPhonesSchema.optional(),
gender: z.enum(contactGenders).optional(),
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa').optional(),
postalCode: z
.string()
.trim()
.regex(/^(?:\d{8})?$/, 'CEP inválido')
.optional(),
})
export type ContactCreateInput = z.input<typeof contactCreateSchema>
export const CONTACT_NAME_CONFLICT_MESSAGE =
'Já existe um contato com este nome — confira a lista antes de salvar.'
export const CONTACT_CELL_STAFF_MESSAGE = 'Apenas a equipe pode editar fichas.'
export const CONTACT_CELL_NOT_IN_SCOPE_MESSAGE = 'Esta ficha não está no seu escopo.'
export const CONTACT_CREATE_STAFF_MESSAGE = 'Apenas a equipe pode criar contatos.'
export const CONTACT_CREATED_MESSAGE = 'Contato criado.'
/** Per-field Contact write shared by campaign person joins. */
export const contactFieldUpdateSchema = z.discriminatedUnion('field', [
z.object({
@ -81,6 +122,70 @@ 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).
z.object({
id: positiveRelationshipId,
field: z.literal('city'),
city: z.string().trim().min(3, 'Cidade inválida').max(100, 'Cidade muito longa'),
}),
z.object({
id: positiveRelationshipId,
field: z.literal('postalCode'),
postalCode: contactPostalCodeSchema,
}),
])
export type ContactFieldUpdateInput = z.input<typeof contactFieldUpdateSchema>
const contactGenderFieldSchema = z
.union([z.enum(contactGenders), z.literal('')])
.transform((value) => (value === '' ? undefined : value))
.optional()
const contactCityFieldSchema = z
.string()
.trim()
.transform((value) => (value === '' ? undefined : value))
.refine((value) => value === undefined || (value.length >= 3 && value.length <= 100), {
message: 'Cidade inválida',
})
.optional()
/**
* C139 — the mobile edit sheet is a SINGLE atomic write (plan decision F):
* the whole ficha in one schema, with the same gate/scope/name invariant as
* the per-field ladder; an empty email/CEP/city clears the column (null),
* an empty gender clears the enum.
*/
export const contactFullUpdateSchema = z.object({
id: positiveRelationshipId,
name: contactNameSchema,
email: optionalPersistedEmail,
phones: contactPhonesSchema.optional(),
gender: contactGenderFieldSchema,
state: z.custom<StateKey>(
(value) => typeof value === 'string' && value in CitiesByState,
'Estado inválido',
),
city: contactCityFieldSchema,
postalCode: contactPostalCodeSchema,
})
export type ContactFullUpdateInput = z.input<typeof contactFullUpdateSchema>

View file

@ -1,6 +1,7 @@
import { z } from 'zod'
import { contactFieldUpdateSchema, contactPhonesSchema } from '@/lib/schemas/contact'
import { personGenders } from '@/lib/schemas/personGenders'
import {
optionalPersistedEmail,
positiveRelationshipId,
@ -8,7 +9,7 @@ import {
trimmedOptionalText,
} from '@/lib/schemas/primitives'
export const leadershipGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const
export const leadershipGenders = personGenders
export const leadershipSupportStatuses = [
'engajado',

View file

@ -0,0 +1,7 @@
/**
* The gender enum shared by every person-ish collection (contact, leadership,
* dobradinha, supporter). Leaf module on purpose: `contact.ts` and
* `leadership.ts` schemas import it from here — a re-export through either
* would be a module cycle.
*/
export const personGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const

View file

@ -22,9 +22,9 @@ export const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro'
* `return null` — a blank screen — instead of redirecting to login). Gates:
*
* - (none) → any authenticated campaign user;
* - 'staff' → non-staff (leader) goes to `/campanha/contatos` (B43);
* - 'staff' → non-staff (leader) goes to `/campanha/meus-contatos` (B43, C139);
* - 'unrestricted' → non-coordinator/candidate goes to `/campanha`;
* - 'noLeader' → leader goes to `/campanha/contatos` (B43).
* - 'noLeader' → leader goes to `/campanha/meus-contatos` (B43, C139).
*
* A custom `redirectTo` overrides the gate's default target. The convention
* guard in `codebaseConventions.unit.spec.ts` fails the build on a

View file

@ -0,0 +1,312 @@
import 'server-only'
import type { Payload } from 'payload'
import { relationshipId } from '@/lib/relationship'
import { leadershipGenders } from '@/lib/schemas/leadership'
import type { CampaignUser, Contact, Leadership, StateDeputy } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
import {
buildContactListWhere,
contactPageSize,
resolveContactListSort,
type ContactGender,
type ContactListState,
type ContactStateKey,
type ContactVinculo,
} from '@/utilities/contacts/contactListUrl'
/**
* The contacts page data (C139): ONE source — the `Contact` ficha itself.
* Unlike people, there is no merge: the list IS the ficha, and the actor
* scope is enforced by the `contact` collection's own `canReadContacts`
* access (read with `user` threaded + `overrideAccess: false`).
*
* Vínculos (liderança, dobradinha, assessores, equipe) enter ONLY as facets:
* three small queries resolve which readable contacts carry which link, so
* the omnibox can filter "fichas com liderança" etc. The staff source
* (`campaignUser`) reads with a justified `overrideAccess` — staff identity
* is gated by a different collection rule — and the merge never widens the
* surface: a contact id outside the readable set is simply not a row here.
*/
type ContactVinculosByID = ReadonlyMap<number, ContactVinculo[]>
export type ContactRowViewModel = {
contactID: number
name: string
email: string | null
/** Stored order = priority (first is primary) — C112 contract. */
phones: string[]
gender: ContactGender | null
state: ContactStateKey | null
city: string | null
postalCode: string | null
/** Which vínculos this ficha carries (facet + filter, never a column). */
vinculos: ContactVinculo[]
}
export type ContactListFilterFacets = {
genders: ContactGender[]
states: ContactStateKey[]
cities: string[]
vinculos: ContactVinculo[]
}
const EMPTY_FACETS: ContactListFilterFacets = { genders: [], states: [], cities: [], vinculos: [] }
const isContactGender = (value: unknown): value is ContactGender =>
typeof value === 'string' && leadershipGenders.some((gender) => gender === value)
const isStateKey = (value: unknown): value is ContactStateKey =>
typeof value === 'string' && /^[A-Z]{2}$/.test(value)
export const toContactRowViewModel = (doc: Contact): ContactRowViewModel => ({
contactID: doc.id,
name: doc.name,
email: doc.email ?? null,
phones: Array.isArray(doc.phones)
? doc.phones.map((entry) => entry.value).filter((value): value is string => !!value)
: [],
gender: isContactGender(doc.gender) ? doc.gender : null,
state: isStateKey(doc.state) ? doc.state : null,
city: doc.city ?? null,
postalCode: doc.postalCode ?? null,
vinculos: [],
})
/**
* Resolves the vínculo map for the scoped contact set. Each source reads with
* its own collection access (`user` threaded); the campaignUser source needs
* the justified admin bypass (staff identity lives behind
* `canReadCampaignUserIdentity`, same precedent as the people staff source) —
* the result is intersected with the readable contact ids, so a link never
* widens the list.
*/
const loadContactVinculos = async (
payload: Payload,
user: CampaignUser,
readableContactIDs: ReadonlySet<number>,
): Promise<ContactVinculosByID> => {
const byID = new Map<number, Set<ContactVinculo>>()
const add = (contactID: number, vinculo: ContactVinculo) => {
if (!readableContactIDs.has(contactID)) return
let set = byID.get(contactID)
if (!set) {
set = new Set()
byID.set(contactID, set)
}
set.add(vinculo)
}
const [leadershipResult, deputyResult, staffResult] = await Promise.all([
payload.find({
collection: 'leadership',
depth: 0,
limit: 0,
pagination: false,
select: { contact: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'stateDeputy',
depth: 0,
limit: 0,
pagination: false,
select: { contact: true },
user,
overrideAccess: false,
}),
payload.find({
collection: 'campaignUser',
depth: 0,
limit: 0,
pagination: false,
select: { contact: true },
// Intentional bypass (impl plan D): the `contact` field is
// identity-gated and the actor scope is enforced by the readable-set
// intersection below — same rationale as the people staff source.
overrideAccess: true,
}),
])
for (const doc of leadershipResult.docs as Leadership[]) {
const id = relationshipId(doc.contact)
if (id !== null) add(id, 'liderancas')
}
for (const doc of deputyResult.docs as StateDeputy[]) {
const id = relationshipId(doc.contact)
if (id !== null) add(id, 'dobradinhas')
}
for (const doc of staffResult.docs as CampaignUser[]) {
const id = relationshipId(doc.contact)
if (id !== null) add(id, 'equipe')
}
const result = new Map<number, ContactVinculo[]>()
for (const [id, set] of byID) {
result.set(
id,
[...set].sort((left, right) => left.localeCompare(right, 'pt-BR')),
)
}
return result
}
const vinculoSet = (row: ContactRowViewModel): Set<ContactVinculo> => new Set(row.vinculos)
export const filterContactRows = (
rows: readonly ContactRowViewModel[],
state: ContactListState,
): ContactRowViewModel[] =>
rows.filter((row) => {
if (state.genders?.length) {
if (row.gender === null || !state.genders.includes(row.gender)) return false
}
if (state.states?.length) {
if (row.state === null || !state.states.includes(row.state)) return false
}
if (state.cities?.length) {
const wanted = new Set(state.cities.map((city) => city.toLowerCase()))
if (row.city === null || !wanted.has(row.city.toLowerCase())) return false
}
if (state.ausencias?.length) {
const wanted = new Set(state.ausencias)
const semTelefone = row.phones.length === 0
const semEmail = row.email === null
const matches =
(wanted.has('sem_telefone') && semTelefone) || (wanted.has('sem_email') && semEmail)
if (!matches) return false
}
if (state.vinculos?.length) {
const links = vinculoSet(row)
if (!state.vinculos.some((vinculo) => links.has(vinculo))) return false
}
return true
})
export const contactFilterFacetsFromRows = (
rows: readonly ContactRowViewModel[],
state: ContactListState,
): ContactListFilterFacets => {
const genders = new Set<ContactGender>(state.genders ?? [])
const states = new Set<ContactStateKey>(state.states ?? [])
// City is free text: dedupe case-insensitively but keep the first-seen
// casing for display (data-driven facet, municipality precedent).
const cities = new Map<string, string>()
for (const city of state.cities ?? []) {
cities.set(city.toLowerCase(), city)
}
const vinculos = new Set<ContactVinculo>(state.vinculos ?? [])
for (const row of rows) {
if (row.gender !== null) genders.add(row.gender)
if (row.state !== null) states.add(row.state)
if (row.city !== null && !cities.has(row.city.toLowerCase())) {
cities.set(row.city.toLowerCase(), row.city)
}
for (const vinculo of row.vinculos) vinculos.add(vinculo)
}
return {
genders: [...genders].sort((left, right) => left.localeCompare(right, 'pt-BR')),
states: [...states].sort((left, right) => left.localeCompare(right, 'pt-BR')),
cities: [...cities.values()].sort((left, right) => left.localeCompare(right, 'pt-BR')),
vinculos: [...vinculos].sort((left, right) => left.localeCompare(right, 'pt-BR')),
}
}
/**
* C139 — global sort over the FILTERED set (never the page), applied before
* pagination. Text columns by value; nulls ("Sem…") always land last (B15
* precedent), ties break by name, then contact id — the same order the list
* opened with.
*/
export const sortContactRows = (
rows: readonly ContactRowViewModel[],
sort: ContactListState['sort'],
dir: ContactListState['dir'],
): ContactRowViewModel[] => {
const direction = dir === 'asc' ? 1 : -1
const sortValue = (row: ContactRowViewModel): string | null => {
switch (sort) {
case 'cidade':
return row.city
case 'estado':
return row.state
case 'email':
return row.email
case 'name':
default:
return row.name
}
}
const byNameThenId = (left: ContactRowViewModel, right: ContactRowViewModel): number =>
left.name.localeCompare(right.name, 'pt-BR') || left.contactID - right.contactID
return [...rows].sort((left, right) => {
const leftValue = sortValue(left)
const rightValue = sortValue(right)
if (leftValue === null && rightValue === null) return byNameThenId(left, right)
if (leftValue === null) return 1
if (rightValue === null) return -1
return leftValue.localeCompare(rightValue, 'pt-BR') * direction || byNameThenId(left, right)
})
}
/**
* The page data for `/campanha/contatos`. `q` runs at the source level; the
* property/vínculo filters, the facets and the pagination run in memory over
* the scoped rows (one source — no merge cost).
*/
export const loadContactListPageData = async (
payload: Payload,
user: CampaignUser,
state: ContactListState,
): Promise<{
rows: ContactRowViewModel[]
totalDocs: number
totalPages: number
filterFacets: ContactListFilterFacets
}> => {
if (!isCampaignStaff(user)) {
return { rows: [], totalDocs: 0, totalPages: 0, filterFacets: EMPTY_FACETS }
}
const contactResult = await payload.find({
collection: 'contact',
where: buildContactListWhere(state),
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
email: true,
phones: true,
gender: true,
state: true,
city: true,
postalCode: true,
},
user,
overrideAccess: false,
})
const docs = contactResult.docs as Contact[]
const rows = docs.map(toContactRowViewModel)
const readableContactIDs = new Set(rows.map((row) => row.contactID))
const vinculosByID = await loadContactVinculos(payload, user, readableContactIDs)
for (const row of rows) {
row.vinculos = vinculosByID.get(row.contactID) ?? []
}
const filterFacets = contactFilterFacetsFromRows(rows, state)
const filtered = filterContactRows(rows, state)
const { sort, dir } = resolveContactListSort(state)
const sorted = sortContactRows(filtered, sort, dir)
const totalDocs = sorted.length
const totalPages = Math.ceil(totalDocs / contactPageSize)
const pageRows = sorted.slice((state.page - 1) * contactPageSize, state.page * contactPageSize)
return { rows: pageRows, totalDocs, totalPages, filterFacets }
}

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,262 @@
/**
* Contacts list URL contract (C139): state, param parse/canonicalize,
* source-level Payload `where` and hrefs. Own module, same shape as
* `peopleListUrl` / `leadershipListUrl`.
*
* The `where` only covers what a single `contact` query can express (`q`).
* Every other filter (gênero/estado/cidade/ausência/vínculo) is applied in
* memory over the scoped rows (`contactListData.ts`) — reverse relations and
* absence predicates have no SQL shape here.
*/
import type { Where } from 'payload'
import { CitiesByState } from '@/lib/cities'
import { leadershipGenders } from '@/lib/schemas/leadership'
import {
allParamValues,
buildListHref,
createSortToggleHref,
firstValue,
normalizedText,
resolveListUrl,
strictDecimalInteger,
type RawSearchParams,
} from '@/utilities/campaignListUrl'
export const contactPageSize = 25
export type ContactGender = (typeof leadershipGenders)[number]
export type ContactStateKey = keyof typeof CitiesByState
/** Labels mirroring the Contact collection options (Fase 3: 'outro' → 'Não binário'). */
export const contactGenderLabels: Record<ContactGender, string> = {
feminino: 'Feminino',
masculino: 'Masculino',
outro: 'Não binário',
nao_informado: 'Não informado',
}
export const CONTACT_ABSENCES = ['sem_telefone', 'sem_email'] as const
export type ContactAbsence = (typeof CONTACT_ABSENCES)[number]
export const contactAbsenceLabels: Record<ContactAbsence, string> = {
sem_telefone: 'Sem telefone',
sem_email: 'Sem e-mail',
}
export const CONTACT_VINCULOS = ['liderancas', 'dobradinhas', 'assessores', 'equipe'] as const
export type ContactVinculo = (typeof CONTACT_VINCULOS)[number]
export const contactVinculoLabels: Record<ContactVinculo, string> = {
liderancas: 'Lideranças',
dobradinhas: 'Dobradinhas',
assessores: 'Assessores',
equipe: 'Equipe',
}
export type ContactListSortKey = 'name' | 'cidade' | 'estado' | 'email'
export type ContactListSortDirection = 'asc' | 'desc'
export const contactListSortLabels: Record<ContactListSortKey, string> = {
name: 'Nome',
cidade: 'Cidade',
estado: 'Estado',
email: 'E-mail',
}
export type ContactListState = {
page: number
q?: string
genders?: ContactGender[]
states?: ContactStateKey[]
cities?: string[]
ausencias?: ContactAbsence[]
vinculos?: ContactVinculo[]
sort?: ContactListSortKey
dir?: ContactListSortDirection
}
export type ContactListSearchParams = RawSearchParams
const contactListParamNames = [
'q',
'gender',
'state',
'city',
'ausencia',
'vinculo',
'sort',
'dir',
'page',
] as const
const contactListParamNameSet = new Set<string>(contactListParamNames)
const contactGenderSet = new Set<string>(leadershipGenders)
const contactStateSet = new Set<string>(Object.keys(CitiesByState))
const contactAbsenceSet = new Set<string>(CONTACT_ABSENCES)
const contactVinculoSet = new Set<string>(CONTACT_VINCULOS)
const contactListSortKeySet = new Set<string>(Object.keys(contactListSortLabels))
const contactListSortDirSet = new Set<ContactListSortDirection>(['asc', 'desc'])
export const defaultContactListSortDir = (_key: ContactListSortKey): ContactListSortDirection =>
'asc'
export const resolveContactListSort = (
state: ContactListState,
): { sort: ContactListSortKey; dir: ContactListSortDirection } => {
const sort = state.sort ?? 'name'
return { sort, dir: state.dir ?? defaultContactListSortDir(sort) }
}
export const isDefaultContactListSort = (state: ContactListState): boolean => {
const { sort, dir } = resolveContactListSort(state)
return sort === 'name' && dir === defaultContactListSortDir('name')
}
/**
* C139 — absence and vínculo facets do NOT use `parseExhaustiveEnumParam`:
* selecting every member is NOT the same filter as selecting none (the
* absence "Sem telefone"+"Sem e-mail" still excludes complete fichas; the
* vínculo "any of them" still excludes fichas without a link) — same
* rational as the C125 umbrella.
*/
const parseContactAbsenceParam = (raw: string | string[] | undefined): ContactAbsence[] =>
allParamValues(raw).filter((token): token is ContactAbsence => contactAbsenceSet.has(token))
const parseContactVinculoParam = (raw: string | string[] | undefined): ContactVinculo[] =>
allParamValues(raw).filter((token): token is ContactVinculo => contactVinculoSet.has(token))
export const parseContactListParams = (params: ContactListSearchParams): ContactListState => {
const rawPage = strictDecimalInteger(firstValue(params.page))
const q = normalizedText(firstValue(params.q))
const genders = allParamValues(params.gender).filter((token): token is ContactGender =>
contactGenderSet.has(token),
)
const states = allParamValues(params.state).filter((token): token is ContactStateKey =>
contactStateSet.has(token),
)
const cities = allParamValues(params.city).filter((token) => token.length <= 100)
const ausencias = parseContactAbsenceParam(params.ausencia)
const vinculos = parseContactVinculoParam(params.vinculo)
const rawSort = firstValue(params.sort)
const sort =
rawSort && contactListSortKeySet.has(rawSort) ? (rawSort as ContactListSortKey) : undefined
const rawDir = firstValue(params.dir)
const dir =
rawDir && contactListSortDirSet.has(rawDir as ContactListSortDirection)
? (rawDir as ContactListSortDirection)
: undefined
return {
page: rawPage ?? 1,
...(q ? { q } : {}),
...(genders.length ? { genders } : {}),
...(states.length ? { states } : {}),
...(cities.length ? { cities } : {}),
...(ausencias.length ? { ausencias } : {}),
...(vinculos.length ? { vinculos } : {}),
...(sort ? { sort } : {}),
...(dir ? { dir } : {}),
}
}
export const contactListStateToRawParams = (
state: ContactListState,
page = state.page,
): ContactListSearchParams => ({
page: String(page),
q: state.q,
gender: state.genders,
state: state.states,
city: state.cities,
ausencia: state.ausencias,
vinculo: state.vinculos,
sort: state.sort,
dir: state.dir,
})
/** Expects already-canonical state (from parse or a rule-preserving toggle). */
export const serializeCanonicalContactListSearchParams = (
canonicalState: ContactListState,
): URLSearchParams => {
const params = new URLSearchParams()
if (canonicalState.q) params.set('q', canonicalState.q)
for (const gender of canonicalState.genders ?? []) params.append('gender', gender)
for (const state of canonicalState.states ?? []) params.append('state', state)
for (const city of canonicalState.cities ?? []) params.append('city', city)
for (const ausencia of canonicalState.ausencias ?? []) params.append('ausencia', ausencia)
for (const vinculo of canonicalState.vinculos ?? []) params.append('vinculo', vinculo)
if (!isDefaultContactListSort(canonicalState)) {
const { sort, dir } = resolveContactListSort(canonicalState)
params.set('sort', sort)
if (dir !== defaultContactListSortDir(sort)) params.set('dir', dir)
}
if (canonicalState.page > 1) params.set('page', String(canonicalState.page))
return params
}
const buildContactListSearchParams = (
state: ContactListState,
page = state.page,
): URLSearchParams =>
serializeCanonicalContactListSearchParams(
parseContactListParams(contactListStateToRawParams(state, page)),
)
export const buildContactListHref = (state: ContactListState, page: number): string =>
buildListHref(state, buildContactListSearchParams, '/campanha/contatos', page)
export const buildContactSortHref = createSortToggleHref<ContactListState, ContactListSortKey>({
resolveCurrentSort: resolveContactListSort,
defaultDir: defaultContactListSortDir,
buildHref: (state) => buildContactListHref(state, 1),
})
const contactSortOptionLabel = (key: ContactListSortKey, dir: ContactListSortDirection): string =>
`${contactListSortLabels[key]} (${dir === 'asc' ? 'A–Z' : 'Z–A'})`
export const contactListSortOptions = (
Object.keys(contactListSortLabels) as ContactListSortKey[]
).flatMap((key) =>
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: contactSortOptionLabel(key, dir) })),
)
/** One option per key, in the key's default direction (omnibox cap precedent, C125). */
export const contactListSortPrimaryOptions = contactListSortOptions.filter(
({ key, dir }) => dir === defaultContactListSortDir(key),
)
export const resolveContactListUrl = (
params: ContactListSearchParams,
totalPages?: number,
): {
state: ContactListState
href: string
redirectHref?: string
} =>
resolveListUrl({
params,
paramNameSet: contactListParamNameSet,
parse: parseContactListParams,
buildSearchParams: buildContactListSearchParams,
basePath: '/campanha/contatos',
totalPages,
})
/**
* Source-level `where`: `q` matches name, e-mail or ANY stored phone. The
* `phones.value` array subfield and the `like` operator (ILIKE substring,
* case-insensitive) were verified against the local Postgres DB during the
* C139 tracer phase (2026-08-13).
*/
export const buildContactListWhere = (state: ContactListState): Where => {
if (!state.q) return {}
return {
or: [
{ name: { like: state.q } },
{ email: { like: state.q } },
{ 'phones.value': { like: state.q } },
],
}
}

View file

@ -0,0 +1,55 @@
import 'server-only'
import type { Payload, PayloadRequest } from 'payload'
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
type ContactNameRequest = Pick<PayloadRequest, 'transactionID'>
/**
* C139 — the Contact ficha is the "person" record and the contacts page lets
* staff edit names in place, so the name becomes a first-class lookup key:
* normalize (trim + collapse whitespace + lowercase), compare exactly on the
* normalized form. The DB query is only a case-insensitive substring
* prefilter (the Postgres adapter has no normalized-equality operator); the
* verdict is the in-memory exact check.
*/
export const normalizeContactName = (name: string): string =>
name.trim().replace(/\s+/g, ' ').toLowerCase()
export const assertContactNameAvailable = async (
payload: Pick<Payload, 'db' | 'find'>,
req: ContactNameRequest,
name: string,
contactID?: number,
): Promise<void> => {
const normalized = normalizeContactName(name)
if (req.transactionID !== undefined && req.transactionID !== null) {
await acquireTextAdvisoryLocks(payload, req, [`contact-name:${normalized}`])
}
const candidates = await payload.find({
collection: 'contact',
where: {
and: [
{ name: { like: normalized } },
...(contactID === undefined ? [] : [{ id: { not_equals: contactID } }]),
],
},
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
// Intentional bypass: the invariant must see rows outside the current
// actor scope (same rationale as the stateDeputy name invariant).
overrideAccess: true,
req,
})
const conflict = (candidates.docs as { name: string }[]).some(
(doc) => normalizeContactName(doc.name) === normalized,
)
if (conflict) throw new Error(CONTACT_NAME_CONFLICT_MESSAGE)
}

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

@ -102,7 +102,7 @@ test.describe('Mobile bottom nav (B164)', () => {
test('leader does not see the bottom nav', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha/contatos')
await page.goto('/campanha/meus-contatos')
await expect(page.locator('[aria-label="Navegação principal"]')).toHaveCount(0)
})
@ -121,7 +121,7 @@ test.describe('Mobile bottom nav (B164)', () => {
test('leader keeps the sidebar sheet on mobile (C102)', async ({ campaign, context, page }) => {
await seedCampaignSession(context, campaign.baseURL, leaderToken)
await page.goto('/campanha/contatos')
await page.goto('/campanha/meus-contatos')
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
const trigger = topBar.locator('[data-slot="sidebar-trigger"]')

View file

@ -64,7 +64,7 @@ test.describe('Conceitos de inteligência', () => {
await page.goto('/campanha/conceitos')
await expect(page).toHaveURL(/\/campanha\/contatos\/?$/)
await expect(page).toHaveURL(/\/campanha\/meus-contatos\/?$/)
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toHaveCount(0)
})
})

View file

@ -0,0 +1,291 @@
import {
campaignPageChrome,
expect,
expectPostResponse,
test,
} from './fixtures/campaignE2EFixtures.js'
import type { CampaignE2EOwnership } from './fixtures/campaignE2EFixtures.js'
/**
* C139 — the campaign contacts entity page: staff CRUD on `/campanha/contatos`
* (desktop table + create row, mobile cards + FAB/sheet) and the staff gate.
*
* The UI name schema forbids digits (`contactNameSchema`), so contacts CREATED
* through the forms need a letters-only unique name: the fixture runID is a
* hex UUID, and hex → [a-p] is a bijection that keeps the full 128-bit
* uniqueness (API-created fixture contacts may keep digits).
*/
const lettersOnlyUnique = (runID: string): string =>
runID
.replaceAll('-', '')
.replace(/[0-9a-f]/g, (hex) => String.fromCharCode(97 + parseInt(hex, 16)))
const uiName = (fixtures: CampaignE2EOwnership, prefix: string): string =>
`${prefix} ${lettersOnlyUnique(fixtures.runID)}`
test.describe('Contatos — página da entidade Contact', () => {
test('staff opens the contacts page and the omnibox narrows the recorte', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Contatos'),
})
const phone = fixtures.phone()
const first = await fixtures.payload.create({
collection: 'contact',
data: {
name: `Alpha ${fixtures.value('Busca')}`,
phones: [{ value: phone }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
const second = await fixtures.payload.create({
collection: 'contact',
data: { name: `Beta ${fixtures.value('Busca')}`, state: 'BA', city: 'Salvador' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await expect(page).toHaveURL(/\/campanha\/contatos$/)
await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
const firstRow = page.getByRole('row', { name: new RegExp(first.name as string) })
await expect(firstRow).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(second.name as string) })).toBeVisible()
await expect(page.getByText(/^\d+ contatos?$/)).toBeVisible()
// The row action links to WhatsApp with the primary phone (C139).
await expect(
firstRow.getByRole('link', { name: `Enviar WhatsApp para ${first.name}` }),
).toHaveAttribute('href', `https://wa.me/55${phone}`)
// The omnibox narrows the recorte (canonical URL, chips mounted).
const omnibox = page.getByRole('combobox', { name: 'Filtrar contatos' })
await omnibox.fill(first.name as string)
await omnibox.press('Enter')
await expect(page).toHaveURL(/q=/)
await expect(firstRow).toBeVisible()
await expect(page.getByRole('row', { name: new RegExp(second.name as string) })).toBeHidden()
})
test('the desktop create row keeps typed values on error and lands a valid contact', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const contactName = uiName(fixtures, 'Contato Criado')
const phone = fixtures.phone()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await page.getByRole('button', { name: 'Novo contato' }).click()
const createRow = page.locator('[data-view="contact-create-row"]')
const nameInput = createRow.getByRole('textbox', { name: 'Nome do contato' })
await expect(nameInput).toBeVisible()
// Digits are not allowed by the name schema: the alert shows and the
// typed value survives the failed submit (C139 — no React form reset).
await nameInput.fill('Nome Invalido 123')
await createRow.getByRole('button', { name: 'Salvar' }).click()
await expect(createRow.getByRole('alert')).toContainText(
'Use apenas letras, no máximo um espaço ou hífen entre termos',
)
await expect(nameInput).toHaveValue('Nome Invalido 123')
// Valid create lands the row and the write reaches the database.
await nameInput.fill(contactName)
await createRow.getByRole('textbox', { name: 'Telefone do contato' }).fill(phone)
const persisted = expectPostResponse(page, '/campanha/contatos')
await createRow.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(page.getByRole('row', { name: new RegExp(contactName) })).toBeVisible()
const stored = await fixtures.payload.find({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
pagination: false,
})
expect(stored.docs).toHaveLength(1)
expect(stored.docs[0]!.phones?.[0]?.value).toBe(phone)
// The row was created THROUGH the UI, so the ownership proxy never tracked
// it (and its letters-only name does not carry the runID, so discovery
// cannot find it) — delete it explicitly or every run leaks a contact.
await fixtures.payload.delete({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
})
})
test('a duplicate name shows the safe conflict message in the create row', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const existing = await fixtures.payload.create({
collection: 'contact',
data: { name: uiName(fixtures, 'Conflito Existente'), state: 'BA' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
await page.getByRole('button', { name: 'Novo contato' }).click()
const createRow = page.locator('[data-view="contact-create-row"]')
const nameInput = createRow.getByRole('textbox', { name: 'Nome do contato' })
await nameInput.fill(existing.name as string)
await createRow.getByRole('button', { name: 'Salvar' }).click()
await expect(createRow.getByRole('alert')).toContainText(
'Já existe um contato com este nome — confira a lista antes de salvar.',
)
await expect(nameInput).toHaveValue(existing.name as string)
})
test('leader cannot open the contacts page', async ({ campaign, page }) => {
const { fixtures } = campaign
const phone = fixtures.phone()
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança Contatos Sem Acesso'),
username: phone,
})
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/contatos')
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('mobile: the FAB opens the create sheet and a valid save lands a card', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const contactName = uiName(fixtures, 'Contato Sheet')
const phone = fixtures.phone()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
// The FAB is the mobile-only create entry point (the toolbar row is
// `md:hidden`); it floats above the bottom nav (C139 z-index fix).
await page.getByRole('button', { name: 'Novo contato' }).click()
const dialog = page.getByRole('dialog', { name: 'Novo contato' })
await expect(dialog).toBeVisible()
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(contactName)
await dialog.getByRole('button', { name: 'Adicionar telefone' }).click()
await dialog.getByRole('textbox', { name: 'Celular 1' }).fill(phone)
const persisted = expectPostResponse(page, '/campanha/contatos')
await dialog.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(dialog).toBeHidden()
const card = page.locator('[data-view="mobile-cards"] li').filter({ hasText: contactName })
await expect(card).toBeVisible()
await expect(card).toContainText('BA')
const stored = await fixtures.payload.find({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
pagination: false,
})
expect(stored.docs).toHaveLength(1)
expect(stored.docs[0]!.phones?.[0]?.value).toBe(phone)
await expect(
card.getByRole('link', { name: `Enviar WhatsApp para ${contactName}` }),
).toHaveAttribute('href', `https://wa.me/55${phone}`)
// UI-created (see the desktop create-row test): delete explicitly so the
// ownership discovery (runID in name) is not the only net.
await fixtures.payload.delete({
collection: 'contact',
where: { name: { equals: contactName } },
depth: 0,
})
})
test('mobile: the edit sheet keeps values on conflict and saves the ficha atomically', async ({
campaign,
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 })
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const targetName = uiName(fixtures, 'Ficha Editada')
const target = await fixtures.payload.create({
collection: 'contact',
data: {
name: targetName,
phones: [{ value: fixtures.phone() }],
state: 'BA',
city: 'Salvador',
},
depth: 0,
})
// The conflict target: a letters-only name that already exists.
const conflictName = uiName(fixtures, 'Ficha Conflito')
await fixtures.payload.create({
collection: 'contact',
data: { name: conflictName, state: 'BA' },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/contatos')
const omnibox = page.getByRole('combobox', { name: 'Filtrar contatos' })
await omnibox.fill(targetName)
await omnibox.press('Enter')
await page.getByRole('button', { name: `Editar contato ${targetName}` }).click()
const dialog = page.getByRole('dialog', { name: 'Editar contato' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('textbox', { name: 'Nome *' })).toHaveValue(targetName)
// A conflict (the typed name already exists) keeps the sheet open with
// the typed value and a visible banner — no silent wipe (C139).
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(conflictName)
await dialog.getByRole('button', { name: 'Salvar' }).click()
await expect(dialog.getByRole('alert')).toContainText(
'Já existe um contato com este nome — confira a lista antes de salvar.',
)
await expect(dialog.getByRole('textbox', { name: 'Nome *' })).toHaveValue(conflictName)
// Reverting the name and editing the city writes the whole ficha in one
// action: the card shows the new city and the database agrees.
await dialog.getByRole('textbox', { name: 'Nome *' }).fill(targetName)
await dialog.getByRole('textbox', { name: 'Cidade' }).fill('Feira de Santana')
const persisted = expectPostResponse(page, '/campanha/contatos')
await dialog.getByRole('button', { name: 'Salvar' }).click()
await persisted
await expect(dialog).toBeHidden()
const card = page.locator('[data-view="mobile-cards"] li').filter({ hasText: targetName })
await expect(card).toContainText('Feira de Santana · BA')
const stored = await fixtures.payload.findByID({
collection: 'contact',
id: target.id,
depth: 0,
})
expect(stored.city).toBe('Feira de Santana')
expect(stored.name).toBe(targetName)
})
})

View file

@ -198,7 +198,7 @@ test.describe('Início — catálogo de ações (B45)', () => {
await expect(page.getByRole('link', { name: 'Ver meus contatos', exact: true })).toBeVisible()
await page.getByRole('link', { name: 'Ver meus contatos', exact: true }).click()
await page.waitForURL(/\/campanha\/contatos/)
await page.waitForURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: /Meus contatos/ })).toBeVisible()
})
})

View file

@ -710,17 +710,17 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText('Média: 90')).toBeVisible()
// Leader home is the blank Início (B43); the contact tool lives at
// /campanha/contatos and municipalities redirect there.
// /campanha/meus-contatos (C139) and municipalities redirect there.
// Each `createCampaignUser` call mints its own password — the advisor's
// `password` variable above does NOT unlock the leader account.
await campaign.login(page, leaderPhone, leaderAccount.password)
await page.goto(`${campaign.baseURL}/campanha/contatos`)
await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/meus-contatos`)
await expect(campaignPageChrome(page, 'Meus contatos')).toBeVisible()
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
// redirect itself is the assertion, and `toHaveURL` retries onto it.
await page.goto(`${campaign.baseURL}/campanha/municipios`).catch(() => {})
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/meus-contatos`)
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()

View file

@ -113,8 +113,8 @@ test.describe('Pessoas — lista unificada', () => {
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas')
await expect(page).toHaveURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('leader cannot open the person detail route', async ({ campaign, page }) => {
@ -128,8 +128,8 @@ test.describe('Pessoas — lista unificada', () => {
await campaign.login(page, phone, leader.password)
await page.goto('/campanha/pessoas/99999')
await expect(page).toHaveURL(/\/campanha\/contatos/)
await expect(page.getByRole('heading', { name: 'Contatos' })).toBeVisible()
await expect(page).toHaveURL(/\/campanha\/meus-contatos/)
await expect(page.getByRole('heading', { name: 'Meus contatos' })).toBeVisible()
})
test('coordinator opens the person detail and sees the sections of her capacities', async ({

View file

@ -85,7 +85,7 @@ test.describe('Wizard "Registrar pedido" (A5/B195)', () => {
await page
.goto(`${campaign.baseURL}/campanha/acoes/registrar-pedido?municipio=${municipality.slug}`)
.catch(() => {})
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/contatos`)
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/meus-contatos`)
await expect(page.getByLabel(CAMPAIGN_DEMAND_BODY_LABEL)).toHaveCount(0)
})
})

View file

@ -144,14 +144,14 @@ test.describe('Territórios de Identidade (HTTP)', () => {
expect([200, 307, 308]).toContain(direct.status())
if (direct.status() === 200) {
expect(await direct.text()).toContain(
'http-equiv="refresh" content="1;url=/campanha/contatos"',
'http-equiv="refresh" content="1;url=/campanha/meus-contatos"',
)
} else {
expect(direct.headers()['location']).toMatch(/\/campanha\/contatos$/)
expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/)
}
// The leader home renders no way in.
const home = await request.get('/campanha/contatos')
const home = await request.get('/campanha/meus-contatos')
expect(home.status()).toBe(200)
expect(await home.text()).not.toContain('href="/campanha/territorios"')
})

View file

@ -41,6 +41,9 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
// (same class as /editar: cold compile mid-test aborts the redirect).
'/campanha/demandas/e2e-prewarm',
'/campanha/liderancas',
// C139 — both contact surfaces: the leader tool (meus-contatos, which the
// lockdown redirects land on) and the new staff ficha page (contatos).
'/campanha/meus-contatos',
'/campanha/contatos',
'/campanha/conceitos',
'/campanha/acoes/atualizar-votos',

View file

@ -54,6 +54,18 @@ describe('campaignPageChrome', () => {
it('builds tab metadata from catalog', () => {
expect(campaignPageMetadataFromCatalog('contatos').title).toBe('Contatos')
expect(campaignPageMetadataFromCatalog('meusContatos').title).toBe('Meus contatos')
expect(campaignPageMetadata(null).title).toBe('Início')
})
it('separates staff contatos from the leader meus-contatos (C139)', () => {
expect(resolveCampaignPageChrome('/campanha/contatos', 'coordinator')).toEqual({
title: 'Contatos',
subtitle: 'Fichas da campanha — atualize dados e fale com as pessoas.',
})
expect(resolveCampaignPageChrome('/campanha/meus-contatos', 'leader')).toEqual({
title: 'Meus contatos',
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
})
})
})

View file

@ -22,6 +22,7 @@ import {
import {
isCampaignActionsPath,
isCampaignHomePath,
isContactsPath,
isLeaderContactsPath,
shouldMountQuickActionsFab,
} from '@/lib/campaignQuickActionMount'
@ -54,13 +55,21 @@ describe('campaignQuickActionMount', () => {
expect(isCampaignActionsPath('/campanha/municipios')).toBe(false)
})
it('matches leader contacts subtree', () => {
expect(isLeaderContactsPath('/campanha/contatos')).toBe(true)
expect(isLeaderContactsPath('/campanha/contatos/novo')).toBe(true)
it('matches leader contacts subtree (C139 — moved to meus-contatos)', () => {
expect(isLeaderContactsPath('/campanha/meus-contatos')).toBe(true)
expect(isLeaderContactsPath('/campanha/meus-contatos/novo')).toBe(true)
expect(isLeaderContactsPath('/campanha/contatos')).toBe(false)
expect(isLeaderContactsPath('/campanha')).toBe(false)
})
it('mounts for staff outside Início and acoes', () => {
it('matches the staff contacts page (C139)', () => {
expect(isContactsPath('/campanha/contatos')).toBe(true)
expect(isContactsPath('/campanha/contatos/')).toBe(true)
expect(isContactsPath('/campanha/meus-contatos')).toBe(false)
expect(isContactsPath('/campanha')).toBe(false)
})
it('mounts for staff outside Início, acoes and the contacts page', () => {
expect(shouldMountQuickActionsFab('/campanha/municipios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/territorios', 'coordinator')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/demandas', 'coordinator')).toBe(true)
@ -71,12 +80,15 @@ describe('campaignQuickActionMount', () => {
expect(shouldMountQuickActionsFab('/campanha/acoes/registrar-atualizacao', 'advisor')).toBe(
false,
)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'coordinator')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'advisor')).toBe(false)
})
it('mounts for leader only on contacts', () => {
expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/meus-contatos', 'leader')).toBe(true)
expect(shouldMountQuickActionsFab('/campanha/municipios', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/apoiadores', 'leader')).toBe(false)
expect(shouldMountQuickActionsFab('/campanha/contatos', 'leader')).toBe(false)
})
it('skips the E13 tour composer (B84)', () => {

View file

@ -0,0 +1,236 @@
import { describe, expect, it } from 'vitest'
import type { Contact } from '@/payload-types'
import {
contactFilterFacetsFromRows,
filterContactRows,
sortContactRows,
toContactRowViewModel,
type ContactRowViewModel,
} from '@/utilities/contacts/contactListData'
import type { ContactListState } from '@/utilities/contacts/contactListUrl'
const baseRow = (overrides: Partial<ContactRowViewModel> = {}): ContactRowViewModel => ({
contactID: 1,
name: 'Ana Souza',
email: 'ana@example.com',
phones: ['71999990000'],
gender: 'feminino',
state: 'BA',
city: 'Salvador',
postalCode: '40000-000',
vinculos: ['liderancas'],
...overrides,
})
const rows = [
baseRow({ contactID: 1, name: 'Ana Souza', gender: 'feminino', state: 'BA', city: 'Salvador' }),
baseRow({
contactID: 2,
name: 'Bruno Lima',
gender: 'masculino',
state: 'SP',
city: 'São Paulo',
email: null,
vinculos: [],
}),
baseRow({
contactID: 3,
name: 'Carla Mendes',
gender: null,
state: null,
city: null,
phones: [],
email: 'carla@example.com',
vinculos: ['dobradinhas', 'equipe'],
}),
]
describe('toContactRowViewModel', () => {
it('maps the contact doc keeping phone order and normalizing optionals', () => {
const view = toContactRowViewModel({
id: 7,
name: ' Maria ',
email: 'maria@example.com',
phones: [{ value: '71 9' }, { value: '75 8' }],
gender: 'outro',
state: 'BA',
city: 'Camaçari',
postalCode: '42800-000',
} as unknown as Contact)
expect(view).toEqual({
contactID: 7,
name: ' Maria ',
email: 'maria@example.com',
phones: ['71 9', '75 8'],
gender: 'outro',
state: 'BA',
city: 'Camaçari',
postalCode: '42800-000',
vinculos: [],
})
})
it('degrades unknown enum values and absent optionals to null', () => {
const view = toContactRowViewModel({
id: 8,
name: 'Sem Dados',
email: null,
phones: null,
gender: 'desconhecido',
state: 'BAHIA',
city: null,
postalCode: null,
} as unknown as Contact)
expect(view).toEqual({
contactID: 8,
name: 'Sem Dados',
email: null,
phones: [],
gender: null,
state: null,
city: null,
postalCode: null,
vinculos: [],
})
})
})
describe('filterContactRows', () => {
const state = (partial: Partial<ContactListState>): ContactListState => ({
page: 1,
...partial,
})
it('keeps every row when no filter is active', () => {
expect(filterContactRows(rows, state({}))).toHaveLength(3)
})
it('filters by gender and drops rows without a gender', () => {
expect(
filterContactRows(rows, state({ genders: ['feminino'] })).map((r) => r.contactID),
).toEqual([1])
expect(
filterContactRows(rows, state({ genders: ['masculino'] })).map((r) => r.contactID),
).toEqual([2])
})
it('filters by state and drops rows without a state', () => {
expect(filterContactRows(rows, state({ states: ['BA'] })).map((r) => r.contactID)).toEqual([1])
})
it('filters by city case-insensitively', () => {
expect(
filterContactRows(rows, state({ cities: ['salvador'] })).map((r) => r.contactID),
).toEqual([1])
expect(
filterContactRows(rows, state({ cities: ['Salvador', 'São Paulo'] })).map((r) => r.contactID),
).toEqual([1, 2])
})
it('filters by absence with OR semantics and never matches a complete ficha', () => {
expect(
filterContactRows(rows, state({ ausencias: ['sem_telefone'] })).map((r) => r.contactID),
).toEqual([3])
expect(
filterContactRows(rows, state({ ausencias: ['sem_email'] })).map((r) => r.contactID),
).toEqual([2])
expect(
filterContactRows(rows, state({ ausencias: ['sem_telefone', 'sem_email'] })).map(
(r) => r.contactID,
),
).toEqual([2, 3])
})
it('filters by vínculo with OR semantics (any of the selected links)', () => {
expect(
filterContactRows(rows, state({ vinculos: ['liderancas'] })).map((r) => r.contactID),
).toEqual([1])
expect(
filterContactRows(rows, state({ vinculos: ['dobradinhas', 'liderancas'] })).map(
(r) => r.contactID,
),
).toEqual([1, 3])
// A ficha without any link never matches a vínculo filter.
expect(
filterContactRows(rows, state({ vinculos: ['assessores'] })).map((r) => r.contactID),
).toEqual([])
})
it('combines filters with AND between facets', () => {
expect(
filterContactRows(rows, state({ genders: ['feminino'], states: ['BA'] })).map(
(r) => r.contactID,
),
).toEqual([1])
expect(
filterContactRows(rows, state({ genders: ['feminino'], states: ['SP'] })).map(
(r) => r.contactID,
),
).toEqual([])
})
})
describe('contactFilterFacetsFromRows', () => {
it('derives facets from the scoped rows, seeded with the active selections', () => {
expect(
contactFilterFacetsFromRows(rows, { page: 1, genders: ['feminino'], states: ['BA'] }),
).toEqual({
genders: ['feminino', 'masculino'],
states: ['BA', 'SP'],
cities: ['Salvador', 'São Paulo'],
vinculos: ['dobradinhas', 'equipe', 'liderancas'],
})
})
it('keeps the first-seen city casing when deduping case-insensitively', () => {
expect(
contactFilterFacetsFromRows(
[baseRow({ contactID: 1, city: 'Salvador' }), baseRow({ contactID: 2, city: 'salvador' })],
{ page: 1 },
).cities,
).toEqual(['Salvador'])
})
it('returns empty facets for an empty scope', () => {
expect(contactFilterFacetsFromRows([], { page: 1 })).toEqual({
genders: [],
states: [],
cities: [],
vinculos: [],
})
})
})
describe('sortContactRows', () => {
it('sorts by name asc by default, ties by contact id', () => {
expect(sortContactRows(rows, 'name', 'asc').map((r) => r.contactID)).toEqual([1, 2, 3])
expect(sortContactRows(rows, 'name', 'desc').map((r) => r.contactID)).toEqual([3, 2, 1])
})
it('sorts by city and estado, landing nulls last in both directions', () => {
const byCityAsc = sortContactRows(rows, 'cidade', 'asc')
expect(byCityAsc.map((r) => r.contactID)).toEqual([1, 2, 3])
expect(byCityAsc[2].city).toBeNull()
const byCityDesc = sortContactRows(rows, 'cidade', 'desc')
expect(byCityDesc.map((r) => r.contactID)).toEqual([2, 1, 3])
const byEstadoAsc = sortContactRows(rows, 'estado', 'asc')
expect(byEstadoAsc.map((r) => r.contactID)).toEqual([1, 2, 3])
})
it('sorts by email, landing null emails last', () => {
const byEmailAsc = sortContactRows(rows, 'email', 'asc')
expect(byEmailAsc.map((r) => r.contactID)).toEqual([1, 3, 2])
const byEmailDesc = sortContactRows(rows, 'email', 'desc')
expect(byEmailDesc.map((r) => r.contactID)).toEqual([3, 1, 2])
})
it('does not mutate the input rows', () => {
const before = [...rows].map((r) => r.contactID)
sortContactRows(rows, 'name', 'desc')
expect(rows.map((r) => r.contactID)).toEqual(before)
})
})

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

View file

@ -0,0 +1,206 @@
import { describe, expect, it } from 'vitest'
import {
buildContactListHref,
buildContactListWhere,
buildContactSortHref,
contactListSortPrimaryOptions,
defaultContactListSortDir,
parseContactListParams,
resolveContactListUrl,
serializeCanonicalContactListSearchParams,
} from '@/utilities/contacts/contactListUrl'
describe('contacts list URL contract', () => {
it('defaults to page 1 with no filters', () => {
expect(parseContactListParams({})).toEqual({ page: 1 })
})
it('validates page as a positive decimal integer', () => {
expect(parseContactListParams({ page: '4' }).page).toBe(4)
expect(parseContactListParams({ page: '0' }).page).toBe(1)
expect(parseContactListParams({ page: '-2' }).page).toBe(1)
expect(parseContactListParams({ page: 'abc' }).page).toBe(1)
})
it('trims q, omits it when empty and takes the first repeated cell', () => {
expect(parseContactListParams({ q: ' ana ' }).q).toBe('ana')
expect(parseContactListParams({ q: ' ' }).q).toBeUndefined()
expect(parseContactListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro')
})
it('parses and dedupes known gender and state values', () => {
expect(parseContactListParams({ gender: 'feminino' })).toEqual({
page: 1,
genders: ['feminino'],
})
expect(
parseContactListParams({ gender: ['masculino', 'outro', 'masculino', 'unknown'] }).genders,
).toEqual(['masculino', 'outro'])
expect(parseContactListParams({ state: 'BA' }).states).toEqual(['BA'])
expect(parseContactListParams({ state: ['BA', 'SP', 'BA', 'XX', 'bA'] }).states).toEqual([
'BA',
'SP',
])
})
it('parses cities as deduped free text up to a sanity cap', () => {
expect(parseContactListParams({ city: ['Salvador', 'salvador', ' '] }).cities).toEqual([
'Salvador',
'salvador',
])
expect(parseContactListParams({ city: ['x'.repeat(101)] }).cities).toBeUndefined()
})
it('parses absence facets as OR values, dropping unknown (no "todas" collapse)', () => {
expect(parseContactListParams({ ausencia: 'sem_telefone' })).toEqual({
page: 1,
ausencias: ['sem_telefone'],
})
expect(
parseContactListParams({ ausencia: ['sem_telefone', 'sem_email', 'sem_telefone', 'nada'] })
.ausencias,
).toEqual(['sem_telefone', 'sem_email'])
// C139 — selecting every absence still filters (fichas sem telefone E sem
// e-mail): no canonical "todas" collapse for this facet.
expect(parseContactListParams({ ausencia: ['sem_telefone', 'sem_email'] }).ausencias).toEqual([
'sem_telefone',
'sem_email',
])
})
it('parses vínculo facets as OR values, dropping unknown', () => {
expect(parseContactListParams({ vinculo: 'liderancas' })).toEqual({
page: 1,
vinculos: ['liderancas'],
})
expect(
parseContactListParams({ vinculo: ['dobradinhas', 'equipe', 'dobradinhas', 'nada'] })
.vinculos,
).toEqual(['dobradinhas', 'equipe'])
})
it('parses known sort keys and directions, dropping anything else', () => {
expect(parseContactListParams({ sort: 'cidade', dir: 'asc' })).toEqual({
page: 1,
sort: 'cidade',
dir: 'asc',
})
expect(parseContactListParams({ sort: 'party', dir: 'up' })).toEqual({ page: 1 })
expect(parseContactListParams({ sort: 'name' })).toEqual({ page: 1, sort: 'name' })
expect(parseContactListParams({ sort: 'party' })).toEqual({ page: 1 })
expect(parseContactListParams({ dir: 'desc' })).toEqual({ page: 1, dir: 'desc' })
})
it('serializes canonical params in stable order and omits defaults', () => {
expect(
serializeCanonicalContactListSearchParams({
page: 1,
q: 'ana',
genders: ['feminino'],
states: ['BA'],
cities: ['Salvador'],
ausencias: ['sem_email'],
vinculos: ['liderancas'],
}).toString(),
).toBe('q=ana&gender=feminino&state=BA&city=Salvador&ausencia=sem_email&vinculo=liderancas')
expect(
serializeCanonicalContactListSearchParams({ page: 1, sort: 'name', dir: 'asc' }).toString(),
).toBe('')
expect(
serializeCanonicalContactListSearchParams({
page: 1,
sort: 'cidade',
dir: 'desc',
}).toString(),
).toBe('sort=cidade&dir=desc')
expect(serializeCanonicalContactListSearchParams({ page: 3, q: 'ana' }).toString()).toBe(
'q=ana&page=3',
)
})
it('builds hrefs through the canonical serializer and drops page 1', () => {
expect(buildContactListHref({ page: 1, vinculos: ['dobradinhas'] }, 1)).toBe(
'/campanha/contatos?vinculo=dobradinhas',
)
expect(buildContactListHref({ page: 3 }, 3)).toBe('/campanha/contatos?page=3')
})
it('resolves a bare URL to the bare path and rejects unsupported params', () => {
const resolved = resolveContactListUrl({ q: 'ana' })
expect(resolved.state).toEqual({ page: 1, q: 'ana' })
expect(resolved.href).toBe('/campanha/contatos?q=ana')
expect(resolved.redirectHref).toBeUndefined()
const unsupported = resolveContactListUrl({ q: 'ana', party: 'PT' })
expect(unsupported.redirectHref).toBe('/campanha/contatos?q=ana')
})
it('canonicalizes a default sort away and keeps a non-default one', () => {
const defaulted = resolveContactListUrl({ q: 'ana', sort: 'name' })
expect(defaulted.state).toEqual({ page: 1, q: 'ana', sort: 'name' })
expect(defaulted.redirectHref).toBe('/campanha/contatos?q=ana')
// Every contacts key defaults to asc, so a non-default dir IS canonical
// (no redirect) — unlike people, whose 'lidera' default is desc.
const kept = resolveContactListUrl({ sort: 'cidade', dir: 'desc' })
expect(kept.redirectHref).toBeUndefined()
expect(kept.href).toBe('/campanha/contatos?sort=cidade&dir=desc')
// A bare dir resolves against the default key and canonicalizes without a loop.
const bareDir = resolveContactListUrl({ dir: 'desc' })
expect(bareDir.state).toEqual({ page: 1, dir: 'desc' })
expect(bareDir.redirectHref).toBe('/campanha/contatos?sort=name&dir=desc')
})
it('builds sort-toggle hrefs: flips the active key, applies the new key default, resets to page 1', () => {
expect(buildContactSortHref({ page: 1 }, 'cidade')).toBe('/campanha/contatos?sort=cidade')
// Flip from desc → asc, the key default: the dir canonicalizes away.
expect(buildContactSortHref({ page: 1, sort: 'cidade', dir: 'desc' }, 'cidade')).toBe(
'/campanha/contatos?sort=cidade',
)
expect(buildContactSortHref({ page: 1, sort: 'cidade', dir: 'desc' }, 'email')).toBe(
'/campanha/contatos?sort=email',
)
expect(buildContactSortHref({ page: 1 }, 'email')).toBe('/campanha/contatos?sort=email')
expect(buildContactSortHref({ page: 3, sort: 'cidade', dir: 'desc' }, 'name')).toBe(
'/campanha/contatos',
)
})
it('derives the omnibox primary sort options: one per key, in its default direction', () => {
expect(contactListSortPrimaryOptions).toHaveLength(4)
expect(contactListSortPrimaryOptions.map((option) => option.key)).toEqual([
'name',
'cidade',
'estado',
'email',
])
for (const option of contactListSortPrimaryOptions) {
expect(option.dir).toBe(defaultContactListSortDir(option.key))
}
})
it('clamps an out-of-range page to totalPages', () => {
const resolved = resolveContactListUrl({ page: '9' }, 3)
expect(resolved.state.page).toBe(3)
expect(resolved.redirectHref).toBe('/campanha/contatos?page=3')
})
})
describe('contacts list source-level where', () => {
it('returns an empty where when there is no q', () => {
expect(buildContactListWhere({ page: 1 })).toEqual({})
})
it('matches q against name, email or any stored phone', () => {
expect(buildContactListWhere({ page: 1, q: 'ana' })).toEqual({
or: [
{ name: { like: 'ana' } },
{ email: { like: 'ana' } },
{ 'phones.value': { like: 'ana' } },
],
})
})
})

View file

@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import type { Payload } from 'payload'
import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact'
import {
assertContactNameAvailable,
normalizeContactName,
} from '@/utilities/contacts/contactNameInvariant'
describe('normalizeContactName', () => {
it('trims, collapses internal whitespace and lowercases', () => {
expect(normalizeContactName(' Maria da Silva ')).toBe('maria da silva')
expect(normalizeContactName('João')).toBe('joão')
expect(normalizeContactName('')).toBe('')
})
})
describe('assertContactNameAvailable', () => {
type WhereClause = {
and?: { name?: { like?: string }; id?: { not_equals?: number } }[]
}
const makePayload = (docs: { name: string; id?: number }[]): Pick<Payload, 'db' | 'find'> => ({
db: {
name: 'postgres',
sessions: { tx: { db: { execute: vi.fn() } } },
} as unknown as Payload['db'],
find: vi.fn().mockImplementation(async ({ where }: { where?: WhereClause }) => {
const conditions = where?.and ?? []
const idExclusion = conditions.find((c) => c.id?.not_equals)?.id?.not_equals
const like = conditions.find((c) => c.name?.like)?.name?.like
const filtered = docs.filter((doc) => {
if (idExclusion !== undefined && doc.id === idExclusion) return false
if (like && !doc.name.toLowerCase().includes(like)) return false
return true
})
return { docs: filtered, totalDocs: filtered.length }
}) as unknown as Payload['find'],
})
it('passes when no stored contact normalizes to the same name', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Souza' }])
await expect(
assertContactNameAvailable(payload, { transactionID: 'tx' }, ' maria silva '),
).resolves.toBeUndefined()
// The DB prefilter runs the case-insensitive substring, the verdict is in memory.
expect(payload.find).toHaveBeenCalledOnce()
})
it('throws the conflict message on a normalized-name match (case/whitespace insensitive)', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
await expect(assertContactNameAvailable(payload, {}, 'MARIA SILVA')).rejects.toThrow(
CONTACT_NAME_CONFLICT_MESSAGE,
)
})
it('ignores the contact itself when renaming (contactID excluded)', async () => {
const payload = makePayload([{ id: 9, name: 'Maria Silva' }])
await expect(assertContactNameAvailable(payload, {}, 'Maria Silva', 9)).resolves.toBeUndefined()
})
it('does not collide on a partial-name substring match', async () => {
const payload = makePayload([{ id: 9, name: 'Ana Maria' }])
await expect(assertContactNameAvailable(payload, {}, 'Maria')).resolves.toBeUndefined()
})
})

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone'
import { contactCreateSchema, contactFieldUpdateSchema } from '@/lib/schemas/contact'
describe('contactCreateSchema (C139)', () => {
it('accepts a full valid ficha', () => {
const result = contactCreateSchema.safeParse({
name: 'Maria Silva',
email: 'maria@example.com',
phones: ['71999990000'],
gender: 'feminino',
state: 'BA',
city: 'Salvador',
postalCode: '40000000',
})
expect(result.success).toBe(true)
})
it('accepts the minimal ficha (name + state)', () => {
const result = contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA' })
expect(result.success).toBe(true)
})
it('rejects a missing state', () => {
const result = contactCreateSchema.safeParse({ name: 'Ana Souza' })
expect(result.success).toBe(false)
if (!result.success)
expect(result.error.issues.some((issue) => issue.path[0] === 'state')).toBe(true)
})
it('rejects an unknown gender', () => {
expect(
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', gender: 'x' }).success,
).toBe(false)
})
it('rejects a single-word name', () => {
expect(contactCreateSchema.safeParse({ name: 'Ana', state: 'BA' }).success).toBe(false)
})
it('rejects duplicated phones within the same ficha (C112)', () => {
const result = contactCreateSchema.safeParse({
name: 'Ana Souza',
state: 'BA',
phones: ['71999990000', '71999990000'],
})
expect(result.success).toBe(false)
if (!result.success) {
expect(
result.error.issues.some((issue) => issue.message === BRAZILIAN_PHONE_DUPLICATE_MESSAGE),
).toBe(true)
}
})
it('accepts an empty phone list', () => {
expect(
contactCreateSchema.safeParse({ name: 'Ana Souza', state: 'BA', phones: [] }).success,
).toBe(true)
})
})
describe('contactFieldUpdateSchema — C139 variants', () => {
it('accepts the city variant', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'Camaçari' }).success,
).toBe(true)
expect(contactFieldUpdateSchema.safeParse({ id: 5, field: 'city', city: 'C' }).success).toBe(
false,
)
})
it('transforms an empty postalCode to undefined (clear semantics)', () => {
const result = contactFieldUpdateSchema.safeParse({
id: 5,
field: 'postalCode',
postalCode: '',
})
expect(result.success).toBe(true)
if (result.success) {
const data = result.data as { field: string; postalCode?: string }
expect(data.postalCode).toBeUndefined()
}
})
it('accepts an 8-digit postalCode and rejects a malformed one', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: '42800000' })
.success,
).toBe(true)
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'postalCode', postalCode: 'abc' }).success,
).toBe(false)
})
it('keeps the legacy variants working', () => {
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'name', name: 'Ana Souza' }).success,
).toBe(true)
expect(
contactFieldUpdateSchema.safeParse({ id: 5, field: 'phones', phones: ['71999990000'] })
.success,
).toBe(true)
})
})