From 1daaaf9f4ea54cbefd2d5b382279223048c0f235 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:05:24 -0300 Subject: [PATCH 1/9] =?UTF-8?q?feat(C139):=20dom=C3=ADnio=20de=20contatos?= =?UTF-8?q?=20=E2=80=94=20URL=20state,=20loader=20com=20v=C3=ADnculos=20e?= =?UTF-8?q?=20invariante=20de=20nome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 2 do plano aprovado. Três módulos em src/utilities/contacts/: - contactListUrl: contrato de params (q|gender|state|city|ausencia|vinculo|sort|dir|page), canonicalize, hrefs de sort e where de fonte com 'phones.value' like (ILIKE verificado no Postgres local). - contactListData: loader com UM find em contact (user threadado, overrideAccess false), vínculos (liderancas/dobradinhas/assessores/equipe) como facets sobre o conjunto legível, filtro/sort/paginação em memória, nulos no fim (precedente B15). - contactNameInvariant: lock contact-name:{normalized} + prefilter ILIKE + veredito exato em memória; mensagem de conflito pt-BR. 38 testes unit novos; format + tsc limpos. --- ...ontatos-pagina-da-entidade-contact-impl.md | 252 ++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 1 + .../{contatos => meus-contatos}/page.tsx | 4 +- src/components/campaign/shell/nav.ts | 3 + src/lib/campaignPageChrome.ts | 17 +- src/lib/campaignPaths.ts | 7 +- src/lib/campaignQuickActionMount.ts | 8 +- src/lib/leaderContactsQuickActions.ts | 2 +- src/utilities/campaignPageActor.ts | 4 +- src/utilities/contacts/contactListData.ts | 312 ++++++++++++++++++ src/utilities/contacts/contactListUrl.ts | 263 +++++++++++++++ .../contacts/contactNameInvariant.ts | 57 ++++ tests/e2e/campaignBottomNav.e2e.spec.ts | 4 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 8 +- tests/e2e/campaignPeople.e2e.spec.ts | 8 +- tests/e2e/campaignRegisterDemand.e2e.spec.ts | 2 +- tests/e2e/campaignTerritoriesHttp.e2e.spec.ts | 6 +- tests/e2e/setup.e2e.spec.ts | 3 + tests/unit/campaignPageChrome.unit.spec.ts | 12 + tests/unit/campaignQuickAction.unit.spec.ts | 22 +- tests/unit/contactListData.unit.spec.ts | 234 +++++++++++++ tests/unit/contactListUrl.unit.spec.ts | 206 ++++++++++++ tests/unit/contactNameInvariant.unit.spec.ts | 63 ++++ 23 files changed, 1470 insertions(+), 28 deletions(-) create mode 100644 docs/plans/contatos-pagina-da-entidade-contact-impl.md rename src/app/(campaign)/campanha/(app)/{contatos => meus-contatos}/page.tsx (89%) create mode 100644 src/utilities/contacts/contactListData.ts create mode 100644 src/utilities/contacts/contactListUrl.ts create mode 100644 src/utilities/contacts/contactNameInvariant.ts create mode 100644 tests/unit/contactListData.unit.spec.ts create mode 100644 tests/unit/contactListUrl.unit.spec.ts create mode 100644 tests/unit/contactNameInvariant.unit.spec.ts diff --git a/docs/plans/contatos-pagina-da-entidade-contact-impl.md b/docs/plans/contatos-pagina-da-entidade-contact-impl.md new file mode 100644 index 00000000..3dce6046 --- /dev/null +++ b/docs/plans/contatos-pagina-da-entidade-contact-impl.md @@ -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
gate staff] --> D[loadContactListPageData] + D --> F[ContactFilters
omnibox adapter] + D --> T[CampaignTable
células permanent] + D --> C[MobileCards + EditSheet + CreateSheet] + end + subgraph Ações + A1[updateContactField
blur-save célula] --> TX[withPayloadTransaction] + A2[updateContactFull
sheet mobile] --> TX + A3[createContactAction
linha nova / FAB] --> TX + A4[deleteContactAction
manifest C100] --> TX + TX --> N[assertContactNameAvailable
advisory lock contact-name] + TX --> S[assertContactEditable
canReadContacts] + end + subgraph Movida leader + M[meus-contatos/page.tsx
= antigo contatos] --> P[LEADER_CONTACTS_HOME
= /campanha/meus-contatos] + P --> CH[campaignPageChrome
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). diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 4dda1e14..d5a6ff3a 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -164,6 +164,7 @@ export const E2E_AFFECTED_MANIFEST = [ `${CAMPAIGN_APP}/apoiadores`, `${CAMPAIGN_APP}/assessores`, `${CAMPAIGN_APP}/atualizacoes`, + `${CAMPAIGN_APP}/meus-contatos`, `${CAMPAIGN_APP}/contatos`, `${CAMPAIGN_APP}/demandas`, `${CAMPAIGN_APP}/dobradinhas`, diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx similarity index 89% rename from src/app/(campaign)/campanha/(app)/contatos/page.tsx rename to src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx index 99deaba5..b05e4939 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx @@ -10,9 +10,9 @@ import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' export const dynamic = 'force-dynamic' -export const metadata = campaignPageMetadataFromCatalog('contatos') +export const metadata = campaignPageMetadataFromCatalog('meusContatos') -export default async function CampaignContatosPage() { +export default async function CampaignMeusContatosPage() { const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()]) if (!isCampaignLeader(user)) { diff --git a/src/components/campaign/shell/nav.ts b/src/components/campaign/shell/nav.ts index ba3152ee..4324d568 100644 --- a/src/components/campaign/shell/nav.ts +++ b/src/components/campaign/shell/nav.ts @@ -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 }, diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index 238d1b83..6c7fb1db 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -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), diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index 96983b68..d38a2bcc 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -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 diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index cf56febc..f64a11b4 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -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) } diff --git a/src/lib/leaderContactsQuickActions.ts b/src/lib/leaderContactsQuickActions.ts index 0d6eaf0f..0c8a25f4 100644 --- a/src/lib/leaderContactsQuickActions.ts +++ b/src/lib/leaderContactsQuickActions.ts @@ -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 diff --git a/src/utilities/campaignPageActor.ts b/src/utilities/campaignPageActor.ts index 503ac149..91fa22bd 100644 --- a/src/utilities/campaignPageActor.ts +++ b/src/utilities/campaignPageActor.ts @@ -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 diff --git a/src/utilities/contacts/contactListData.ts b/src/utilities/contacts/contactListData.ts new file mode 100644 index 00000000..fe8a0c1a --- /dev/null +++ b/src/utilities/contacts/contactListData.ts @@ -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 + +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. + */ +export const loadContactVinculos = async ( + payload: Payload, + user: CampaignUser, + readableContactIDs: ReadonlySet, +): Promise => { + const byID = new Map>() + 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() + 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 => 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(state.genders ?? []) + const states = new Set(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() + for (const city of state.cities ?? []) { + cities.set(city.toLowerCase(), city) + } + const vinculos = new Set(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 } +} diff --git a/src/utilities/contacts/contactListUrl.ts b/src/utilities/contacts/contactListUrl.ts new file mode 100644 index 00000000..17a8f882 --- /dev/null +++ b/src/utilities/contacts/contactListUrl.ts @@ -0,0 +1,263 @@ +/** + * 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 = { + 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 = { + 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 = { + 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 = { + 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(contactListParamNames) + +const contactGenderSet = new Set(leadershipGenders) +const contactStateSet = new Set(Object.keys(CitiesByState)) +const contactAbsenceSet = new Set(CONTACT_ABSENCES) +const contactVinculoSet = new Set(CONTACT_VINCULOS) +const contactListSortKeySet = new Set(Object.keys(contactListSortLabels)) +const contactListSortDirSet = new Set(['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({ + 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 } }, + ], + } +} diff --git a/src/utilities/contacts/contactNameInvariant.ts b/src/utilities/contacts/contactNameInvariant.ts new file mode 100644 index 00000000..d3596285 --- /dev/null +++ b/src/utilities/contacts/contactNameInvariant.ts @@ -0,0 +1,57 @@ +import 'server-only' + +import type { Payload, PayloadRequest } from 'payload' + +import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' + +export const CONTACT_NAME_CONFLICT_MESSAGE = + 'Já existe um contato com este nome — confira a lista antes de salvar.' + +type ContactNameRequest = Pick + +/** + * 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, + req: ContactNameRequest, + name: string, + contactID?: number, +): Promise => { + 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) +} diff --git a/tests/e2e/campaignBottomNav.e2e.spec.ts b/tests/e2e/campaignBottomNav.e2e.spec.ts index aacd9c42..4cf2d06a 100644 --- a/tests/e2e/campaignBottomNav.e2e.spec.ts +++ b/tests/e2e/campaignBottomNav.e2e.spec.ts @@ -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"]') diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index e98dbb8d..490fa1ac 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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() diff --git a/tests/e2e/campaignPeople.e2e.spec.ts b/tests/e2e/campaignPeople.e2e.spec.ts index 3ba46d9e..0e4f4c30 100644 --- a/tests/e2e/campaignPeople.e2e.spec.ts +++ b/tests/e2e/campaignPeople.e2e.spec.ts @@ -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 ({ diff --git a/tests/e2e/campaignRegisterDemand.e2e.spec.ts b/tests/e2e/campaignRegisterDemand.e2e.spec.ts index 4e0551f7..f83cf6da 100644 --- a/tests/e2e/campaignRegisterDemand.e2e.spec.ts +++ b/tests/e2e/campaignRegisterDemand.e2e.spec.ts @@ -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) }) }) diff --git a/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts b/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts index c87d3957..9fddba33 100644 --- a/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts +++ b/tests/e2e/campaignTerritoriesHttp.e2e.spec.ts @@ -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"') }) diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index 0dded7b1..94991fef 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -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', diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts index 5c789a8f..74593594 100644 --- a/tests/unit/campaignPageChrome.unit.spec.ts +++ b/tests/unit/campaignPageChrome.unit.spec.ts @@ -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.', + }) + }) }) diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index dc9c9b49..bd900c2d 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -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)', () => { diff --git a/tests/unit/contactListData.unit.spec.ts b/tests/unit/contactListData.unit.spec.ts new file mode 100644 index 00000000..e444dc92 --- /dev/null +++ b/tests/unit/contactListData.unit.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' + +import { + contactFilterFacetsFromRows, + filterContactRows, + sortContactRows, + toContactRowViewModel, + type ContactRowViewModel, +} from '@/utilities/contacts/contactListData' +import type { ContactListState } from '@/utilities/contacts/contactListUrl' + +const baseRow = (overrides: Partial = {}): 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 never) + 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 never) + 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 => ({ + 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) + }) +}) diff --git a/tests/unit/contactListUrl.unit.spec.ts b/tests/unit/contactListUrl.unit.spec.ts new file mode 100644 index 00000000..e81d40cc --- /dev/null +++ b/tests/unit/contactListUrl.unit.spec.ts @@ -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' } }, + ], + }) + }) +}) diff --git a/tests/unit/contactNameInvariant.unit.spec.ts b/tests/unit/contactNameInvariant.unit.spec.ts new file mode 100644 index 00000000..c40bf05c --- /dev/null +++ b/tests/unit/contactNameInvariant.unit.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + assertContactNameAvailable, + CONTACT_NAME_CONFLICT_MESSAGE, + 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', () => { + const makePayload = (docs: { name: string; id?: number }[]) => ({ + db: { + name: 'postgres', + sessions: { tx: { db: { execute: vi.fn() } } }, + }, + find: vi.fn().mockImplementation(async ({ where }: { where: any }) => { + const conditions: any[] = where?.and ?? [] + const idExclusion = conditions.find((c) => c.id?.not_equals)?.id.not_equals + const like = conditions.find((c) => c.name?.like)?.name.like as string | undefined + 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 } + }), + }) + + it('passes when no stored contact normalizes to the same name', async () => { + const payload = makePayload([{ id: 9, name: 'Maria Souza' }]) + await expect( + assertContactNameAvailable(payload as never, { 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 as never, {}, '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 as never, {}, '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 as never, {}, 'Maria')).resolves.toBeUndefined() + }) +}) From e71b06265072ba26a576df198534a2a73b165459 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:12:53 -0300 Subject: [PATCH 2/9] =?UTF-8?q?feat(C139):=20schemas=20e=20actions=20de=20?= =?UTF-8?q?contato=20=E2=80=94=20cria=C3=A7=C3=A3o=20staff=20e=20edi=C3=A7?= =?UTF-8?q?=C3=A3o=20por=20c=C3=A9lula?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase 3 (server) do plano aprovado: - schemas/contact.ts: contactCreateSchema (nome único via invariante, state obrigatório, phones opcionais C112); contactFieldUpdateSchema ganha variantes city e postalCode ('' → clear); mensagens das actions client-safe aqui. - schemas/personGenders.ts: enum de gênero em módulo folha (ciclo de imports contact ↔ leadership detectado e quebrado). - actions/contact.ts: createContact (gate staff + bypass justificado, lock contact-name:{normalized}) e updateContactField (escopo 'edita o que vê' via re-check user-threaded para advisor; reorder de telefone primário). Apagar reusa as actions C100 existentes. - collections Contact e Supporter: label de gênero 'Outro' → 'Não binário' (sem migration; nenhum teste pina a label). - 11 testes unit novos; lint, tsc e prettier limpos. --- .../(campaign)/campanha/actions/contact.ts | 170 ++++++++++++++++++ src/collections/Contact.ts | 2 +- src/collections/Supporter.ts | 2 +- src/lib/schemas/contact.ts | 110 +++++++++--- src/lib/schemas/leadership.ts | 3 +- src/lib/schemas/personGenders.ts | 9 + src/utilities/contacts/contactListUrl.ts | 1 - .../contacts/contactNameInvariant.ts | 4 +- tests/unit/contactListData.unit.spec.ts | 6 +- tests/unit/contactNameInvariant.unit.spec.ts | 31 ++-- tests/unit/contactSchemas.unit.spec.ts | 105 +++++++++++ 11 files changed, 392 insertions(+), 51 deletions(-) create mode 100644 src/app/(campaign)/campanha/actions/contact.ts create mode 100644 src/lib/schemas/personGenders.ts create mode 100644 tests/unit/contactSchemas.unit.spec.ts diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts new file mode 100644 index 00000000..abd1ec1d --- /dev/null +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -0,0 +1,170 @@ +'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, + type ContactCreateInput, + type ContactFieldUpdateInput, +} 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 => { + 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) +} + +export 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> & { + 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 'phones': + return { phones: data.phones.map((value) => ({ value })) } + case 'phone': + return { + phones: data.phone === null || data.phone === undefined ? [] : [{ value: data.phone }], + } + } +} + +export const updateContactFieldRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ContactFieldUpdateInput, +): Promise => { + 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') +} diff --git a/src/collections/Contact.ts b/src/collections/Contact.ts index 743e3e7f..e7632362 100644 --- a/src/collections/Contact.ts +++ b/src/collections/Contact.ts @@ -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' }, ], }, diff --git a/src/collections/Supporter.ts b/src/collections/Supporter.ts index 260ada68..5a055963 100644 --- a/src/collections/Supporter.ts +++ b/src/collections/Supporter.ts @@ -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' }, ], }, { diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index 768fd0e4..01d06afe 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -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( - (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). */ +export 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( + (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( + (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 + +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,19 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [ field: z.literal('phones'), phones: contactPhonesSchema, }), + // 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 diff --git a/src/lib/schemas/leadership.ts b/src/lib/schemas/leadership.ts index 6a3aaa5f..2655b998 100644 --- a/src/lib/schemas/leadership.ts +++ b/src/lib/schemas/leadership.ts @@ -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', diff --git a/src/lib/schemas/personGenders.ts b/src/lib/schemas/personGenders.ts new file mode 100644 index 00000000..168b2711 --- /dev/null +++ b/src/lib/schemas/personGenders.ts @@ -0,0 +1,9 @@ +/** + * 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 + +export type PersonGender = (typeof personGenders)[number] diff --git a/src/utilities/contacts/contactListUrl.ts b/src/utilities/contacts/contactListUrl.ts index 17a8f882..4445647b 100644 --- a/src/utilities/contacts/contactListUrl.ts +++ b/src/utilities/contacts/contactListUrl.ts @@ -36,7 +36,6 @@ export const contactGenderLabels: Record = { 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] diff --git a/src/utilities/contacts/contactNameInvariant.ts b/src/utilities/contacts/contactNameInvariant.ts index d3596285..ecffafd9 100644 --- a/src/utilities/contacts/contactNameInvariant.ts +++ b/src/utilities/contacts/contactNameInvariant.ts @@ -2,11 +2,9 @@ import 'server-only' import type { Payload, PayloadRequest } from 'payload' +import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' -export const CONTACT_NAME_CONFLICT_MESSAGE = - 'Já existe um contato com este nome — confira a lista antes de salvar.' - type ContactNameRequest = Pick /** diff --git a/tests/unit/contactListData.unit.spec.ts b/tests/unit/contactListData.unit.spec.ts index e444dc92..e382f747 100644 --- a/tests/unit/contactListData.unit.spec.ts +++ b/tests/unit/contactListData.unit.spec.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import type { Contact } from '@/payload-types' + import { contactFilterFacetsFromRows, filterContactRows, @@ -56,7 +58,7 @@ describe('toContactRowViewModel', () => { state: 'BA', city: 'Camaçari', postalCode: '42800-000', - } as never) + } as unknown as Contact) expect(view).toEqual({ contactID: 7, name: ' Maria ', @@ -80,7 +82,7 @@ describe('toContactRowViewModel', () => { state: 'BAHIA', city: null, postalCode: null, - } as never) + } as unknown as Contact) expect(view).toEqual({ contactID: 8, name: 'Sem Dados', diff --git a/tests/unit/contactNameInvariant.unit.spec.ts b/tests/unit/contactNameInvariant.unit.spec.ts index c40bf05c..bec6e729 100644 --- a/tests/unit/contactNameInvariant.unit.spec.ts +++ b/tests/unit/contactNameInvariant.unit.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest' +import type { Payload } from 'payload' + +import { CONTACT_NAME_CONFLICT_MESSAGE } from '@/lib/schemas/contact' import { assertContactNameAvailable, - CONTACT_NAME_CONFLICT_MESSAGE, normalizeContactName, } from '@/utilities/contacts/contactNameInvariant' @@ -15,28 +17,31 @@ describe('normalizeContactName', () => { }) describe('assertContactNameAvailable', () => { - const makePayload = (docs: { name: string; id?: number }[]) => ({ + type WhereClause = { + and?: { name?: { like?: string }; id?: { not_equals?: number } }[] + } + const makePayload = (docs: { name: string; id?: number }[]): Pick => ({ db: { name: 'postgres', sessions: { tx: { db: { execute: vi.fn() } } }, - }, - find: vi.fn().mockImplementation(async ({ where }: { where: any }) => { - const conditions: any[] = where?.and ?? [] - const idExclusion = conditions.find((c) => c.id?.not_equals)?.id.not_equals - const like = conditions.find((c) => c.name?.like)?.name.like as string | undefined + } 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 as never, { transactionID: 'tx' }, ' maria silva '), + 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() @@ -44,20 +49,18 @@ describe('assertContactNameAvailable', () => { 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 as never, {}, 'MARIA SILVA')).rejects.toThrow( + 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 as never, {}, 'Maria Silva', 9), - ).resolves.toBeUndefined() + 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 as never, {}, 'Maria')).resolves.toBeUndefined() + await expect(assertContactNameAvailable(payload, {}, 'Maria')).resolves.toBeUndefined() }) }) diff --git a/tests/unit/contactSchemas.unit.spec.ts b/tests/unit/contactSchemas.unit.spec.ts new file mode 100644 index 00000000..25ef73ad --- /dev/null +++ b/tests/unit/contactSchemas.unit.spec.ts @@ -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) + }) +}) From 621364f41fba9289c2382bd8682acb66ce1f95e5 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 14:29:11 -0300 Subject: [PATCH 3/9] =?UTF-8?q?feat(C139):=20p=C3=A1gina=20/campanha/conta?= =?UTF-8?q?tos=20=E2=80=94=20lista=20desktop=20com=20c=C3=A9lulas=20edit?= =?UTF-8?q?=C3=A1veis,=20cria=C3=A7=C3=A3o=20e=20filtros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/lib/e2e-affected-manifest.mjs | 2 + .../campanha/(app)/contatos/formActions.ts | 105 +++++ .../campanha/(app)/contatos/page.tsx | 419 ++++++++++++++++++ .../(campaign)/campanha/actions/contact.ts | 10 +- .../campaign/contacts/ContactCreateRow.tsx | 212 +++++++++ .../campaign/contacts/ContactCreateState.tsx | 44 ++ .../campaign/contacts/ContactFilters.tsx | 100 +++++ .../campaign/contacts/ContactPhonesCell.tsx | 138 ++++++ .../campaign/contacts/ContactSelectCell.tsx | 118 +++++ .../campaign/contacts/ContactSortableHead.tsx | 49 ++ .../campaign/people/DeletePersonButton.tsx | 40 +- .../shared/CampaignInlineEditableCell.tsx | 21 +- src/lib/campaignColumnVisibility.ts | 6 + src/lib/schemas/contact.ts | 17 +- src/lib/schemas/personGenders.ts | 2 - src/utilities/contacts/contactListData.ts | 2 +- src/utilities/contacts/contactListFilters.ts | 141 ++++++ src/utilities/contacts/contactOmnibox.ts | 277 ++++++++++++ tests/unit/contactListFilters.unit.spec.ts | 204 +++++++++ 19 files changed, 1885 insertions(+), 22 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/contatos/formActions.ts create mode 100644 src/app/(campaign)/campanha/(app)/contatos/page.tsx create mode 100644 src/components/campaign/contacts/ContactCreateRow.tsx create mode 100644 src/components/campaign/contacts/ContactCreateState.tsx create mode 100644 src/components/campaign/contacts/ContactFilters.tsx create mode 100644 src/components/campaign/contacts/ContactPhonesCell.tsx create mode 100644 src/components/campaign/contacts/ContactSelectCell.tsx create mode 100644 src/components/campaign/contacts/ContactSortableHead.tsx create mode 100644 src/utilities/contacts/contactListFilters.ts create mode 100644 src/utilities/contacts/contactOmnibox.ts create mode 100644 tests/unit/contactListFilters.unit.spec.ts diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index d5a6ff3a..3b88a5b2 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -166,6 +166,8 @@ export const E2E_AFFECTED_MANIFEST = [ `${CAMPAIGN_APP}/atualizacoes`, `${CAMPAIGN_APP}/meus-contatos`, `${CAMPAIGN_APP}/contatos`, + 'src/components/campaign/contacts', + 'src/utilities/contacts', `${CAMPAIGN_APP}/demandas`, `${CAMPAIGN_APP}/dobradinhas`, `${CAMPAIGN_APP}/organizacoes`, diff --git a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts new file mode 100644 index 00000000..e26c8365 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts @@ -0,0 +1,105 @@ +'use server' + +import { createContact, updateContactField } from '@/app/(campaign)/campanha/actions/contact' +import { + nullableFormText, + repeatedPhoneFormValues, + requiredFormText, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone' +import { + CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, + CONTACT_CELL_STAFF_MESSAGE, + CONTACT_CREATED_MESSAGE, + CONTACT_NAME_CONFLICT_MESSAGE, +} from '@/lib/schemas/contact' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const contactCellSafeMessages = [ + CONTACT_CELL_STAFF_MESSAGE, + CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, + CONTACT_NAME_CONFLICT_MESSAGE, + BRAZILIAN_PHONE_DUPLICATE_MESSAGE, +] as const + +/** Per-field ficha edit for the contacts page cells (C139, C116 shape). */ +export const updateContactFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const id = requiredRelationshipFormValue(formData, 'id') + const field = requiredFormText(formData, 'field') + + if (field === 'name') { + await updateContactField({ id, field: 'name', name: requiredFormText(formData, 'name') }) + } else if (field === 'email') { + await updateContactField({ + id, + field: 'email', + email: nullableFormText(formData, 'email') ?? undefined, + }) + } else if (field === 'phone') { + await updateContactField({ + id, + field: 'phone', + phone: nullableFormText(formData, 'phone') ?? null, + }) + } else if (field === 'phones') { + await updateContactField({ + id, + field: 'phones', + phones: repeatedPhoneFormValues(formData, 'phones'), + }) + } else if (field === 'gender') { + await updateContactField({ + id, + field: 'gender', + gender: requiredFormText(formData, 'gender'), + }) + } else if (field === 'state') { + await updateContactField({ id, field: 'state', state: requiredFormText(formData, 'state') }) + } else if (field === 'city') { + await updateContactField({ id, field: 'city', city: requiredFormText(formData, 'city') }) + } else if (field === 'postalCode') { + await updateContactField({ + id, + field: 'postalCode', + postalCode: nullableFormText(formData, 'postalCode') ?? undefined, + }) + } else { + throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE) + } + + return { message: 'Salvo.' } + }, + safeMessages: contactCellSafeMessages, + genericMessage: 'Não foi possível salvar. Verifique os dados e tente novamente.', + }) + +/** The contacts page create ladder (desktop row / mobile sheet — C139). */ +export const createContactFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + runCampaignFormAction({ + execute: async () => { + const phone = nullableFormText(formData, 'phone') + const result = await createContact({ + name: requiredFormText(formData, 'name'), + email: nullableFormText(formData, 'email') ?? undefined, + phones: phone ? [phone] : [], + state: requiredFormText(formData, 'state'), + city: nullableFormText(formData, 'city') ?? undefined, + postalCode: nullableFormText(formData, 'postalCode') ?? undefined, + }) + return { message: CONTACT_CREATED_MESSAGE, contactID: result.contactID } + }, + safeMessages: [CONTACT_NAME_CONFLICT_MESSAGE], + genericMessage: 'Não foi possível criar o contato. Verifique os dados e tente novamente.', + }) diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx new file mode 100644 index 00000000..e56629e1 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -0,0 +1,419 @@ +import config from '@payload-config' +import { MessageCircleIcon, SearchXIcon } from 'lucide-react' +import { redirect } from 'next/navigation' +import { getPayload } from 'payload' +import type { ReactNode } from 'react' + +import { ContactCreateRow } from '@/components/campaign/contacts/ContactCreateRow' +import { ContactCreateProvider } from '@/components/campaign/contacts/ContactCreateState' +import { ContactFilters } from '@/components/campaign/contacts/ContactFilters' +import { ContactPhonesCell } from '@/components/campaign/contacts/ContactPhonesCell' +import { ContactSelectCell } from '@/components/campaign/contacts/ContactSelectCell' +import { ContactSortableHead } from '@/components/campaign/contacts/ContactSortableHead' +import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton' +import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell' +import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' +import { + CampaignListPendingBoundary, + CampaignListResults, + CampaignTransitionAnchor, +} from '@/components/campaign/shared/CampaignListPending' +import { + CampaignTable, + CampaignTableHead, + type CampaignTableColumn, +} from '@/components/campaign/shared/CampaignTable' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { CitiesByState } from '@/lib/cities' +import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' +import { cn } from '@/lib/utils' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' +import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { + loadContactListPageData, + type ContactRowViewModel, +} from '@/utilities/contacts/contactListData' +import { + buildContactFilterHref, + clearContactListFilters, + type ContactFilterOption, +} from '@/utilities/contacts/contactListFilters' +import { + buildContactListHref, + contactGenderLabels, + resolveContactListUrl, + type ContactGender, + type ContactListState, + type ContactStateKey, +} from '@/utilities/contacts/contactListUrl' + +import { createContactFormAction, updateContactFormAction } from './formActions' + +export const metadata = campaignPageMetadataFromCatalog('contatos') + +type ContactsPageProps = { + searchParams: Promise> +} + +const genderOptions: ReadonlyArray<{ value: ContactGender; label: string }> = ( + Object.keys(contactGenderLabels) as ContactGender[] +).map((value) => ({ value, label: contactGenderLabels[value] })) + +const stateOptions: ReadonlyArray<{ value: ContactStateKey; label: string }> = ( + Object.keys(CitiesByState) as ContactStateKey[] +).map((value) => ({ value, label: value })) + +const ContactsListEmptyState = ({ + hasFilters, + state, +}: { + hasFilters: boolean + state: ContactListState +}) => ( + + + + + Nenhum contato neste recorte + + Ajuste a busca e os filtros para ver as fichas da campanha. + + + {hasFilters ? ( + + + Limpar busca e filtros + + + ) : null} + +) + +const contactsColumns = ({ + state, + canDelete, +}: { + state: ContactListState + canDelete: boolean +}): Array> => [ + { + id: 'name', + label: 'Nome', + mandatory: true, + cellClassName: 'max-w-72', + head: , + cell: (row) => ( + + ), + }, + { + id: 'gender', + label: 'Gênero', + cellClassName: 'max-w-36', + cell: (row) => ( + + ), + }, + { + id: 'phone', + label: 'Telefone', + cellClassName: 'max-w-56', + cell: (row) => ( + + ), + }, + { + id: 'email', + label: 'E-mail', + cellClassName: 'max-w-56 whitespace-normal', + head: , + cell: (row) => ( + + ), + }, + { + id: 'city', + label: 'Cidade', + cellClassName: 'max-w-44 whitespace-normal', + head: , + cell: (row) => ( + + ), + }, + { + id: 'state', + label: 'Estado', + cellClassName: 'max-w-24', + head: , + cell: (row) => ( + + ), + }, + { + id: 'postalCode', + label: 'CEP', + cellClassName: 'max-w-28', + cell: (row) => ( + + ), + }, + { + id: 'actions', + label: 'Ações', + mandatory: true, + head: ( + + Ações + + ), + cellClassName: 'text-right', + cell: (row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null) + return ( +
+ {whatsAppHref ? ( + + + + ) : null} + {canDelete ? ( + + {/* C130 — span wrapper: `DeletePersonButton` is a component and + does not forward the tooltip trigger's ref/pointer props, so + the hover would never reach the trigger. */} + + + + + ) : null} +
+ ) + }, + }, +] + +/** Fase 4 interim mobile list — replaced by the cards/sheets in Fase 5. */ +const ContactsMobileList = ({ + rows, + canDelete, + empty, +}: { + rows: readonly ContactRowViewModel[] + canDelete: boolean + empty: ReactNode +}) => ( +
    + {rows.length === 0 ? ( +
  • {empty}
  • + ) : ( + rows.map((row) => { + const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null) + return ( +
  • +
    +

    {row.name}

    +

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

    +
    +
    + {whatsAppHref ? ( + + ) : null} + {canDelete ? ( + + ) : null} +
    +
  • + ) + }) + )} +
+) + +export default async function ContactsPage({ searchParams }: ContactsPageProps) { + const rawSearchParams = await searchParams + const canonicalUrl = resolveContactListUrl(rawSearchParams) + if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) + + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const [columnVisibility, listData] = await Promise.all([ + readCampaignColumnVisibility('contatos'), + loadContactListPageData(payload, user, canonicalUrl.state), + ]) + + const resolvedUrl = resolveContactListUrl(rawSearchParams, listData.totalPages) + if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) + const { state } = resolvedUrl + + const cityFilterOptions: ContactFilterOption[] = listData.filterFacets.cities.map((city) => ({ + value: city, + label: city, + })) + + const canDelete = isCampaignUnrestricted(user) + const columns = contactsColumns({ state, canDelete }) + const hasFilters = Boolean( + state.q || + state.genders?.length || + state.states?.length || + state.cities?.length || + state.ausencias?.length || + state.vinculos?.length, + ) + + return ( + + + + + } + /> + + + } + /> + + row.contactID} + empty={} + /> + {listData.rows.length ? ( + buildContactListHref(state, page)} + /> + ) : null} + + + + + ) +} diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts index abd1ec1d..8934c618 100644 --- a/src/app/(campaign)/campanha/actions/contact.ts +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -52,7 +52,7 @@ const assertContactRowEditable = async ( if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE) } -export const createContactRecord = async ( +const createContactRecord = async ( payload: Payload, actor: CampaignUser, input: ContactCreateInput, @@ -96,7 +96,7 @@ export const createContact = async (input: unknown) => { const contactFieldData = ( data: ContactFieldUpdateInput, -): Partial> & { +): Partial> & { phones?: { value: string }[] } => { switch (data.field) { @@ -109,6 +109,10 @@ const contactFieldData = ( case 'postalCode': // The schema transforms '' → undefined: clearing the CEP stores null. return { postalCode: data.postalCode ?? null } + case 'gender': + return { gender: data.gender } + case 'state': + return { state: data.state } case 'phones': return { phones: data.phones.map((value) => ({ value })) } case 'phone': @@ -118,7 +122,7 @@ const contactFieldData = ( } } -export const updateContactFieldRecord = async ( +const updateContactFieldRecord = async ( payload: Payload, actor: CampaignUser, input: ContactFieldUpdateInput, diff --git a/src/components/campaign/contacts/ContactCreateRow.tsx b/src/components/campaign/contacts/ContactCreateRow.tsx new file mode 100644 index 00000000..093cd807 --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateRow.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useActionState, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { useContactCreate } from '@/components/campaign/contacts/ContactCreateState' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Spinner } from '@/components/ui/Spinner' +import { CitiesByState } from '@/lib/cities' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' +import { cn } from '@/lib/utils' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' +import { firstFormActionMessage } from '@/utilities/campaignFormFields' + +const stateOptions = Object.keys(CitiesByState) + +const rowInputClassName = + 'border-border/60 bg-background shadow-none focus-visible:ring-1 focus-visible:ring-ring/50' + +type ContactCreateRowProps = { + /** The `contatos/formActions` create ladder (`runCampaignFormAction`). */ + formAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +/** + * C139 — the create row of the contacts table: an empty ficha at the top of + * the list (highlighted background, borderless inputs), mounted by the shared + * create store (omnibox button / mobile FAB). Salvar stays disabled until the + * name is filled; a conflict (duplicate name) keeps the typed values and + * shows the message inside the row; success resets, closes and refreshes. + */ +export const ContactCreateRow = ({ formAction }: ContactCreateRowProps) => { + const router = useRouter() + const { open, setOpen } = useContactCreate() + const formRef = useRef(null) + const [hasName, setHasName] = useState(false) + const [phone, setPhone] = useState('') + const [formActionState, submitAction, isPending] = useActionState< + CampaignFormActionState, + FormData + >(formAction, {}) + const errorMessage = + formActionState.status === 'success' ? undefined : firstFormActionMessage(formActionState) + + useEffect(() => { + if (formActionState.status === 'success') { + toast.success(formActionState.message) + formRef.current?.reset() + setHasName(false) + setPhone('') + setOpen(false) + router.refresh() + } + }, [formActionState, router, setOpen]) + + if (!open) return null + + return ( +
+
+
+ + setHasName(event.currentTarget.value.trim().length >= 2)} + /> +
+
+ + + setPhone( + formatBrazilianPhoneInput(sanitizeBrazilianPhoneInput(event.currentTarget.value)), + ) + } + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) +} diff --git a/src/components/campaign/contacts/ContactCreateState.tsx b/src/components/campaign/contacts/ContactCreateState.tsx new file mode 100644 index 00000000..401f6567 --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateState.tsx @@ -0,0 +1,44 @@ +'use client' + +import { PlusIcon } from 'lucide-react' +import { createContext, useContext, useState, type ReactNode } from 'react' + +import { Button } from '@/components/ui/button' + +/** + * C139 — the contacts create surface. One tiny client store shared by the + * desktop row (inside the table container) and the mobile FAB: opening from + * either mounts the same row. The row itself is `ContactCreateRow`. + */ +type ContactCreateState = { + open: boolean + setOpen: (open: boolean) => void +} + +const ContactCreateContext = createContext(null) + +export const ContactCreateProvider = ({ children }: { children: ReactNode }) => { + const [open, setOpen] = useState(false) + return ( + + {children} + + ) +} + +export const useContactCreate = (): ContactCreateState => { + const context = useContext(ContactCreateContext) + if (!context) throw new Error('useContactCreate must be used within ContactCreateProvider') + return context +} + +/** Desktop trailing control of the omnibox (the mobile FAB is Fase 5). */ +export const ContactCreateButton = () => { + const { open, setOpen } = useContactCreate() + return ( + + ) +} diff --git a/src/components/campaign/contacts/ContactFilters.tsx b/src/components/campaign/contacts/ContactFilters.tsx new file mode 100644 index 00000000..2c870659 --- /dev/null +++ b/src/components/campaign/contacts/ContactFilters.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useMemo, useState, type ReactNode } from 'react' + +import { ContactCreateButton } from '@/components/campaign/contacts/ContactCreateState' +import { + CampaignListOmnibox, + campaignListOmniboxFormClassName, +} from '@/components/campaign/shared/CampaignListOmnibox' +import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation' +import { + buildContactFilterHref, + type ContactFilterOption, +} from '@/utilities/contacts/contactListFilters' +import type { ContactListState } from '@/utilities/contacts/contactListUrl' +import { + applyContactOmniboxSuggestion, + buildContactOmniboxChips, + buildContactOmniboxSuggestionSeeds, + clearContactOmnibox, + filterContactOmniboxSuggestions, + removeContactOmniboxChip, + type ContactOmniboxAction, +} from '@/utilities/contacts/contactOmnibox' + +export const ContactFilters = ({ + state, + cityFilterOptions, + trailing, +}: { + state: ContactListState + cityFilterOptions: ContactFilterOption[] + trailing?: ReactNode +}) => { + const { navigate, isPending } = useCampaignListFilterNavigation({ + state, + toHref: buildContactFilterHref, + }) + const [query, setQuery] = useState('') + + const chips = useMemo(() => buildContactOmniboxChips(state), [state]) + + const suggestionSeeds = useMemo( + () => buildContactOmniboxSuggestionSeeds({ cityFilterOptions }), + [cityFilterOptions], + ) + + const suggestions = useMemo( + () => filterContactOmniboxSuggestions(suggestionSeeds, query), + [suggestionSeeds, query], + ) + + const runAction = (action: ContactOmniboxAction) => { + if (action.kind === 'clear') { + setQuery('') + navigate(action.state) + return + } + navigate(action.state) + } + + return ( +
{ + event.preventDefault() + }} + > + { + runAction(applyContactOmniboxSuggestion({ state, suggestionId })) + }} + onCommitQuery={(text) => { + runAction(applyContactOmniboxSuggestion({ state, suggestionId: `q:${text}` })) + }} + onRemoveChip={(chipId) => { + runAction(removeContactOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearContactOmnibox(state)) + }} + trailing={ + <> + {trailing} + + + } + /> + + ) +} diff --git a/src/components/campaign/contacts/ContactPhonesCell.tsx b/src/components/campaign/contacts/ContactPhonesCell.tsx new file mode 100644 index 00000000..d96f9dcf --- /dev/null +++ b/src/components/campaign/contacts/ContactPhonesCell.tsx @@ -0,0 +1,138 @@ +'use client' + +import { PlusIcon } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState, type KeyboardEvent } from 'react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { campaignInlineInputClassName } from '@/lib/campaignInlineInput' +import { formatBrazilianPhoneInput, sanitizeBrazilianPhoneInput } from '@/lib/phone' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type ContactPhonesCellProps = { + recordId: number + /** Stored order = priority (first is primary) — C112 contract. */ + phones: readonly string[] + formAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise +} + +const sanitize = (raw: string): string => sanitizeBrazilianPhoneInput(raw) + +/** + * C139 — the phone cell of the contacts table: stacked borderless inputs with + * the phone mask. Blur/Enter on ANY line commits the WHOLE list (order = + * priority), empty lines are discarded before the schema; Escape discards the + * draft and "+" appends a line. The commit is the ficha's full `phones` array + * (`field: 'phones'`) — a single value is never written alone. + */ +export const ContactPhonesCell = ({ recordId, phones, formAction }: ContactPhonesCellProps) => { + const router = useRouter() + const [draft, setDraft] = useState(() => phones.map(formatBrazilianPhoneInput)) + const [isPending, setIsPending] = useState(false) + + const clean = (lines: string[]): string[] => { + const seen = new Set() + const result: string[] = [] + for (const line of lines) { + const value = sanitize(line) + if (!value || seen.has(value)) continue + seen.add(value) + result.push(value) + } + return result + } + + const dirty = (lines: string[]): boolean => { + const cleaned = clean(lines) + if (cleaned.length !== phones.length) return true + return cleaned.some((value, index) => value !== sanitize(phones[index] ?? '')) + } + + const commit = (lines: string[]) => { + if (isPending) return + if (!dirty(lines)) return + + const formData = new FormData() + formData.set('id', String(recordId)) + formData.set('field', 'phones') + for (const value of clean(lines)) formData.append('phones', value) + + setIsPending(true) + void formAction({}, formData) + .then((result) => { + if (result.status === 'success') { + setDraft(clean(lines).map(formatBrazilianPhoneInput)) + router.refresh() + return + } + const message = + result.fieldErrors?.phones?.[0] ?? result.message ?? 'Não foi possível salvar.' + setDraft(phones.map(formatBrazilianPhoneInput)) + toast.error(message) + }) + .catch(() => { + setDraft(phones.map(formatBrazilianPhoneInput)) + toast.error('Não foi possível salvar. Tente novamente.') + }) + .finally(() => { + setIsPending(false) + }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + setDraft(phones.map(formatBrazilianPhoneInput)) + return + } + if (event.key === 'Enter') { + event.preventDefault() + commit(draft) + event.currentTarget.blur() + } + } + + return ( +
+ {draft.map((line, index) => ( + { + const next = [...draft] + next[index] = formatBrazilianPhoneInput(sanitize(event.currentTarget.value)) + setDraft(next) + }} + onBlur={() => commit(draft)} + onKeyDown={handleKeyDown} + /> + ))} + {draft.length === 0 ? ( + Sem telefone + ) : null} + +
+ ) +} diff --git a/src/components/campaign/contacts/ContactSelectCell.tsx b/src/components/campaign/contacts/ContactSelectCell.tsx new file mode 100644 index 00000000..9f963db1 --- /dev/null +++ b/src/components/campaign/contacts/ContactSelectCell.tsx @@ -0,0 +1,118 @@ +'use client' + +import { ChevronDownIcon } from 'lucide-react' +import { useRouter } from 'next/navigation' +import { useState, type ChangeEvent } from 'react' +import { toast } from 'sonner' + +import { cn } from '@/lib/utils' +import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' + +type ContactSelectField = 'gender' | 'state' + +type ContactSelectOption = { + value: string + label: string +} + +type ContactSelectCellProps = { + recordId: number + field: ContactSelectField + value: string | null + label: string + options: readonly ContactSelectOption[] + /** + * The label of the "empty" option shown when `value` is null (the ficha has + * no gender recorded). State is required on the collection, so the empty + * option never appears there. + */ + emptyLabel?: string + formAction: ( + state: CampaignFormActionState, + formData: FormData, + ) => Promise + className?: string +} + +/** + * C139 — the gender/state cells of the contacts table: a native select styled + * as a borderless text cell. Changing the option commits immediately through + * the same per-cell ladder as the text cells (`field: gender|state`); a failed + * commit reverts the select and toasts the message (the people-cell error + * pattern). + */ +export const ContactSelectCell = ({ + recordId, + field, + value, + label, + options, + emptyLabel = '—', + formAction, + className, +}: ContactSelectCellProps) => { + const router = useRouter() + const [selected, setSelected] = useState(value ?? '') + const [isPending, setIsPending] = useState(false) + + const commit = async (next: string) => { + if (next === (value ?? '') || next === '') return + setIsPending(true) + const formData = new FormData() + formData.set('id', String(recordId)) + formData.set('field', field) + formData.set(field, next) + try { + const result = await formAction({}, formData) + if (result.status === 'success') { + router.refresh() + return + } + const message = + result.fieldErrors?.[field]?.[0] ?? result.message ?? 'Não foi possível salvar.' + setSelected(value ?? '') + toast.error(message) + } catch { + setSelected(value ?? '') + toast.error('Não foi possível salvar. Tente novamente.') + } finally { + setIsPending(false) + } + } + + const handleChange = (event: ChangeEvent) => { + setSelected(event.currentTarget.value) + void commit(event.currentTarget.value) + } + + const hasValue = value !== null && value !== '' + + return ( +
+ +
+ ) +} diff --git a/src/components/campaign/contacts/ContactSortableHead.tsx b/src/components/campaign/contacts/ContactSortableHead.tsx new file mode 100644 index 00000000..0a05e7f6 --- /dev/null +++ b/src/components/campaign/contacts/ContactSortableHead.tsx @@ -0,0 +1,49 @@ +'use client' + +import type { ReactNode } from 'react' + +import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead' +import { + buildContactSortHref, + contactListSortLabels, + defaultContactListSortDir, + resolveContactListSort, + type ContactListSortKey, + type ContactListState, +} from '@/utilities/contacts/contactListUrl' + +type ContactSortableHeadProps = { + state: ContactListState + sortKey: ContactListSortKey + children?: ReactNode + align?: 'left' | 'center' | 'right' + className?: string +} + +/** Sortable header for the contacts table (C139). Filters live in the + * omnibox only — no header filter popovers (people precedent, C117). */ +export const ContactSortableHead = ({ + state, + sortKey, + children, + align = 'left', + className, +}: ContactSortableHeadProps) => { + const { sort: activeSort, dir } = resolveContactListSort(state) + const active = activeSort === sortKey + const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultContactListSortDir(sortKey) + + return ( + + {children ?? contactListSortLabels[sortKey]} + + ) +} diff --git a/src/components/campaign/people/DeletePersonButton.tsx b/src/components/campaign/people/DeletePersonButton.tsx index 92f9e109..4d4f2798 100644 --- a/src/components/campaign/people/DeletePersonButton.tsx +++ b/src/components/campaign/people/DeletePersonButton.tsx @@ -37,6 +37,12 @@ type DeletePersonButtonProps = { * navigate away — the ficha no longer exists. */ deletedHref?: string + /** + * C139 — the contacts page reuses the C100 dialog with its own vocabulary + * ("contato" ficha instead of "pessoa"); the manifest and the cascade are + * the same (the delete contract is contactId-based). + */ + vocabulary?: 'pessoa' | 'contato' } type ManifestState = @@ -45,17 +51,23 @@ type ManifestState = | { kind: 'ready'; manifest: PersonDeleteManifest } | { kind: 'error'; message: string } -const mapManifestError = (error: unknown): string => { +const mapManifestError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => { const message = error instanceof Error ? error.message : '' - if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.' + if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) { + return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.' + } return 'Não foi possível carregar o que será apagado. Tente de novo.' } -const mapDeleteError = (error: unknown): string => { +const mapDeleteError = (error: unknown, vocabulary: 'pessoa' | 'contato'): string => { const message = error instanceof Error ? error.message : '' if (message === PERSON_DELETE_PROTECTED_ACCOUNT_MESSAGE) return message - if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) return 'Pessoa não encontrada.' - return 'Não foi possível apagar a pessoa. Tente de novo.' + if (message === PERSON_DELETE_NOT_FOUND_MESSAGE) { + return vocabulary === 'contato' ? 'Contato não encontrado.' : 'Pessoa não encontrada.' + } + return vocabulary === 'contato' + ? 'Não foi possível apagar o contato. Tente de novo.' + : 'Não foi possível apagar a pessoa. Tente de novo.' } /** @@ -69,6 +81,7 @@ export const DeletePersonButton = ({ personName, contactId, deletedHref, + vocabulary = 'pessoa', }: DeletePersonButtonProps) => { const router = useRouter() const [manifest, setManifest] = useState({ kind: 'idle' }) @@ -87,11 +100,11 @@ export const DeletePersonButton = ({ ? { kind: 'ready', manifest: next } : { kind: 'error', - message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE)), + message: mapManifestError(new Error(PERSON_DELETE_NOT_FOUND_MESSAGE), vocabulary), }, ) } catch (error) { - setManifest({ kind: 'error', message: mapManifestError(error) }) + setManifest({ kind: 'error', message: mapManifestError(error, vocabulary) }) } } @@ -101,12 +114,13 @@ export const DeletePersonButton = ({ setDeleteError('') try { const result = await deletePersonAction({ contactId }) + const deletedCopy = vocabulary === 'contato' ? 'Contato apagado.' : 'Pessoa apagada.' toast.success( result.contactDeleted - ? 'Pessoa apagada.' + ? deletedCopy : result.contactAnonymized - ? 'Pessoa apagada. A ficha foi anonimizada porque ainda é referenciada por participações públicas.' - : 'Pessoa apagada.', + ? `${deletedCopy} A ficha foi anonimizada porque ainda é referenciada por participações públicas.` + : deletedCopy, ) if (deletedHref) { router.push(deletedHref) @@ -114,7 +128,7 @@ export const DeletePersonButton = ({ router.refresh() } } catch (error) { - setDeleteError(mapDeleteError(error)) + setDeleteError(mapDeleteError(error, vocabulary)) } finally { setDeleting(false) } @@ -129,14 +143,14 @@ export const DeletePersonButton = ({ variant="ghost" size="icon" className="size-10 text-destructive hover:text-destructive" - aria-label={`Apagar pessoa ${personName}`} + aria-label={`Apagar ${vocabulary} ${personName}`} > - Apagar pessoa + Apagar {vocabulary} Esta ação não pode ser desfeita. Serão removidos todos os vínculos de{' '} {personName} com a campanha. diff --git a/src/components/campaign/shared/CampaignInlineEditableCell.tsx b/src/components/campaign/shared/CampaignInlineEditableCell.tsx index 08c587b0..b1e8061f 100644 --- a/src/components/campaign/shared/CampaignInlineEditableCell.tsx +++ b/src/components/campaign/shared/CampaignInlineEditableCell.tsx @@ -20,7 +20,17 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro const SAVE_DEBOUNCE_MS = 500 -export type CampaignInlineEditableField = 'name' | 'email' | 'phone' | 'party' | 'ballotName' +// C139 — the contacts page adds the ficha's own text cells: `city` and +// `postalCode` (same blur-save contract; the placeholder prop lets the cell +// spell the empty state, e.g. "Sem cidade"). +export type CampaignInlineEditableField = + | 'name' + | 'email' + | 'phone' + | 'party' + | 'ballotName' + | 'city' + | 'postalCode' type CampaignInlineEditableCellProps = { recordId: number @@ -45,6 +55,12 @@ type CampaignInlineEditableCellProps = { * typing edits in the same place (mechanism locked at the C116 gate). */ permanent?: boolean + /** + * C139 — the empty-state placeholder of the permanent input (default '—'). + * Cells pass their own copy (e.g. 'Sem cidade') — the ficha's absence is a + * state worth spelling, not a dash. + */ + placeholder?: string className?: string } @@ -77,6 +93,7 @@ export const CampaignInlineEditableCell = ({ readBehavior = 'text', saveOnChange = true, permanent = false, + placeholder, className, }: CampaignInlineEditableCellProps) => { const router = useRouter() @@ -269,7 +286,7 @@ export const CampaignInlineEditableCell = ({ ref={inputRef} type={inputTypeForField(field)} value={draft} - placeholder={isNameLink ? undefined : '—'} + placeholder={isNameLink ? undefined : (placeholder ?? '—')} aria-label={label} aria-busy={isPending} className={inputClassName} diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index e17e5da4..55ac7543 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -29,6 +29,9 @@ const CAMPAIGN_LIST_IDS = [ // B197 — the advisors surface is a hand-rolled client table (no // `CampaignTable`), but it joined the picker with the same cookie key. 'assessores', + // C139 — the contacts page (staff) joins the picker with the same key + // contract; the ficha fields are the columns. + 'contatos', ] as const export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number] @@ -86,6 +89,9 @@ const DEFAULT_HIDDEN_COLUMN_IDS: Partial diff --git a/src/lib/schemas/contact.ts b/src/lib/schemas/contact.ts index 01d06afe..e30c56fc 100644 --- a/src/lib/schemas/contact.ts +++ b/src/lib/schemas/contact.ts @@ -12,7 +12,7 @@ import { export type StateKey = keyof typeof CitiesByState /** Gender enum shared with the leadership collection (same values, C139). */ -export const contactGenders = personGenders +const contactGenders = personGenders const contactNameSchema = z .string() @@ -122,6 +122,21 @@ export const contactFieldUpdateSchema = z.discriminatedUnion('field', [ field: z.literal('phones'), phones: contactPhonesSchema, }), + // C139 — the select cells commit the ficha's own enum fields: gender (the + // shared person enum) and state (a UF of the cities catalog). + z.object({ + id: positiveRelationshipId, + field: z.literal('gender'), + gender: z.enum(contactGenders), + }), + z.object({ + id: positiveRelationshipId, + field: z.literal('state'), + state: z.custom( + (value) => typeof value === 'string' && value in CitiesByState, + 'Estado inválido', + ), + }), // C139 — the contacts page edits the ficha's own fields in place: city and // CEP are free text on the Contact collection itself (no person-join // sibling writes them). diff --git a/src/lib/schemas/personGenders.ts b/src/lib/schemas/personGenders.ts index 168b2711..212e12bc 100644 --- a/src/lib/schemas/personGenders.ts +++ b/src/lib/schemas/personGenders.ts @@ -5,5 +5,3 @@ * would be a module cycle. */ export const personGenders = ['feminino', 'masculino', 'outro', 'nao_informado'] as const - -export type PersonGender = (typeof personGenders)[number] diff --git a/src/utilities/contacts/contactListData.ts b/src/utilities/contacts/contactListData.ts index fe8a0c1a..1320897c 100644 --- a/src/utilities/contacts/contactListData.ts +++ b/src/utilities/contacts/contactListData.ts @@ -83,7 +83,7 @@ export const toContactRowViewModel = (doc: Contact): ContactRowViewModel => ({ * the result is intersected with the readable contact ids, so a link never * widens the list. */ -export const loadContactVinculos = async ( +const loadContactVinculos = async ( payload: Payload, user: CampaignUser, readableContactIDs: ReadonlySet, diff --git a/src/utilities/contacts/contactListFilters.ts b/src/utilities/contacts/contactListFilters.ts new file mode 100644 index 00000000..98ace691 --- /dev/null +++ b/src/utilities/contacts/contactListFilters.ts @@ -0,0 +1,141 @@ +/** + * Contacts list filter affordances (C139): Gênero / Estado / Cidade / + * Ausência / Vínculo toggles and clear-filters. Multi-select facets are OR + * within the facet, like every other list (ausência and vínculo are real ORs + * — selecting every member still excludes complete/unlinked fichas). + */ +import { CitiesByState } from '@/lib/cities' +import { + buildContactListHref, + CONTACT_ABSENCES, + CONTACT_VINCULOS, + contactAbsenceLabels, + contactGenderLabels, + contactListStateToRawParams, + contactVinculoLabels, + parseContactListParams, + type ContactGender, + type ContactListState, + type ContactStateKey, +} from '@/utilities/contacts/contactListUrl' + +export type ContactFilterOption = { + value: string + label: string +} + +type ContactMultiFilterParam = 'gender' | 'state' | 'city' | 'ausencia' | 'vinculo' + +/** Static seeds: the gender enum of the collection (4 values). */ +export const contactGenderFilterOptions: ContactFilterOption[] = ( + Object.keys(contactGenderLabels) as ContactGender[] +).map((value) => ({ value, label: contactGenderLabels[value] })) + +/** Static seeds: the 27 UFs of the cities catalog (the select-cell source too). */ +export const contactStateFilterOptions: ContactFilterOption[] = ( + Object.keys(CitiesByState) as ContactStateKey[] +).map((value) => ({ value, label: value })) + +export const contactAbsenceFilterOptions: ContactFilterOption[] = CONTACT_ABSENCES.map((value) => ({ + value, + label: contactAbsenceLabels[value], +})) + +export const contactVinculoFilterOptions: ContactFilterOption[] = CONTACT_VINCULOS.map((value) => ({ + value, + label: contactVinculoLabels[value], +})) + +/** + * Writes the RAW param and lets `parseContactListParams` validate, so no + * branch has to cast a `string[]` into the state's narrow value types. + */ +const setContactMultiFilterValues = ( + state: ContactListState, + param: ContactMultiFilterParam, + values: string[], +): ContactListState => + parseContactListParams({ + ...contactListStateToRawParams({ ...state, page: 1 }, 1), + [param]: values, + }) + +const getContactMultiFilterValues = ( + state: ContactListState, + param: ContactMultiFilterParam, +): string[] => { + if (param === 'gender') return state.genders ?? [] + if (param === 'state') return state.states ?? [] + if (param === 'ausencia') return state.ausencias ?? [] + if (param === 'vinculo') return state.vinculos ?? [] + return state.cities ?? [] +} + +const toggleContactMultiFilterValue = ( + state: ContactListState, + param: ContactMultiFilterParam, + value: string, +): ContactListState => { + const current = getContactMultiFilterValues(state, param) + return setContactMultiFilterValues( + state, + param, + current.includes(value) ? current.filter((entry) => entry !== value) : [...current, value], + ) +} + +export const toggleContactGenderFilter = ( + state: ContactListState, + value: string, +): ContactListState => { + if (!contactGenderFilterOptions.some((option) => option.value === value)) return state + return toggleContactMultiFilterValue(state, 'gender', value) +} + +export const toggleContactStateFilter = ( + state: ContactListState, + value: string, +): ContactListState => { + if (!(value in CitiesByState)) return state + return toggleContactMultiFilterValue(state, 'state', value) +} + +export const toggleContactAbsenceFilter = ( + state: ContactListState, + value: string, +): ContactListState => { + if (!CONTACT_ABSENCES.some((absence) => absence === value)) return state + return toggleContactMultiFilterValue(state, 'ausencia', value) +} + +export const toggleContactVinculoFilter = ( + state: ContactListState, + value: string, +): ContactListState => { + if (!CONTACT_VINCULOS.some((vinculo) => vinculo === value)) return state + return toggleContactMultiFilterValue(state, 'vinculo', value) +} + +/** + * C139 — city is FREE TEXT on `Contact.city` (maxLength 100), so the toggle + * validates structurally (non-empty, ≤ 100 chars) and lets + * `parseContactListParams` dedupe — the party facet precedent (C130). + */ +export const toggleContactCityFilter = ( + state: ContactListState, + value: string, +): ContactListState => { + const normalized = value.trim() + if (!normalized || normalized.length > 100) return state + return toggleContactMultiFilterValue(state, 'city', normalized) +} + +/** Drop every filter and the search; keep the ordering (municipios precedent). */ +export const clearContactListFilters = (state: ContactListState): ContactListState => ({ + page: 1, + sort: state.sort, + dir: state.dir, +}) + +export const buildContactFilterHref = (next: ContactListState): string => + buildContactListHref(next, 1) diff --git a/src/utilities/contacts/contactOmnibox.ts b/src/utilities/contacts/contactOmnibox.ts new file mode 100644 index 00000000..bd0a2524 --- /dev/null +++ b/src/utilities/contacts/contactOmnibox.ts @@ -0,0 +1,277 @@ +/** + * Contacts list omnibox adapter (C139). Pure / client-safe — the sibling of + * `peopleOmnibox` for the ficha surface. + */ +import { + createOmniboxSuggestionSeed, + filterOmniboxSuggestionSeeds, + type CampaignListOmniboxChip, + type CampaignListOmniboxSuggestion, +} from '@/lib/campaignListOmnibox' +import { + clearContactListFilters, + contactAbsenceFilterOptions, + contactGenderFilterOptions, + contactStateFilterOptions, + contactVinculoFilterOptions, + toggleContactAbsenceFilter, + toggleContactCityFilter, + toggleContactGenderFilter, + toggleContactStateFilter, + toggleContactVinculoFilter, + type ContactFilterOption, +} from '@/utilities/contacts/contactListFilters' +import { + contactAbsenceLabels, + contactGenderLabels, + contactListSortOptions, + contactListSortPrimaryOptions, + contactVinculoLabels, + isDefaultContactListSort, + resolveContactListSort, + type ContactListState, +} from '@/utilities/contacts/contactListUrl' + +export type ContactOmniboxAction = + | { kind: 'url'; state: ContactListState } + | { kind: 'clear'; state: ContactListState } + +const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}` + +export const buildContactOmniboxChips = (state: ContactListState): CampaignListOmniboxChip[] => { + const chips: CampaignListOmniboxChip[] = [] + + if (state.q) chips.push({ id: 'q', label: chipLabel('Busca', state.q) }) + + for (const gender of state.genders ?? []) { + chips.push({ id: `gender:${gender}`, label: chipLabel('Gênero', contactGenderLabels[gender]) }) + } + + for (const stateKey of state.states ?? []) { + chips.push({ id: `state:${stateKey}`, label: chipLabel('Estado', stateKey) }) + } + + for (const city of state.cities ?? []) { + chips.push({ id: `city:${city}`, label: chipLabel('Cidade', city) }) + } + + for (const ausencia of state.ausencias ?? []) { + chips.push({ + id: `ausencia:${ausencia}`, + label: chipLabel('Ausência', contactAbsenceLabels[ausencia]), + }) + } + + for (const vinculo of state.vinculos ?? []) { + chips.push({ + id: `vinculo:${vinculo}`, + label: chipLabel('Vínculo', contactVinculoLabels[vinculo]), + }) + } + + if (!isDefaultContactListSort(state)) { + const { sort, dir } = resolveContactListSort(state) + const option = contactListSortOptions.find((entry) => entry.key === sort && entry.dir === dir) + chips.push({ + id: 'sort', + label: chipLabel('Ordenação', option?.label ?? `${sort} ${dir}`), + }) + } + + return chips +} + +export const buildContactOmniboxSuggestionSeeds = ({ + cityFilterOptions, +}: { + cityFilterOptions: readonly ContactFilterOption[] +}) => { + const seeds = [] + + for (const option of contactGenderFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `gender:${option.value}`, + group: 'Gênero', + label: option.label, + keywords: ['genero', 'sexo'], + }, + { emptyQueryVisible: true }, + ), + ) + } + + for (const option of contactStateFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `state:${option.value}`, + group: 'Estado', + label: option.label, + keywords: ['estado', 'uf'], + }, + { emptyQueryVisible: true }, + ), + ) + } + + for (const option of cityFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed({ + id: `city:${option.value}`, + group: 'Cidade', + label: option.label, + keywords: ['cidade', 'municipio'], + }), + ) + } + + for (const option of contactAbsenceFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `ausencia:${option.value}`, + group: 'Ausência', + label: option.label, + keywords: [ + 'sem', + 'ausencia', + 'faltando', + 'faltante', + 'vazio', + 'incompleto', + 'incompleta', + ], + }, + { emptyQueryVisible: true }, + ), + ) + } + + for (const option of contactVinculoFilterOptions) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `vinculo:${option.value}`, + group: 'Vínculo', + label: option.label, + keywords: ['vinculo', 'ligacao', 'lideranca', 'dobradinha', 'assessor', 'equipe'], + }, + { emptyQueryVisible: true }, + ), + ) + } + + // C125 — primary-direction seeds only, visible on an empty query (the mobile + // cards have no sortable headers, so the omnibox group is the only sort + // surface below `md` — same rationale as people). + for (const option of contactListSortPrimaryOptions) { + seeds.push( + createOmniboxSuggestionSeed( + { + id: `sort:${option.key}|${option.dir}`, + group: 'Ordenação', + label: option.label, + keywords: ['ordenar', 'ordenacao', 'ordem', 'sort'], + }, + { emptyQueryVisible: true }, + ), + ) + } + + return seeds +} + +export const filterContactOmniboxSuggestions = ( + seeds: ReturnType, + query: string, +): CampaignListOmniboxSuggestion[] => filterOmniboxSuggestionSeeds(seeds, query) + +export const applyContactOmniboxSuggestion = ({ + state, + suggestionId, +}: { + state: ContactListState + suggestionId: string +}): ContactOmniboxAction => { + if (suggestionId.startsWith('q:')) { + const q = suggestionId.slice(2) + return { kind: 'url', state: { ...state, page: 1, q: q || undefined } } + } + + if (suggestionId.startsWith('gender:')) { + return { kind: 'url', state: toggleContactGenderFilter(state, suggestionId.slice(7)) } + } + + if (suggestionId.startsWith('state:')) { + return { kind: 'url', state: toggleContactStateFilter(state, suggestionId.slice(6)) } + } + + if (suggestionId.startsWith('city:')) { + return { kind: 'url', state: toggleContactCityFilter(state, suggestionId.slice(5)) } + } + + if (suggestionId.startsWith('ausencia:')) { + return { kind: 'url', state: toggleContactAbsenceFilter(state, suggestionId.slice(9)) } + } + + if (suggestionId.startsWith('vinculo:')) { + return { kind: 'url', state: toggleContactVinculoFilter(state, suggestionId.slice(8)) } + } + + if (suggestionId.startsWith('sort:')) { + const raw = suggestionId.slice(5) + const [key, dir] = raw.split('|') + const option = contactListSortOptions.find((entry) => entry.key === key && entry.dir === dir) + if (!option) return { kind: 'url', state } + const next = { ...state, page: 1, sort: option.key, dir: option.dir } + if (isDefaultContactListSort(next)) { + return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } } + } + return { kind: 'url', state: next } + } + + return { kind: 'url', state } +} + +export const removeContactOmniboxChip = ({ + state, + chipId, +}: { + state: ContactListState + chipId: string +}): ContactOmniboxAction => { + if (chipId === 'q') return { kind: 'url', state: { ...state, page: 1, q: undefined } } + + if (chipId.startsWith('gender:')) { + return { kind: 'url', state: toggleContactGenderFilter(state, chipId.slice(7)) } + } + + if (chipId.startsWith('state:')) { + return { kind: 'url', state: toggleContactStateFilter(state, chipId.slice(6)) } + } + + if (chipId.startsWith('city:')) { + return { kind: 'url', state: toggleContactCityFilter(state, chipId.slice(5)) } + } + + if (chipId.startsWith('ausencia:')) { + return { kind: 'url', state: toggleContactAbsenceFilter(state, chipId.slice(9)) } + } + + if (chipId.startsWith('vinculo:')) { + return { kind: 'url', state: toggleContactVinculoFilter(state, chipId.slice(8)) } + } + + if (chipId === 'sort') { + return { kind: 'url', state: { ...state, page: 1, sort: undefined, dir: undefined } } + } + + return { kind: 'url', state } +} + +export const clearContactOmnibox = (state: ContactListState): ContactOmniboxAction => ({ + kind: 'clear', + state: clearContactListFilters(state), +}) diff --git a/tests/unit/contactListFilters.unit.spec.ts b/tests/unit/contactListFilters.unit.spec.ts new file mode 100644 index 00000000..30e02ca5 --- /dev/null +++ b/tests/unit/contactListFilters.unit.spec.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest' + +import { + buildContactFilterHref, + clearContactListFilters, + contactAbsenceFilterOptions, + contactGenderFilterOptions, + contactStateFilterOptions, + contactVinculoFilterOptions, + toggleContactAbsenceFilter, + toggleContactCityFilter, + toggleContactGenderFilter, + toggleContactStateFilter, + toggleContactVinculoFilter, +} from '@/utilities/contacts/contactListFilters' +import { parseContactListParams } from '@/utilities/contacts/contactListUrl' +import { + applyContactOmniboxSuggestion, + buildContactOmniboxChips, + buildContactOmniboxSuggestionSeeds, + filterContactOmniboxSuggestions, + removeContactOmniboxChip, +} from '@/utilities/contacts/contactOmnibox' + +describe('contacts list filter affordances', () => { + it('offers the static seeds: 4 genders, 27 states, 2 absences, 4 vinculos', () => { + expect(contactGenderFilterOptions.map((option) => option.value)).toEqual([ + 'feminino', + 'masculino', + 'outro', + 'nao_informado', + ]) + expect(contactStateFilterOptions).toHaveLength(27) + expect(contactStateFilterOptions[0]).toEqual({ value: 'AC', label: 'AC' }) + expect(contactAbsenceFilterOptions.map((option) => option.value)).toEqual([ + 'sem_telefone', + 'sem_email', + ]) + expect(contactVinculoFilterOptions.map((option) => option.value)).toEqual([ + 'liderancas', + 'dobradinhas', + 'assessores', + 'equipe', + ]) + }) + + it('toggles gender and state, validating the value', () => { + const base = parseContactListParams({}) + expect(toggleContactGenderFilter(base, 'feminino')).toEqual({ + page: 1, + genders: ['feminino'], + }) + expect(toggleContactGenderFilter(base, 'unknown')).toEqual(base) + const withGender = toggleContactGenderFilter(base, 'feminino') + expect(toggleContactGenderFilter(withGender, 'feminino')).toEqual(base) + expect(toggleContactStateFilter(base, 'BA').states).toEqual(['BA']) + expect(toggleContactStateFilter(base, 'XX')).toEqual(base) + }) + + it('toggles absence and vinculo as real ORs (never a no-op umbrella)', () => { + const base = parseContactListParams({}) + const bothAbsences = toggleContactAbsenceFilter( + toggleContactAbsenceFilter(base, 'sem_telefone'), + 'sem_email', + ) + expect(bothAbsences.ausencias).toEqual(['sem_telefone', 'sem_email']) + expect(toggleContactAbsenceFilter(base, 'nope')).toEqual(base) + const bothVinculos = toggleContactVinculoFilter( + toggleContactVinculoFilter(base, 'liderancas'), + 'equipe', + ) + expect(bothVinculos.vinculos).toEqual(['liderancas', 'equipe']) + expect(toggleContactVinculoFilter(base, 'nope')).toEqual(base) + }) + + it('toggles city as free text with structural validation', () => { + const base = parseContactListParams({}) + expect(toggleContactCityFilter(base, ' Feira de Santana ').cities).toEqual([ + 'Feira de Santana', + ]) + expect(toggleContactCityFilter(base, ' ')).toEqual(base) + expect(toggleContactCityFilter(base, 'x'.repeat(101))).toEqual(base) + }) + + it('clears filters keeping the sort, and builds filter hrefs pinned to page 1', () => { + const filtered = parseContactListParams({ + q: 'ana', + gender: 'feminino', + state: 'BA', + city: 'Feira de Santana', + ausencia: 'sem_email', + vinculo: 'liderancas', + sort: 'cidade', + dir: 'desc', + page: '3', + }) + const cleared = clearContactListFilters(filtered) + expect(cleared).toEqual({ page: 1, sort: 'cidade', dir: 'desc' }) + expect(buildContactFilterHref(cleared)).toContain('sort=cidade') + expect(buildContactFilterHref(cleared)).toContain('dir=desc') + // Canonical hrefs omit default page=1, so a filtered href never carries + // a page param at all — filters always land back on page 1. + expect(buildContactFilterHref(filtered)).not.toContain('page=') + }) +}) + +describe('contacts list omnibox adapter', () => { + it('builds one chip per active filter dimension', () => { + const state = parseContactListParams({ + q: 'ana', + gender: 'feminino', + state: ['BA', 'SP'], + city: 'Feira de Santana', + ausencia: 'sem_telefone', + vinculo: 'liderancas', + sort: 'cidade', + dir: 'desc', + }) + const chips = buildContactOmniboxChips(state) + expect(chips.map((chip) => chip.id)).toEqual([ + 'q', + 'gender:feminino', + 'state:BA', + 'state:SP', + 'city:Feira de Santana', + 'ausencia:sem_telefone', + 'vinculo:liderancas', + 'sort', + ]) + expect(chips[0]).toEqual({ id: 'q', label: 'Busca: ana' }) + expect(chips[1]).toEqual({ id: 'gender:feminino', label: 'Gênero: Feminino' }) + }) + + it('omits the sort chip on the default sort', () => { + const chips = buildContactOmniboxChips(parseContactListParams({ q: 'ana' })) + expect(chips.map((chip) => chip.id)).toEqual(['q']) + }) + + it('filters suggestions by group, label and keyword, capping per group', () => { + const seeds = buildContactOmniboxSuggestionSeeds({ + cityFilterOptions: [ + { value: 'Feira de Santana', label: 'Feira de Santana' }, + { value: 'Salvador', label: 'Salvador' }, + ], + }) + const byLabel = filterContactOmniboxSuggestions(seeds, 'Salvador') + expect(byLabel.map((suggestion) => suggestion.id)).toContain('city:Salvador') + const byKeyword = filterContactOmniboxSuggestions(seeds, 'genero') + // The omnibox always surfaces the q suggestion for a non-empty query + // (group 'Busca'); the keyword-matched seeds are the Gênero shortcuts. + expect(byKeyword.some((suggestion) => suggestion.group === 'Busca')).toBe(true) + const genderMatches = byKeyword.filter((suggestion) => suggestion.group === 'Gênero') + expect(genderMatches.map((suggestion) => suggestion.id)).toEqual([ + 'gender:feminino', + 'gender:masculino', + 'gender:outro', + 'gender:nao_informado', + ]) + const emptyQuery = filterContactOmniboxSuggestions(seeds, '') + // Empty query shows only the dimension shortcuts; data-driven city seeds are hidden. + expect(emptyQuery.some((suggestion) => suggestion.id.startsWith('city:'))).toBe(false) + expect(emptyQuery.some((suggestion) => suggestion.group === 'Ordenação')).toBe(true) + }) + + it('applies suggestions: q, facets (toggle), sort (with default collapse)', () => { + const base = parseContactListParams({}) + expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'q:ana' })).toEqual({ + kind: 'url', + state: { page: 1, q: 'ana' }, + }) + expect(applyContactOmniboxSuggestion({ state: base, suggestionId: 'gender:feminino' })).toEqual( + { kind: 'url', state: { page: 1, genders: ['feminino'] } }, + ) + const sortApplied = applyContactOmniboxSuggestion({ + state: base, + suggestionId: 'sort:cidade|desc', + }) + expect(sortApplied).toEqual({ + kind: 'url', + state: { page: 1, sort: 'cidade', dir: 'desc' }, + }) + const sortToggled = applyContactOmniboxSuggestion({ + state: { page: 1, sort: 'cidade', dir: 'desc' }, + suggestionId: 'sort:cidade|desc', + }) + expect(sortToggled).toEqual({ kind: 'url', state: { page: 1, sort: 'cidade', dir: 'desc' } }) + }) + + it('removes chips by id and clears everything preserving the sort', () => { + const state = parseContactListParams({ + q: 'ana', + gender: 'feminino', + city: 'Salvador', + sort: 'cidade', + }) + const removed = removeContactOmniboxChip({ state, chipId: 'city:Salvador' }) + expect(removed).toEqual({ + kind: 'url', + state: { page: 1, q: 'ana', genders: ['feminino'], sort: 'cidade' }, + }) + const cleared = applyContactOmniboxSuggestion({ state, suggestionId: 'clear:all' }) + expect(cleared.kind).toBe('url') + }) +}) From 86595ca1239347ea01e8e66c7299fe8e9e9f14c2 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 13 Aug 2026 15:06:20 -0300 Subject: [PATCH 4/9] feat(C139): ficha mobile de contatos em bottom sheet (Fase 5) Create/edit sheets (ContactFormSheet + ContactFormFields) e cards mobile (ContactMobileCard/ContactMobileList), FAB de criar (ContactCreateFab, z-40 sobre o bottom nav) e a ladder updateContactFullFormAction. - Form sheets com campos controlados + dispatch manual: React 19 reseta campos nao controlados apos qualquer action assentada, apagando o que o usuario digitou em erro de validacao/conflito de nome; startTransition mantem isPending correto e o state.message vira banner role=alert. - state de acao da sessao anterior e descartado ao reabrir o sheet (sessionId), evitando banner/conflito fantasma. - CreateRow usa o mesmo dispatch manual (mesma classe de bug). - updateContactFull: escrita atomica da ficha em transacao com gates de escopo e invariante de nome (decisao F do plano). --- .../campanha/(app)/contatos/formActions.ts | 39 +++- .../campanha/(app)/contatos/page.tsx | 141 ++++------- .../(campaign)/campanha/actions/contact.ts | 46 ++++ .../campaign/contacts/ContactCreateFab.tsx | 32 +++ .../campaign/contacts/ContactCreateRow.tsx | 15 +- .../campaign/contacts/ContactFormFields.tsx | 221 ++++++++++++++++++ .../campaign/contacts/ContactFormSheet.tsx | 107 +++++++++ .../campaign/contacts/ContactMobileCard.tsx | 113 +++++++++ .../campaign/contacts/ContactMobileList.tsx | 24 ++ src/lib/schemas/contact.ts | 36 +++ 10 files changed, 672 insertions(+), 102 deletions(-) create mode 100644 src/components/campaign/contacts/ContactCreateFab.tsx create mode 100644 src/components/campaign/contacts/ContactFormFields.tsx create mode 100644 src/components/campaign/contacts/ContactFormSheet.tsx create mode 100644 src/components/campaign/contacts/ContactMobileCard.tsx create mode 100644 src/components/campaign/contacts/ContactMobileList.tsx diff --git a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts index e26c8365..bc937aa8 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts @@ -1,6 +1,10 @@ 'use server' -import { createContact, updateContactField } from '@/app/(campaign)/campanha/actions/contact' +import { + createContact, + updateContactField, + updateContactFull, +} from '@/app/(campaign)/campanha/actions/contact' import { nullableFormText, repeatedPhoneFormValues, @@ -89,11 +93,17 @@ export const createContactFormAction = async ( ): Promise => runCampaignFormAction({ execute: async () => { - const phone = nullableFormText(formData, 'phone') + // 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: phone ? [phone] : [], + phones, + gender: nullableFormText(formData, 'gender') ?? undefined, state: requiredFormText(formData, 'state'), city: nullableFormText(formData, 'city') ?? undefined, postalCode: nullableFormText(formData, 'postalCode') ?? undefined, @@ -103,3 +113,26 @@ export const createContactFormAction = async ( 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 => + 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.', + }) diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx index e56629e1..7a1db262 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -2,11 +2,12 @@ import config from '@payload-config' import { MessageCircleIcon, SearchXIcon } from 'lucide-react' import { redirect } from 'next/navigation' import { getPayload } from 'payload' -import type { ReactNode } from 'react' +import { 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' @@ -20,6 +21,7 @@ import { CampaignListResults, CampaignTransitionAnchor, } from '@/components/campaign/shared/CampaignListPending' +import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost' import { CampaignTable, CampaignTableHead, @@ -38,7 +40,7 @@ import { import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { CitiesByState } from '@/lib/cities' -import { formatBrazilianPhoneInput, whatsAppHrefForPhone } from '@/lib/phone' +import { whatsAppHrefForPhone } from '@/lib/phone' import { cn } from '@/lib/utils' import { isCampaignUnrestricted } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' @@ -279,62 +281,6 @@ const contactsColumns = ({ }, ] -/** Fase 4 interim mobile list — replaced by the cards/sheets in Fase 5. */ -const ContactsMobileList = ({ - rows, - canDelete, - empty, -}: { - rows: readonly ContactRowViewModel[] - canDelete: boolean - empty: ReactNode -}) => ( -
    - {rows.length === 0 ? ( -
  • {empty}
  • - ) : ( - rows.map((row) => { - const whatsAppHref = whatsAppHrefForPhone(row.phones[0] ?? null) - return ( -
  • -
    -

    {row.name}

    -

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

    -
    -
    - {whatsAppHref ? ( - - ) : null} - {canDelete ? ( - - ) : null} -
    -
  • - ) - }) - )} -
-) - export default async function ContactsPage({ searchParams }: ContactsPageProps) { const rawSearchParams = await searchParams const canonicalUrl = resolveContactListUrl(rawSearchParams) @@ -373,46 +319,49 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps) return ( - - - } - /> + + + + } + /> - - } - /> - - row.contactID} - empty={} - /> - {listData.rows.length ? ( - buildContactListHref(state, page)} + + } /> - ) : null} - - + + row.contactID} + empty={} + /> + {listData.rows.length ? ( + buildContactListHref(state, page)} + /> + ) : null} + + + + ) diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts index 8934c618..e6834451 100644 --- a/src/app/(campaign)/campanha/actions/contact.ts +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -9,8 +9,10 @@ import { 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' @@ -172,3 +174,47 @@ export const updateContactField = async (input: unknown) => { 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 => { + 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') +} diff --git a/src/components/campaign/contacts/ContactCreateFab.tsx b/src/components/campaign/contacts/ContactCreateFab.tsx new file mode 100644 index 00000000..5c0bd97f --- /dev/null +++ b/src/components/campaign/contacts/ContactCreateFab.tsx @@ -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 = () => ( +