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:
commit
d6df8c6360
53 changed files with 4690 additions and 92 deletions
252
docs/plans/contatos-pagina-da-entidade-contact-impl.md
Normal file
252
docs/plans/contatos-pagina-da-entidade-contact-impl.md
Normal 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).
|
||||
86
docs/plans/react-19-form-reset-campanha.md
Normal file
86
docs/plans/react-19-form-reset-campanha.md
Normal 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).
|
||||
|
|
@ -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`,
|
||||
|
|
|
|||
138
src/app/(campaign)/campanha/(app)/contatos/formActions.ts
Normal file
138
src/app/(campaign)/campanha/(app)/contatos/formActions.ts
Normal 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.',
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
33
src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx
Normal file
33
src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
220
src/app/(campaign)/campanha/actions/contact.ts
Normal file
220
src/app/(campaign)/campanha/actions/contact.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
32
src/components/campaign/contacts/ContactCreateFab.tsx
Normal file
32
src/components/campaign/contacts/ContactCreateFab.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
|
||||
import { createContactFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
|
||||
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
|
||||
|
||||
/**
|
||||
* C139 — the mobile create FAB (below `md`, same corner as the quick-actions
|
||||
* FAB, which the staff contacts page deliberately does not mount): opens the
|
||||
* create sheet — the same ficha form, empty, with the state defaulting to BA.
|
||||
*/
|
||||
export const ContactCreateFab = () => (
|
||||
<ContactFormSheet
|
||||
title="Novo contato"
|
||||
description="Preencha a ficha da campanha"
|
||||
trigger={<PlusIcon className="size-6" aria-hidden="true" />}
|
||||
triggerLabel="Novo contato"
|
||||
triggerClassName="fixed right-4 bottom-5 z-40 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg md:hidden"
|
||||
formAction={createContactFormAction}
|
||||
defaults={{
|
||||
name: '',
|
||||
email: '',
|
||||
phones: [],
|
||||
gender: '',
|
||||
state: 'BA',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
}}
|
||||
successMessage="Contato criado."
|
||||
/>
|
||||
)
|
||||
221
src/components/campaign/contacts/ContactCreateRow.tsx
Normal file
221
src/components/campaign/contacts/ContactCreateRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
src/components/campaign/contacts/ContactCreateState.tsx
Normal file
44
src/components/campaign/contacts/ContactCreateState.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* C139 — the contacts create surface. One tiny client store shared by the
|
||||
* desktop row (inside the table container) and the mobile FAB: opening from
|
||||
* either mounts the same row. The row itself is `ContactCreateRow`.
|
||||
*/
|
||||
type ContactCreateState = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ContactCreateContext = createContext<ContactCreateState | null>(null)
|
||||
|
||||
export const ContactCreateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<ContactCreateContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</ContactCreateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useContactCreate = (): ContactCreateState => {
|
||||
const context = useContext(ContactCreateContext)
|
||||
if (!context) throw new Error('useContactCreate must be used within ContactCreateProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
/** Desktop trailing control of the omnibox (the mobile FAB is Fase 5). */
|
||||
export const ContactCreateButton = () => {
|
||||
const { open, setOpen } = useContactCreate()
|
||||
return (
|
||||
<Button type="button" className="min-h-11" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Novo contato
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
100
src/components/campaign/contacts/ContactFilters.tsx
Normal file
100
src/components/campaign/contacts/ContactFilters.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
|
||||
import { ContactCreateButton } from '@/components/campaign/contacts/ContactCreateState'
|
||||
import {
|
||||
CampaignListOmnibox,
|
||||
campaignListOmniboxFormClassName,
|
||||
} from '@/components/campaign/shared/CampaignListOmnibox'
|
||||
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import {
|
||||
buildContactFilterHref,
|
||||
type ContactFilterOption,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import type { ContactListState } from '@/utilities/contacts/contactListUrl'
|
||||
import {
|
||||
applyContactOmniboxSuggestion,
|
||||
buildContactOmniboxChips,
|
||||
buildContactOmniboxSuggestionSeeds,
|
||||
clearContactOmnibox,
|
||||
filterContactOmniboxSuggestions,
|
||||
removeContactOmniboxChip,
|
||||
type ContactOmniboxAction,
|
||||
} from '@/utilities/contacts/contactOmnibox'
|
||||
|
||||
export const ContactFilters = ({
|
||||
state,
|
||||
cityFilterOptions,
|
||||
trailing,
|
||||
}: {
|
||||
state: ContactListState
|
||||
cityFilterOptions: ContactFilterOption[]
|
||||
trailing?: ReactNode
|
||||
}) => {
|
||||
const { navigate, isPending } = useCampaignListFilterNavigation({
|
||||
state,
|
||||
toHref: buildContactFilterHref,
|
||||
})
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const chips = useMemo(() => buildContactOmniboxChips(state), [state])
|
||||
|
||||
const suggestionSeeds = useMemo(
|
||||
() => buildContactOmniboxSuggestionSeeds({ cityFilterOptions }),
|
||||
[cityFilterOptions],
|
||||
)
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => filterContactOmniboxSuggestions(suggestionSeeds, query),
|
||||
[suggestionSeeds, query],
|
||||
)
|
||||
|
||||
const runAction = (action: ContactOmniboxAction) => {
|
||||
if (action.kind === 'clear') {
|
||||
setQuery('')
|
||||
navigate(action.state)
|
||||
return
|
||||
}
|
||||
navigate(action.state)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className={campaignListOmniboxFormClassName}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<CampaignListOmnibox
|
||||
id="contact-omnibox"
|
||||
label="Filtrar contatos"
|
||||
placeholder="Buscar contato…"
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
isPending={isPending}
|
||||
onSelectSuggestion={(suggestionId) => {
|
||||
runAction(applyContactOmniboxSuggestion({ state, suggestionId }))
|
||||
}}
|
||||
onCommitQuery={(text) => {
|
||||
runAction(applyContactOmniboxSuggestion({ state, suggestionId: `q:${text}` }))
|
||||
}}
|
||||
onRemoveChip={(chipId) => {
|
||||
runAction(removeContactOmniboxChip({ state, chipId }))
|
||||
}}
|
||||
onClearAll={() => {
|
||||
runAction(clearContactOmnibox(state))
|
||||
}}
|
||||
trailing={
|
||||
<>
|
||||
{trailing}
|
||||
<ContactCreateButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
221
src/components/campaign/contacts/ContactFormFields.tsx
Normal file
221
src/components/campaign/contacts/ContactFormFields.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client'
|
||||
|
||||
import { startTransition, useState, type FormEvent } from 'react'
|
||||
|
||||
import { PhonesFieldEditor } from '@/components/campaign/shared/PhonesFieldEditor'
|
||||
import { FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CitiesByState } from '@/lib/cities'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
import { errorProps } from '@/utilities/campaignFormFields'
|
||||
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
export type ContactFormDefaults = {
|
||||
name: string
|
||||
email: string
|
||||
phones: string[]
|
||||
gender: string
|
||||
state: string
|
||||
city: string
|
||||
postalCode: string
|
||||
}
|
||||
|
||||
const stateOptions = Object.keys(CitiesByState)
|
||||
|
||||
/**
|
||||
* C139 — the mobile sheet's ficha form (edit and create share it; the create
|
||||
* sheet passes empty defaults). Full-bleed fields with dividers; phones use
|
||||
* `PhonesFieldEditor` in FORM mode (no `saveAction`), so the repeated `phones`
|
||||
* inputs are collected by the enclosing form. Field errors come from the
|
||||
* ladder's `fieldErrors` (name/email/phones/gender/state/city/postalCode);
|
||||
* top-level ladder messages (name conflict, out of scope) render as a banner.
|
||||
*
|
||||
* The fields are CONTROLLED and the submit goes through a manual `onSubmit`
|
||||
* dispatch instead of `<form action={submitAction}>`: React 19 resets
|
||||
* uncontrolled form fields after any settled form action, wiping typed values
|
||||
* on validation/conflict errors (the sheet stays open, so the wipe is visible).
|
||||
* `preventDefault` keeps the native submit event away from that plumbing.
|
||||
*/
|
||||
export const ContactFormFields = ({
|
||||
formId,
|
||||
state,
|
||||
sessionId,
|
||||
submitAction,
|
||||
defaults,
|
||||
contactId,
|
||||
}: {
|
||||
formId: string
|
||||
state: CampaignFormActionState
|
||||
/**
|
||||
* Bumps on every sheet open; the fields drop the state from any PREVIOUS
|
||||
* session with it (the sheet wrapper stays mounted, so `useActionState`
|
||||
* state would otherwise leak a stale conflict banner into a fresh open).
|
||||
*/
|
||||
sessionId: number
|
||||
submitAction: (formData: FormData) => void
|
||||
defaults: ContactFormDefaults
|
||||
/** Edit mode: the record being updated (submitted as the `id` field). */
|
||||
contactId?: string
|
||||
}) => {
|
||||
const [lastSessionId, setLastSessionId] = useState(sessionId)
|
||||
if (lastSessionId !== sessionId) setLastSessionId(sessionId)
|
||||
const visibleState = lastSessionId === sessionId ? state : {}
|
||||
|
||||
const [name, setName] = useState(defaults.name)
|
||||
const [email, setEmail] = useState(defaults.email)
|
||||
const [gender, setGender] = useState(defaults.gender)
|
||||
const [stateValue, setStateValue] = useState(defaults.state)
|
||||
const [city, setCity] = useState(defaults.city)
|
||||
const [postalCode, setPostalCode] = useState(defaults.postalCode)
|
||||
|
||||
const nameProps = errorProps(visibleState.fieldErrors, 'name', formId)
|
||||
const emailProps = errorProps(visibleState.fieldErrors, 'email', formId)
|
||||
const cityProps = errorProps(visibleState.fieldErrors, 'city', formId)
|
||||
const postalCodeProps = errorProps(visibleState.fieldErrors, 'postalCode', formId)
|
||||
const stateProps = errorProps(visibleState.fieldErrors, 'state', formId)
|
||||
const genderProps = errorProps(visibleState.fieldErrors, 'gender', formId)
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
// `startTransition` keeps `isPending` correct for the imperative dispatch
|
||||
// (React warns otherwise) without re-entering the `<form action>` plumbing.
|
||||
startTransition(() => submitAction(new FormData(event.currentTarget)))
|
||||
}
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="flex flex-col divide-y">
|
||||
{contactId !== undefined ? <input type="hidden" name="id" value={contactId} /> : null}
|
||||
{visibleState.fieldErrors?.id ? (
|
||||
<FieldError id={`${formId}-id-error`}>{visibleState.fieldErrors.id[0]}</FieldError>
|
||||
) : null}
|
||||
{visibleState.message ? (
|
||||
<p role="alert" className="py-3 text-sm font-medium text-destructive">
|
||||
{visibleState.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-name`}>Nome *</FieldLabel>
|
||||
<Input
|
||||
id={`${formId}-name`}
|
||||
name="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Nome e sobrenome"
|
||||
aria-invalid={nameProps.invalid}
|
||||
aria-describedby={nameProps.describedBy}
|
||||
/>
|
||||
{nameProps.error ? (
|
||||
<FieldError id={nameProps.describedBy}>{nameProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="py-3">
|
||||
<PhonesFieldEditor
|
||||
defaultValues={defaults.phones}
|
||||
error={visibleState.fieldErrors?.phones}
|
||||
label="Telefones"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-email`}>E-mail</FieldLabel>
|
||||
<Input
|
||||
id={`${formId}-email`}
|
||||
name="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="nome@email.com"
|
||||
aria-invalid={emailProps.invalid}
|
||||
aria-describedby={emailProps.describedBy}
|
||||
/>
|
||||
{emailProps.error ? (
|
||||
<FieldError id={emailProps.describedBy}>{emailProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-gender`}>Gênero</FieldLabel>
|
||||
<select
|
||||
id={`${formId}-gender`}
|
||||
name="gender"
|
||||
value={gender}
|
||||
onChange={(event) => setGender(event.target.value)}
|
||||
aria-invalid={genderProps.invalid}
|
||||
aria-describedby={genderProps.describedBy}
|
||||
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{(Object.keys(contactGenderLabels) as (keyof typeof contactGenderLabels)[]).map(
|
||||
(gender) => (
|
||||
<option key={gender} value={gender}>
|
||||
{contactGenderLabels[gender]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
{genderProps.error ? (
|
||||
<FieldError id={genderProps.describedBy}>{genderProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-city`}>Cidade</FieldLabel>
|
||||
<Input
|
||||
id={`${formId}-city`}
|
||||
name="city"
|
||||
value={city}
|
||||
onChange={(event) => setCity(event.target.value)}
|
||||
placeholder="Cidade"
|
||||
aria-invalid={cityProps.invalid}
|
||||
aria-describedby={cityProps.describedBy}
|
||||
/>
|
||||
{cityProps.error ? (
|
||||
<FieldError id={cityProps.describedBy}>{cityProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-state`}>Estado *</FieldLabel>
|
||||
<select
|
||||
id={`${formId}-state`}
|
||||
name="state"
|
||||
required
|
||||
value={stateValue}
|
||||
onChange={(event) => setStateValue(event.target.value)}
|
||||
aria-invalid={stateProps.invalid}
|
||||
aria-describedby={stateProps.describedBy}
|
||||
className="min-h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
{stateOptions.map((stateKey) => (
|
||||
<option key={stateKey} value={stateKey}>
|
||||
{stateKey}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{stateProps.error ? (
|
||||
<FieldError id={stateProps.describedBy}>{stateProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<FieldLabel htmlFor={`${formId}-postal-code`}>CEP</FieldLabel>
|
||||
<Input
|
||||
id={`${formId}-postal-code`}
|
||||
name="postalCode"
|
||||
inputMode="numeric"
|
||||
maxLength={8}
|
||||
value={postalCode}
|
||||
onChange={(event) => setPostalCode(event.target.value)}
|
||||
placeholder="00000000"
|
||||
aria-invalid={postalCodeProps.invalid}
|
||||
aria-describedby={postalCodeProps.describedBy}
|
||||
/>
|
||||
{postalCodeProps.error ? (
|
||||
<FieldError id={postalCodeProps.describedBy}>{postalCodeProps.error}</FieldError>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
107
src/components/campaign/contacts/ContactFormSheet.tsx
Normal file
107
src/components/campaign/contacts/ContactFormSheet.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useActionState, useEffect, useId, useState, type ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
ContactFormFields,
|
||||
type ContactFormDefaults,
|
||||
} from '@/components/campaign/contacts/ContactFormFields'
|
||||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DrawerCloseButton } from '@/components/ui/Drawer'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
/**
|
||||
* C139 — the mobile ficha sheet (edit card / create FAB): the shared
|
||||
* `CampaignListSheetProvider` drawer via `CampaignCellEditOverlay`, the whole
|
||||
* ficha as ONE form (plan decision F) and the custom footer submit. Success
|
||||
* toasts, closes and refreshes; field errors render inline in the body.
|
||||
*/
|
||||
export const ContactFormSheet = ({
|
||||
title,
|
||||
description,
|
||||
trigger,
|
||||
triggerLabel,
|
||||
triggerClassName,
|
||||
formAction,
|
||||
defaults,
|
||||
successMessage,
|
||||
deleteControl,
|
||||
contactId,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
trigger: ReactNode
|
||||
triggerLabel: string
|
||||
triggerClassName?: string
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
defaults: ContactFormDefaults
|
||||
successMessage: string
|
||||
/** Rendered under the form, outside it (the destructive dialog, edit only). */
|
||||
deleteControl?: ReactNode
|
||||
/** Edit mode: the record being updated (submitted as the `id` field). */
|
||||
contactId?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const formId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [openCount, setOpenCount] = useState(0)
|
||||
const [formActionState, submitAction, isPending] = useActionState<
|
||||
CampaignFormActionState,
|
||||
FormData
|
||||
>(formAction, {})
|
||||
|
||||
// Bump on every open so the fields can drop a PREVIOUS session's action
|
||||
// state (the sheet wrapper stays mounted; useActionState does not reset).
|
||||
useEffect(() => {
|
||||
if (open) setOpenCount((count) => count + 1)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (formActionState.status !== 'success') return
|
||||
toast.success(formActionState.message ?? successMessage)
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}, [formActionState, router, successMessage])
|
||||
|
||||
const footer = (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button form={formId} type="submit" disabled={isPending} className="min-h-11 w-full">
|
||||
{isPending ? <Spinner data-icon="inline-start" /> : null}
|
||||
Salvar
|
||||
</Button>
|
||||
<DrawerCloseButton disabled={isPending}>Cancelar</DrawerCloseButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<CampaignCellEditOverlay
|
||||
variant="sheet"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={title}
|
||||
description={description}
|
||||
trigger={trigger}
|
||||
triggerLabel={triggerLabel}
|
||||
triggerClassName={triggerClassName}
|
||||
footer={footer}
|
||||
sheetBodyClassName="gap-4"
|
||||
>
|
||||
<ContactFormFields
|
||||
formId={formId}
|
||||
state={formActionState}
|
||||
sessionId={openCount}
|
||||
submitAction={submitAction}
|
||||
defaults={defaults}
|
||||
contactId={contactId}
|
||||
/>
|
||||
{deleteControl}
|
||||
</CampaignCellEditOverlay>
|
||||
)
|
||||
}
|
||||
113
src/components/campaign/contacts/ContactMobileCard.tsx
Normal file
113
src/components/campaign/contacts/ContactMobileCard.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
'use client'
|
||||
|
||||
import { MailIcon, MessageCircleIcon, PencilIcon } from 'lucide-react'
|
||||
|
||||
import { updateContactFullFormAction } from '@/app/(campaign)/campanha/(app)/contatos/formActions'
|
||||
import { ContactFormSheet } from '@/components/campaign/contacts/ContactFormSheet'
|
||||
import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone'
|
||||
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
|
||||
import { contactGenderLabels } from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
const editTriggerClassName = 'size-10 rounded-full'
|
||||
|
||||
/**
|
||||
* C139 — the mobile ficha card (below `md`): name + secondary line (primary
|
||||
* phone ?? e-mail), gender chip + "Cidade · Estado", and the actions row
|
||||
* (WhatsApp / e-mail / edit / delete). Editing opens the shared ficha sheet.
|
||||
*/
|
||||
export const ContactMobileCard = ({
|
||||
row,
|
||||
canDelete,
|
||||
}: {
|
||||
row: ContactRowViewModel
|
||||
canDelete: boolean
|
||||
}) => {
|
||||
const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null)
|
||||
const secondary =
|
||||
row.phones.length > 0
|
||||
? formatBrazilianPhoneInput(row.phones[0]!)
|
||||
: (row.email ?? 'Sem contato registrado')
|
||||
const place = [row.city, row.state].filter(Boolean).join(' · ')
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 py-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{row.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{secondary}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{whatsAppHref ? (
|
||||
<Button asChild variant="ghost" size="icon" className="size-10">
|
||||
<a
|
||||
href={whatsAppHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Enviar WhatsApp para ${row.name}`}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{row.email ? (
|
||||
<Button asChild variant="ghost" size="icon" className="size-10">
|
||||
<a href={`mailto:${row.email}`} aria-label={`Enviar e-mail para ${row.name}`}>
|
||||
<MailIcon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<DeletePersonButton
|
||||
personName={row.name}
|
||||
contactId={row.contactID}
|
||||
vocabulary="contato"
|
||||
/>
|
||||
) : null}
|
||||
<ContactFormSheet
|
||||
title="Editar contato"
|
||||
description={row.name}
|
||||
trigger={<PencilIcon className="size-4" aria-hidden="true" />}
|
||||
triggerLabel={`Editar contato ${row.name}`}
|
||||
triggerClassName={editTriggerClassName}
|
||||
formAction={updateContactFullFormAction}
|
||||
contactId={String(row.contactID)}
|
||||
defaults={{
|
||||
name: row.name,
|
||||
email: row.email ?? '',
|
||||
phones: row.phones,
|
||||
gender: row.gender ?? '',
|
||||
state: row.state ?? 'BA',
|
||||
city: row.city ?? '',
|
||||
postalCode: row.postalCode ?? '',
|
||||
}}
|
||||
successMessage="Salvo."
|
||||
deleteControl={
|
||||
canDelete ? (
|
||||
<div className="flex justify-center border-t pt-4">
|
||||
<DeletePersonButton
|
||||
personName={row.name}
|
||||
contactId={row.contactID}
|
||||
vocabulary="contato"
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{row.gender || place ? (
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
{row.gender ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
{contactGenderLabels[row.gender]}
|
||||
</Badge>
|
||||
) : null}
|
||||
{place ? <span>{place}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
24
src/components/campaign/contacts/ContactMobileList.tsx
Normal file
24
src/components/campaign/contacts/ContactMobileList.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ContactMobileCard } from '@/components/campaign/contacts/ContactMobileCard'
|
||||
import type { ContactRowViewModel } from '@/utilities/contacts/contactListData'
|
||||
|
||||
/** C139 — the mobile ficha list (below `md`), replacing the interim one-liner. */
|
||||
export const ContactMobileList = ({
|
||||
rows,
|
||||
canDelete,
|
||||
empty,
|
||||
}: {
|
||||
rows: readonly ContactRowViewModel[]
|
||||
canDelete: boolean
|
||||
empty: ReactNode
|
||||
}) => (
|
||||
<ul data-view="mobile-cards" className="flex flex-col divide-y md:hidden">
|
||||
{rows.length === 0 ? <li className="py-4">{empty}</li> : null}
|
||||
{rows.map((row) => (
|
||||
<ContactMobileCard key={row.contactID} row={row} canDelete={canDelete} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
138
src/components/campaign/contacts/ContactPhonesCell.tsx
Normal file
138
src/components/campaign/contacts/ContactPhonesCell.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type ContactPhonesCellProps = {
|
||||
recordId: number
|
||||
/** Stored order = priority (first is primary) — C112 contract. */
|
||||
phones: readonly string[]
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
}
|
||||
|
||||
const sanitize = (raw: string): string => sanitizeBrazilianPhoneInput(raw)
|
||||
|
||||
/**
|
||||
* C139 — the phone cell of the contacts table: stacked borderless inputs with
|
||||
* the phone mask. Blur/Enter on ANY line commits the WHOLE list (order =
|
||||
* priority), empty lines are discarded before the schema; Escape discards the
|
||||
* draft and "+" appends a line. The commit is the ficha's full `phones` array
|
||||
* (`field: 'phones'`) — a single value is never written alone.
|
||||
*/
|
||||
export const ContactPhonesCell = ({ recordId, phones, formAction }: ContactPhonesCellProps) => {
|
||||
const router = useRouter()
|
||||
const [draft, setDraft] = useState<string[]>(() => phones.map(formatBrazilianPhoneInput))
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const clean = (lines: string[]): string[] => {
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const value = sanitize(line)
|
||||
if (!value || seen.has(value)) continue
|
||||
seen.add(value)
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const dirty = (lines: string[]): boolean => {
|
||||
const cleaned = clean(lines)
|
||||
if (cleaned.length !== phones.length) return true
|
||||
return cleaned.some((value, index) => value !== sanitize(phones[index] ?? ''))
|
||||
}
|
||||
|
||||
const commit = (lines: string[]) => {
|
||||
if (isPending) return
|
||||
if (!dirty(lines)) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('id', String(recordId))
|
||||
formData.set('field', 'phones')
|
||||
for (const value of clean(lines)) formData.append('phones', value)
|
||||
|
||||
setIsPending(true)
|
||||
void formAction({}, formData)
|
||||
.then((result) => {
|
||||
if (result.status === 'success') {
|
||||
setDraft(clean(lines).map(formatBrazilianPhoneInput))
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.phones?.[0] ?? result.message ?? 'Não foi possível salvar.'
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
toast.error(message)
|
||||
})
|
||||
.catch(() => {
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
toast.error('Não foi possível salvar. Tente novamente.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPending(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setDraft(phones.map(formatBrazilianPhoneInput))
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commit(draft)
|
||||
event.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
{draft.map((line, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
inputMode="numeric"
|
||||
aria-label={`Telefone ${index + 1}${index === 0 ? ' (principal)' : ''}`}
|
||||
aria-busy={isPending}
|
||||
value={line}
|
||||
placeholder="Sem telefone"
|
||||
className={campaignInlineInputClassName}
|
||||
onChange={(event) => {
|
||||
const next = [...draft]
|
||||
next[index] = formatBrazilianPhoneInput(sanitize(event.currentTarget.value))
|
||||
setDraft(next)
|
||||
}}
|
||||
onBlur={() => commit(draft)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
))}
|
||||
{draft.length === 0 ? (
|
||||
<span className="px-1 text-sm text-muted-foreground">Sem telefone</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit min-h-9 gap-1 self-start px-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Adicionar telefone"
|
||||
onClick={() => {
|
||||
setDraft((current) => [...current, ''])
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-3.5" aria-hidden />
|
||||
Adicionar telefone
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/campaign/contacts/ContactSelectCell.tsx
Normal file
118
src/components/campaign/contacts/ContactSelectCell.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type ChangeEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
||||
type ContactSelectField = 'gender' | 'state'
|
||||
|
||||
type ContactSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type ContactSelectCellProps = {
|
||||
recordId: number
|
||||
field: ContactSelectField
|
||||
value: string | null
|
||||
label: string
|
||||
options: readonly ContactSelectOption[]
|
||||
/**
|
||||
* The label of the "empty" option shown when `value` is null (the ficha has
|
||||
* no gender recorded). State is required on the collection, so the empty
|
||||
* option never appears there.
|
||||
*/
|
||||
emptyLabel?: string
|
||||
formAction: (
|
||||
state: CampaignFormActionState,
|
||||
formData: FormData,
|
||||
) => Promise<CampaignFormActionState>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* C139 — the gender/state cells of the contacts table: a native select styled
|
||||
* as a borderless text cell. Changing the option commits immediately through
|
||||
* the same per-cell ladder as the text cells (`field: gender|state`); a failed
|
||||
* commit reverts the select and toasts the message (the people-cell error
|
||||
* pattern).
|
||||
*/
|
||||
export const ContactSelectCell = ({
|
||||
recordId,
|
||||
field,
|
||||
value,
|
||||
label,
|
||||
options,
|
||||
emptyLabel = '—',
|
||||
formAction,
|
||||
className,
|
||||
}: ContactSelectCellProps) => {
|
||||
const router = useRouter()
|
||||
const [selected, setSelected] = useState(value ?? '')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const commit = async (next: string) => {
|
||||
if (next === (value ?? '') || next === '') return
|
||||
setIsPending(true)
|
||||
const formData = new FormData()
|
||||
formData.set('id', String(recordId))
|
||||
formData.set('field', field)
|
||||
formData.set(field, next)
|
||||
try {
|
||||
const result = await formAction({}, formData)
|
||||
if (result.status === 'success') {
|
||||
router.refresh()
|
||||
return
|
||||
}
|
||||
const message =
|
||||
result.fieldErrors?.[field]?.[0] ?? result.message ?? 'Não foi possível salvar.'
|
||||
setSelected(value ?? '')
|
||||
toast.error(message)
|
||||
} catch {
|
||||
setSelected(value ?? '')
|
||||
toast.error('Não foi possível salvar. Tente novamente.')
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
setSelected(event.currentTarget.value)
|
||||
void commit(event.currentTarget.value)
|
||||
}
|
||||
|
||||
const hasValue = value !== null && value !== ''
|
||||
|
||||
return (
|
||||
<div className={cn('relative inline-flex min-w-0 max-w-full items-center', className)}>
|
||||
<select
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
disabled={isPending}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
className={cn(
|
||||
'min-h-10 w-full cursor-pointer appearance-none rounded-sm border-transparent bg-transparent pr-6 pl-1 text-sm',
|
||||
'focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||
hasValue ? 'text-foreground' : 'text-muted-foreground',
|
||||
isPending && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{!hasValue ? <option value="">{emptyLabel}</option> : null}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-1.5 size-3.5 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
src/components/campaign/contacts/ContactSortableHead.tsx
Normal file
49
src/components/campaign/contacts/ContactSortableHead.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead'
|
||||
import {
|
||||
buildContactSortHref,
|
||||
contactListSortLabels,
|
||||
defaultContactListSortDir,
|
||||
resolveContactListSort,
|
||||
type ContactListSortKey,
|
||||
type ContactListState,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
type ContactSortableHeadProps = {
|
||||
state: ContactListState
|
||||
sortKey: ContactListSortKey
|
||||
children?: ReactNode
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Sortable header for the contacts table (C139). Filters live in the
|
||||
* omnibox only — no header filter popovers (people precedent, C117). */
|
||||
export const ContactSortableHead = ({
|
||||
state,
|
||||
sortKey,
|
||||
children,
|
||||
align = 'left',
|
||||
className,
|
||||
}: ContactSortableHeadProps) => {
|
||||
const { sort: activeSort, dir } = resolveContactListSort(state)
|
||||
const active = activeSort === sortKey
|
||||
const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultContactListSortDir(sortKey)
|
||||
|
||||
return (
|
||||
<CampaignSortableHead
|
||||
active={active}
|
||||
align={align}
|
||||
className={className}
|
||||
dir={dir}
|
||||
href={buildContactSortHref(state, sortKey)}
|
||||
nextDir={nextDir}
|
||||
sortLabel={contactListSortLabels[sortKey]}
|
||||
>
|
||||
{children ?? contactListSortLabels[sortKey]}
|
||||
</CampaignSortableHead>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,6 +37,12 @@ type DeletePersonButtonProps = {
|
|||
* navigate away — the ficha no longer exists.
|
||||
*/
|
||||
deletedHref?: string
|
||||
/**
|
||||
* C139 — the contacts page reuses the C100 dialog with its own vocabulary
|
||||
* ("contato" ficha instead of "pessoa"); the manifest and the cascade are
|
||||
* the same (the delete contract is contactId-based).
|
||||
*/
|
||||
vocabulary?: 'pessoa' | 'contato'
|
||||
}
|
||||
|
||||
type ManifestState =
|
||||
|
|
@ -45,17 +51,23 @@ type ManifestState =
|
|||
| { kind: 'ready'; manifest: PersonDeleteManifest }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
const mapManifestError = (error: unknown): string => {
|
||||
const mapManifestError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) {
|
||||
return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.'
|
||||
}
|
||||
return 'Não foi possível carregar o que será apagado. Tente de novo.'
|
||||
}
|
||||
|
||||
const mapDeleteError = (error: unknown): string => {
|
||||
const mapDeleteError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => {
|
||||
const message = error instanceof Error ? error.message : ''
|
||||
if (message === PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE) return message
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.'
|
||||
return 'Não foi possível apagar a pessoa. Tente de novo.'
|
||||
if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) {
|
||||
return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.'
|
||||
}
|
||||
return vocabulary === 'contato'
|
||||
? 'Não foi possível apagar o contato. Tente de novo.'
|
||||
: 'Não foi possível apagar a pessoa. Tente de novo.'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,6 +81,7 @@ export const DeletePersonButton = ({
|
|||
personName,
|
||||
contactId,
|
||||
deletedHref,
|
||||
vocabulary = 'pessoa',
|
||||
}: DeletePersonButtonProps) => {
|
||||
const router = useRouter()
|
||||
const [manifest, setManifest] = useState<ManifestState>({ kind: 'idle' })
|
||||
|
|
@ -87,11 +100,11 @@ export const DeletePersonButton = ({
|
|||
? { kind: 'ready', manifest: next }
|
||||
: {
|
||||
kind: 'error',
|
||||
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE)),
|
||||
message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE), vocabulary),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
setManifest({ kind: 'error', message: mapManifestError(error) })
|
||||
setManifest({ kind: 'error', message: mapManifestError(error, vocabulary) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,12 +114,13 @@ export const DeletePersonButton = ({
|
|||
setDeleteError('')
|
||||
try {
|
||||
const result = await deletePersonAction({ contactId })
|
||||
const deletedCopy = vocabulary === 'contato' ? 'Contato apagado.' : 'Pessoa apagada.'
|
||||
toast.success(
|
||||
result.contactDeleted
|
||||
? 'Pessoa apagada.'
|
||||
? deletedCopy
|
||||
: result.contactAnonymized
|
||||
? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.'
|
||||
: 'Pessoa apagada.',
|
||||
? `${deletedCopy} A ficha foi anonimizada porque ainda é referenciada por participações públicas.`
|
||||
: deletedCopy,
|
||||
)
|
||||
if (deletedHref) {
|
||||
router.push(deletedHref)
|
||||
|
|
@ -114,7 +128,7 @@ export const DeletePersonButton = ({
|
|||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
setDeleteError(mapDeleteError(error))
|
||||
setDeleteError(mapDeleteError(error, vocabulary))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
|
|
@ -129,14 +143,14 @@ export const DeletePersonButton = ({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 text-destructive hover:text-destructive"
|
||||
aria-label={`Apagar pessoa ${personName}`}
|
||||
aria-label={`Apagar ${vocabulary} ${personName}`}
|
||||
>
|
||||
<Trash2Icon className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Apagar pessoa</AlertDialogTitle>
|
||||
<AlertDialogTitle>Apagar {vocabulary}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '}
|
||||
<span className="font-medium text-foreground">{personName}</span> com a campanha.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,17 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
|
||||
const SAVE_DEBOUNCE_MS = 500
|
||||
|
||||
export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'ballotName'
|
||||
// C139 — the contacts page adds the ficha's own text cells: `city` and
|
||||
// `postalCode` (same blur-save contract; the placeholder prop lets the cell
|
||||
// spell the empty state, e.g. "Sem cidade").
|
||||
export type CampaignInlineEditableField =
|
||||
| 'name'
|
||||
| 'email'
|
||||
| 'phone'
|
||||
| 'party'
|
||||
| 'ballotName'
|
||||
| 'city'
|
||||
| 'postalCode'
|
||||
|
||||
type CampaignInlineEditableCellProps = {
|
||||
recordId: number
|
||||
|
|
@ -45,6 +55,12 @@ type CampaignInlineEditableCellProps = {
|
|||
* typing edits in the same place (mechanism locked at the C116 gate).
|
||||
*/
|
||||
permanent?: boolean
|
||||
/**
|
||||
* C139 — the empty-state placeholder of the permanent input (default '—').
|
||||
* Cells pass their own copy (e.g. 'Sem cidade') — the ficha's absence is a
|
||||
* state worth spelling, not a dash.
|
||||
*/
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +93,7 @@ export const CampaignInlineEditableCell = ({
|
|||
readBehavior = 'text',
|
||||
saveOnChange = true,
|
||||
permanent = false,
|
||||
placeholder,
|
||||
className,
|
||||
}: CampaignInlineEditableCellProps) => {
|
||||
const router = useRouter()
|
||||
|
|
@ -269,7 +286,7 @@ export const CampaignInlineEditableCell = ({
|
|||
ref={inputRef}
|
||||
type={inputTypeForField(field)}
|
||||
value={draft}
|
||||
placeholder={isNameLink ? undefined : '—'}
|
||||
placeholder={isNameLink ? undefined : (placeholder ?? '—')}
|
||||
aria-label={label}
|
||||
aria-busy={isPending}
|
||||
className={inputClassName}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
7
src/lib/schemas/personGenders.ts
Normal file
7
src/lib/schemas/personGenders.ts
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
312
src/utilities/contacts/contactListData.ts
Normal file
312
src/utilities/contacts/contactListData.ts
Normal 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 }
|
||||
}
|
||||
141
src/utilities/contacts/contactListFilters.ts
Normal file
141
src/utilities/contacts/contactListFilters.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* Contacts list filter affordances (C139): Gênero / Estado / Cidade /
|
||||
* Ausência / Vínculo toggles and clear-filters. Multi-select facets are OR
|
||||
* within the facet, like every other list (ausência and vínculo are real ORs
|
||||
* — selecting every member still excludes complete/unlinked fichas).
|
||||
*/
|
||||
import { CitiesByState } from '@/lib/cities'
|
||||
import {
|
||||
buildContactListHref,
|
||||
CONTACT_ABSENCES,
|
||||
CONTACT_VINCULOS,
|
||||
contactAbsenceLabels,
|
||||
contactGenderLabels,
|
||||
contactListStateToRawParams,
|
||||
contactVinculoLabels,
|
||||
parseContactListParams,
|
||||
type ContactGender,
|
||||
type ContactListState,
|
||||
type ContactStateKey,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
export type ContactFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type ContactMultiFilterParam = 'gender' | 'state' | 'city' | 'ausencia' | 'vinculo'
|
||||
|
||||
/** Static seeds: the gender enum of the collection (4 values). */
|
||||
export const contactGenderFilterOptions: ContactFilterOption[] = (
|
||||
Object.keys(contactGenderLabels) as ContactGender[]
|
||||
).map((value) => ({ value, label: contactGenderLabels[value] }))
|
||||
|
||||
/** Static seeds: the 27 UFs of the cities catalog (the select-cell source too). */
|
||||
export const contactStateFilterOptions: ContactFilterOption[] = (
|
||||
Object.keys(CitiesByState) as ContactStateKey[]
|
||||
).map((value) => ({ value, label: value }))
|
||||
|
||||
export const contactAbsenceFilterOptions: ContactFilterOption[] = CONTACT_ABSENCES.map((value) => ({
|
||||
value,
|
||||
label: contactAbsenceLabels[value],
|
||||
}))
|
||||
|
||||
export const contactVinculoFilterOptions: ContactFilterOption[] = CONTACT_VINCULOS.map((value) => ({
|
||||
value,
|
||||
label: contactVinculoLabels[value],
|
||||
}))
|
||||
|
||||
/**
|
||||
* Writes the RAW param and lets `parseContactListParams` validate, so no
|
||||
* branch has to cast a `string[]` into the state's narrow value types.
|
||||
*/
|
||||
const setContactMultiFilterValues = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
values: string[],
|
||||
): ContactListState =>
|
||||
parseContactListParams({
|
||||
...contactListStateToRawParams({ ...state, page: 1 }, 1),
|
||||
[param]: values,
|
||||
})
|
||||
|
||||
const getContactMultiFilterValues = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
): string[] => {
|
||||
if (param === 'gender') return state.genders ?? []
|
||||
if (param === 'state') return state.states ?? []
|
||||
if (param === 'ausencia') return state.ausencias ?? []
|
||||
if (param === 'vinculo') return state.vinculos ?? []
|
||||
return state.cities ?? []
|
||||
}
|
||||
|
||||
const toggleContactMultiFilterValue = (
|
||||
state: ContactListState,
|
||||
param: ContactMultiFilterParam,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
const current = getContactMultiFilterValues(state, param)
|
||||
return setContactMultiFilterValues(
|
||||
state,
|
||||
param,
|
||||
current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value],
|
||||
)
|
||||
}
|
||||
|
||||
export const toggleContactGenderFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!contactGenderFilterOptions.some((option) => option.value === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'gender', value)
|
||||
}
|
||||
|
||||
export const toggleContactStateFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!(value in CitiesByState)) return state
|
||||
return toggleContactMultiFilterValue(state, 'state', value)
|
||||
}
|
||||
|
||||
export const toggleContactAbsenceFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!CONTACT_ABSENCES.some((absence) => absence === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'ausencia', value)
|
||||
}
|
||||
|
||||
export const toggleContactVinculoFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
if (!CONTACT_VINCULOS.some((vinculo) => vinculo === value)) return state
|
||||
return toggleContactMultiFilterValue(state, 'vinculo', value)
|
||||
}
|
||||
|
||||
/**
|
||||
* C139 — city is FREE TEXT on `Contact.city` (maxLength 100), so the toggle
|
||||
* validates structurally (non-empty, ≤ 100 chars) and lets
|
||||
* `parseContactListParams` dedupe — the party facet precedent (C130).
|
||||
*/
|
||||
export const toggleContactCityFilter = (
|
||||
state: ContactListState,
|
||||
value: string,
|
||||
): ContactListState => {
|
||||
const normalized = value.trim()
|
||||
if (!normalized || normalized.length > 100) return state
|
||||
return toggleContactMultiFilterValue(state, 'city', normalized)
|
||||
}
|
||||
|
||||
/** Drop every filter and the search; keep the ordering (municipios precedent). */
|
||||
export const clearContactListFilters = (state: ContactListState): ContactListState => ({
|
||||
page: 1,
|
||||
sort: state.sort,
|
||||
dir: state.dir,
|
||||
})
|
||||
|
||||
export const buildContactFilterHref = (next: ContactListState): string =>
|
||||
buildContactListHref(next, 1)
|
||||
262
src/utilities/contacts/contactListUrl.ts
Normal file
262
src/utilities/contacts/contactListUrl.ts
Normal 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 } },
|
||||
],
|
||||
}
|
||||
}
|
||||
55
src/utilities/contacts/contactNameInvariant.ts
Normal file
55
src/utilities/contacts/contactNameInvariant.ts
Normal 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)
|
||||
}
|
||||
277
src/utilities/contacts/contactOmnibox.ts
Normal file
277
src/utilities/contacts/contactOmnibox.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/**
|
||||
* Contacts list omnibox adapter (C139). Pure / client-safe — the sibling of
|
||||
* `peopleOmnibox` for the ficha surface.
|
||||
*/
|
||||
import {
|
||||
createOmniboxSuggestionSeed,
|
||||
filterOmniboxSuggestionSeeds,
|
||||
type CampaignListOmniboxChip,
|
||||
type CampaignListOmniboxSuggestion,
|
||||
} from '@/lib/campaignListOmnibox'
|
||||
import {
|
||||
clearContactListFilters,
|
||||
contactAbsenceFilterOptions,
|
||||
contactGenderFilterOptions,
|
||||
contactStateFilterOptions,
|
||||
contactVinculoFilterOptions,
|
||||
toggleContactAbsenceFilter,
|
||||
toggleContactCityFilter,
|
||||
toggleContactGenderFilter,
|
||||
toggleContactStateFilter,
|
||||
toggleContactVinculoFilter,
|
||||
type ContactFilterOption,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import {
|
||||
contactAbsenceLabels,
|
||||
contactGenderLabels,
|
||||
contactListSortOptions,
|
||||
contactListSortPrimaryOptions,
|
||||
contactVinculoLabels,
|
||||
isDefaultContactListSort,
|
||||
resolveContactListSort,
|
||||
type ContactListState,
|
||||
} from '@/utilities/contacts/contactListUrl'
|
||||
|
||||
export type ContactOmniboxAction =
|
||||
| { kind: 'url'; state: ContactListState }
|
||||
| { kind: 'clear'; state: ContactListState }
|
||||
|
||||
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
|
||||
|
||||
export const buildContactOmniboxChips = (state: ContactListState): CampaignListOmniboxChip[] => {
|
||||
const chips: CampaignListOmniboxChip[] = []
|
||||
|
||||
if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) })
|
||||
|
||||
for (const gender of state.genders ?? []) {
|
||||
chips.push({ id: `gender:${gender}`, label: chipLabel('Gênero', contactGenderLabels[gender]) })
|
||||
}
|
||||
|
||||
for (const stateKey of state.states ?? []) {
|
||||
chips.push({ id: `state:${stateKey}`, label: chipLabel('Estado', stateKey) })
|
||||
}
|
||||
|
||||
for (const city of state.cities ?? []) {
|
||||
chips.push({ id: `city:${city}`, label: chipLabel('Cidade', city) })
|
||||
}
|
||||
|
||||
for (const ausencia of state.ausencias ?? []) {
|
||||
chips.push({
|
||||
id: `ausencia:${ausencia}`,
|
||||
label: chipLabel('Ausência', contactAbsenceLabels[ausencia]),
|
||||
})
|
||||
}
|
||||
|
||||
for (const vinculo of state.vinculos ?? []) {
|
||||
chips.push({
|
||||
id: `vinculo:${vinculo}`,
|
||||
label: chipLabel('Vínculo', contactVinculoLabels[vinculo]),
|
||||
})
|
||||
}
|
||||
|
||||
if (!isDefaultContactListSort(state)) {
|
||||
const { sort, dir } = resolveContactListSort(state)
|
||||
const option = contactListSortOptions.find((entry) => entry.key === sort && entry.dir === dir)
|
||||
chips.push({
|
||||
id: 'sort',
|
||||
label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`),
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
export const buildContactOmniboxSuggestionSeeds = ({
|
||||
cityFilterOptions,
|
||||
}: {
|
||||
cityFilterOptions: readonly ContactFilterOption[]
|
||||
}) => {
|
||||
const seeds = []
|
||||
|
||||
for (const option of contactGenderFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `gender:${option.value}`,
|
||||
group: 'Gênero',
|
||||
label: option.label,
|
||||
keywords: ['genero', 'sexo'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactStateFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `state:${option.value}`,
|
||||
group: 'Estado',
|
||||
label: option.label,
|
||||
keywords: ['estado', 'uf'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of cityFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed({
|
||||
id: `city:${option.value}`,
|
||||
group: 'Cidade',
|
||||
label: option.label,
|
||||
keywords: ['cidade', 'municipio'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactAbsenceFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `ausencia:${option.value}`,
|
||||
group: 'Ausência',
|
||||
label: option.label,
|
||||
keywords: [
|
||||
'sem',
|
||||
'ausencia',
|
||||
'faltando',
|
||||
'faltante',
|
||||
'vazio',
|
||||
'incompleto',
|
||||
'incompleta',
|
||||
],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const option of contactVinculoFilterOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `vinculo:${option.value}`,
|
||||
group: 'Vínculo',
|
||||
label: option.label,
|
||||
keywords: ['vinculo', 'ligacao', 'lideranca', 'dobradinha', 'assessor', 'equipe'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// C125 — primary-direction seeds only, visible on an empty query (the mobile
|
||||
// cards have no sortable headers, so the omnibox group is the only sort
|
||||
// surface below `md` — same rationale as people).
|
||||
for (const option of contactListSortPrimaryOptions) {
|
||||
seeds.push(
|
||||
createOmniboxSuggestionSeed(
|
||||
{
|
||||
id: `sort:${option.key}|${option.dir}`,
|
||||
group: 'Ordenação',
|
||||
label: option.label,
|
||||
keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'],
|
||||
},
|
||||
{ emptyQueryVisible: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return seeds
|
||||
}
|
||||
|
||||
export const filterContactOmniboxSuggestions = (
|
||||
seeds: ReturnType<typeof buildContactOmniboxSuggestionSeeds>,
|
||||
query: string,
|
||||
): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query)
|
||||
|
||||
export const applyContactOmniboxSuggestion = ({
|
||||
state,
|
||||
suggestionId,
|
||||
}: {
|
||||
state: ContactListState
|
||||
suggestionId: string
|
||||
}): ContactOmniboxAction => {
|
||||
if (suggestionId.startsWith('q:')) {
|
||||
const q = suggestionId.slice(2)
|
||||
return { kind: 'url', state: { ...state, page: 1, q: q || undefined } }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('gender:')) {
|
||||
return { kind: 'url', state: toggleContactGenderFilter(state, suggestionId.slice(7)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('state:')) {
|
||||
return { kind: 'url', state: toggleContactStateFilter(state, suggestionId.slice(6)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('city:')) {
|
||||
return { kind: 'url', state: toggleContactCityFilter(state, suggestionId.slice(5)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('ausencia:')) {
|
||||
return { kind: 'url', state: toggleContactAbsenceFilter(state, suggestionId.slice(9)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('vinculo:')) {
|
||||
return { kind: 'url', state: toggleContactVinculoFilter(state, suggestionId.slice(8)) }
|
||||
}
|
||||
|
||||
if (suggestionId.startsWith('sort:')) {
|
||||
const raw = suggestionId.slice(5)
|
||||
const [key, dir] = raw.split('|')
|
||||
const option = contactListSortOptions.find((entry) => entry.key === key && entry.dir === dir)
|
||||
if (!option) return { kind: 'url', state }
|
||||
const next = { ...state, page: 1, sort: option.key, dir: option.dir }
|
||||
if (isDefaultContactListSort(next)) {
|
||||
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
|
||||
}
|
||||
return { kind: 'url', state: next }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const removeContactOmniboxChip = ({
|
||||
state,
|
||||
chipId,
|
||||
}: {
|
||||
state: ContactListState
|
||||
chipId: string
|
||||
}): ContactOmniboxAction => {
|
||||
if (chipId === 'q') return { kind: 'url', state: { ...state, page: 1, q: undefined } }
|
||||
|
||||
if (chipId.startsWith('gender:')) {
|
||||
return { kind: 'url', state: toggleContactGenderFilter(state, chipId.slice(7)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('state:')) {
|
||||
return { kind: 'url', state: toggleContactStateFilter(state, chipId.slice(6)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('city:')) {
|
||||
return { kind: 'url', state: toggleContactCityFilter(state, chipId.slice(5)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('ausencia:')) {
|
||||
return { kind: 'url', state: toggleContactAbsenceFilter(state, chipId.slice(9)) }
|
||||
}
|
||||
|
||||
if (chipId.startsWith('vinculo:')) {
|
||||
return { kind: 'url', state: toggleContactVinculoFilter(state, chipId.slice(8)) }
|
||||
}
|
||||
|
||||
if (chipId === 'sort') {
|
||||
return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } }
|
||||
}
|
||||
|
||||
return { kind: 'url', state }
|
||||
}
|
||||
|
||||
export const clearContactOmnibox = (state: ContactListState): ContactOmniboxAction => ({
|
||||
kind: 'clear',
|
||||
state: clearContactListFilters(state),
|
||||
})
|
||||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
291
tests/e2e/campaignContacts.e2e.spec.ts
Normal file
291
tests/e2e/campaignContacts.e2e.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
236
tests/unit/contactListData.unit.spec.ts
Normal file
236
tests/unit/contactListData.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
204
tests/unit/contactListFilters.unit.spec.ts
Normal file
204
tests/unit/contactListFilters.unit.spec.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildContactFilterHref,
|
||||
clearContactListFilters,
|
||||
contactAbsenceFilterOptions,
|
||||
contactGenderFilterOptions,
|
||||
contactStateFilterOptions,
|
||||
contactVinculoFilterOptions,
|
||||
toggleContactAbsenceFilter,
|
||||
toggleContactCityFilter,
|
||||
toggleContactGenderFilter,
|
||||
toggleContactStateFilter,
|
||||
toggleContactVinculoFilter,
|
||||
} from '@/utilities/contacts/contactListFilters'
|
||||
import { parseContactListParams } from '@/utilities/contacts/contactListUrl'
|
||||
import {
|
||||
applyContactOmniboxSuggestion,
|
||||
buildContactOmniboxChips,
|
||||
buildContactOmniboxSuggestionSeeds,
|
||||
filterContactOmniboxSuggestions,
|
||||
removeContactOmniboxChip,
|
||||
} from '@/utilities/contacts/contactOmnibox'
|
||||
|
||||
describe('contacts list filter affordances', () => {
|
||||
it('offers the static seeds: 4 genders, 27 states, 2 absences, 4 vinculos', () => {
|
||||
expect(contactGenderFilterOptions.map((option) => option.value)).toEqual([
|
||||
'feminino',
|
||||
'masculino',
|
||||
'outro',
|
||||
'nao_informado',
|
||||
])
|
||||
expect(contactStateFilterOptions).toHaveLength(27)
|
||||
expect(contactStateFilterOptions[0]).toEqual({ value: 'AC', label: 'AC' })
|
||||
expect(contactAbsenceFilterOptions.map((option) => option.value)).toEqual([
|
||||
'sem_telefone',
|
||||
'sem_email',
|
||||
])
|
||||
expect(contactVinculoFilterOptions.map((option) => option.value)).toEqual([
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'assessores',
|
||||
'equipe',
|
||||
])
|
||||
})
|
||||
|
||||
it('toggles gender and state, validating the value', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(toggleContactGenderFilter(base, 'feminino')).toEqual({
|
||||
page: 1,
|
||||
genders: ['feminino'],
|
||||
})
|
||||
expect(toggleContactGenderFilter(base, 'unknown')).toEqual(base)
|
||||
const withGender = toggleContactGenderFilter(base, 'feminino')
|
||||
expect(toggleContactGenderFilter(withGender, 'feminino')).toEqual(base)
|
||||
expect(toggleContactStateFilter(base, 'BA').states).toEqual(['BA'])
|
||||
expect(toggleContactStateFilter(base, 'XX')).toEqual(base)
|
||||
})
|
||||
|
||||
it('toggles absence and vinculo as real ORs (never a no-op umbrella)', () => {
|
||||
const base = parseContactListParams({})
|
||||
const bothAbsences = toggleContactAbsenceFilter(
|
||||
toggleContactAbsenceFilter(base, 'sem_telefone'),
|
||||
'sem_email',
|
||||
)
|
||||
expect(bothAbsences.ausencias).toEqual(['sem_telefone', 'sem_email'])
|
||||
expect(toggleContactAbsenceFilter(base, 'nope')).toEqual(base)
|
||||
const bothVinculos = toggleContactVinculoFilter(
|
||||
toggleContactVinculoFilter(base, 'liderancas'),
|
||||
'equipe',
|
||||
)
|
||||
expect(bothVinculos.vinculos).toEqual(['liderancas', 'equipe'])
|
||||
expect(toggleContactVinculoFilter(base, 'nope')).toEqual(base)
|
||||
})
|
||||
|
||||
it('toggles city as free text with structural validation', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(toggleContactCityFilter(base, ' Feira de Santana ').cities).toEqual([
|
||||
'Feira de Santana',
|
||||
])
|
||||
expect(toggleContactCityFilter(base, ' ')).toEqual(base)
|
||||
expect(toggleContactCityFilter(base, 'x'.repeat(101))).toEqual(base)
|
||||
})
|
||||
|
||||
it('clears filters keeping the sort, and builds filter hrefs pinned to page 1', () => {
|
||||
const filtered = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
state: 'BA',
|
||||
city: 'Feira de Santana',
|
||||
ausencia: 'sem_email',
|
||||
vinculo: 'liderancas',
|
||||
sort: 'cidade',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
})
|
||||
const cleared = clearContactListFilters(filtered)
|
||||
expect(cleared).toEqual({ page: 1, sort: 'cidade', dir: 'desc' })
|
||||
expect(buildContactFilterHref(cleared)).toContain('sort=cidade')
|
||||
expect(buildContactFilterHref(cleared)).toContain('dir=desc')
|
||||
// Canonical hrefs omit default page=1, so a filtered href never carries
|
||||
// a page param at all — filters always land back on page 1.
|
||||
expect(buildContactFilterHref(filtered)).not.toContain('page=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contacts list omnibox adapter', () => {
|
||||
it('builds one chip per active filter dimension', () => {
|
||||
const state = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
state: ['BA', 'SP'],
|
||||
city: 'Feira de Santana',
|
||||
ausencia: 'sem_telefone',
|
||||
vinculo: 'liderancas',
|
||||
sort: 'cidade',
|
||||
dir: 'desc',
|
||||
})
|
||||
const chips = buildContactOmniboxChips(state)
|
||||
expect(chips.map((chip) => chip.id)).toEqual([
|
||||
'q',
|
||||
'gender:feminino',
|
||||
'state:BA',
|
||||
'state:SP',
|
||||
'city:Feira de Santana',
|
||||
'ausencia:sem_telefone',
|
||||
'vinculo:liderancas',
|
||||
'sort',
|
||||
])
|
||||
expect(chips[0]).toEqual({ id: 'q', label: 'Busca: ana' })
|
||||
expect(chips[1]).toEqual({ id: 'gender:feminino', label: 'Gênero: Feminino' })
|
||||
})
|
||||
|
||||
it('omits the sort chip on the default sort', () => {
|
||||
const chips = buildContactOmniboxChips(parseContactListParams({ q: 'ana' }))
|
||||
expect(chips.map((chip) => chip.id)).toEqual(['q'])
|
||||
})
|
||||
|
||||
it('filters suggestions by group, label and keyword, capping per group', () => {
|
||||
const seeds = buildContactOmniboxSuggestionSeeds({
|
||||
cityFilterOptions: [
|
||||
{ value: 'Feira de Santana', label: 'Feira de Santana' },
|
||||
{ value: 'Salvador', label: 'Salvador' },
|
||||
],
|
||||
})
|
||||
const byLabel = filterContactOmniboxSuggestions(seeds, 'Salvador')
|
||||
expect(byLabel.map((suggestion) => suggestion.id)).toContain('city:Salvador')
|
||||
const byKeyword = filterContactOmniboxSuggestions(seeds, 'genero')
|
||||
// The omnibox always surfaces the q suggestion for a non-empty query
|
||||
// (group 'Busca'); the keyword-matched seeds are the Gênero shortcuts.
|
||||
expect(byKeyword.some((suggestion) => suggestion.group === 'Busca')).toBe(true)
|
||||
const genderMatches = byKeyword.filter((suggestion) => suggestion.group === 'Gênero')
|
||||
expect(genderMatches.map((suggestion) => suggestion.id)).toEqual([
|
||||
'gender:feminino',
|
||||
'gender:masculino',
|
||||
'gender:outro',
|
||||
'gender:nao_informado',
|
||||
])
|
||||
const emptyQuery = filterContactOmniboxSuggestions(seeds, '')
|
||||
// Empty query shows only the dimension shortcuts; data-driven city seeds are hidden.
|
||||
expect(emptyQuery.some((suggestion) => suggestion.id.startsWith('city:'))).toBe(false)
|
||||
expect(emptyQuery.some((suggestion) => suggestion.group === 'Ordenação')).toBe(true)
|
||||
})
|
||||
|
||||
it('applies suggestions: q, facets (toggle), sort (with default collapse)', () => {
|
||||
const base = parseContactListParams({})
|
||||
expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'q:ana' })).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, q: 'ana' },
|
||||
})
|
||||
expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'gender:feminino' })).toEqual(
|
||||
{ kind: 'url', state: { page: 1, genders: ['feminino'] } },
|
||||
)
|
||||
const sortApplied = applyContactOmniboxSuggestion({
|
||||
state: base,
|
||||
suggestionId: 'sort:cidade|desc',
|
||||
})
|
||||
expect(sortApplied).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, sort: 'cidade', dir: 'desc' },
|
||||
})
|
||||
const sortToggled = applyContactOmniboxSuggestion({
|
||||
state: { page: 1, sort: 'cidade', dir: 'desc' },
|
||||
suggestionId: 'sort:cidade|desc',
|
||||
})
|
||||
expect(sortToggled).toEqual({ kind: 'url', state: { page: 1, sort: 'cidade', dir: 'desc' } })
|
||||
})
|
||||
|
||||
it('removes chips by id and clears everything preserving the sort', () => {
|
||||
const state = parseContactListParams({
|
||||
q: 'ana',
|
||||
gender: 'feminino',
|
||||
city: 'Salvador',
|
||||
sort: 'cidade',
|
||||
})
|
||||
const removed = removeContactOmniboxChip({ state, chipId: 'city:Salvador' })
|
||||
expect(removed).toEqual({
|
||||
kind: 'url',
|
||||
state: { page: 1, q: 'ana', genders: ['feminino'], sort: 'cidade' },
|
||||
})
|
||||
const cleared = applyContactOmniboxSuggestion({ state, suggestionId: 'clear:all' })
|
||||
expect(cleared.kind).toBe('url')
|
||||
})
|
||||
})
|
||||
206
tests/unit/contactListUrl.unit.spec.ts
Normal file
206
tests/unit/contactListUrl.unit.spec.ts
Normal 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' } },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
66
tests/unit/contactNameInvariant.unit.spec.ts
Normal file
66
tests/unit/contactNameInvariant.unit.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
105
tests/unit/contactSchemas.unit.spec.ts
Normal file
105
tests/unit/contactSchemas.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue