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/docs/plans/react-19-form-reset-campanha.md b/docs/plans/react-19-form-reset-campanha.md new file mode 100644 index 00000000..30838de6 --- /dev/null +++ b/docs/plans/react-19-form-reset-campanha.md @@ -0,0 +1,86 @@ +# C140 — Formulários de campanha apagam o digitado em erro de validação (React 19 form reset) + +Status: rascunho +Atualizado em: 2026-08-13 +Issue: #740 +Priority: P2 +Model: composer-2.5 +Impeccable: B — UX (perda de dado digitado em erro); sem fluxo novo +Canvas UI: N/A — não muda layout +Appetite: ~1 dia eng; um outcome verificável +Responsável: — + +## Intenção + +Formulários de campanha com `useActionState` + `
` (padrão +`action={submitAction}`) sofrem o reset automático do React 19: **após QUALQUER action +assentada (sucesso ou erro), campos não controlados voltam ao defaultValue**. Quando o +formulário fica aberto em erro de validação/conflito, o usuário vê o alerta mas **todo o +digitado some** — silenciosamente. + +**Provado empiricamente nesta sessão (C139):** create row desktop e sheets mobile de +contatos — mesmo nó DOM, valor restaurado ao default após submit que retornou erro +(validation e conflito de nome). O fix do C139 trocou para campos controlados + dispatch +manual (`preventDefault` + `startTransition(() => submitAction(new FormData(...)))`), que +mantém `isPending` correto sem reentrar no plumbing de form action. + +Restam **~21 formulários** com o padrão antigo na árvore campaign +(`grep 'action={submitAction}' src/components/campaign`): votePledge (2), municipality +(wizard steps, updates, advisors), tour, advisor, leadership (3), organization, stateDeputy, +demand (3), shared wizard. Todos com o mesmo defeito de UX em erro. + +## Persona e fluxo + +- **Persona:** staff da campanha (coordenador/assessor) preenchendo formulários longos + (liderança, demanda, organização, dobradinha, declaração de votos, wizard de giro). +- **Job principal:** errar um campo, ver o erro, corrigir SÓ o campo errado e salvar. +- **Fluxo desejado:** o erro aparece, os demais campos **permanecem digitados**, o usuário + corrige o mínimo. +- **Anti-goal:** nenhuma mudança de comportamento de sucesso; nenhuma mudança de schemas + de dados; não virar epic de refactor global. + +## Objetivo e aceite + +- Nenhum form com `useActionState` em `/campanha` apaga o digitado quando a action retorna + erro (fieldErrors ou message). +- O padrão de fix segue o precedente C139: campos controlados **ou** dispatch manual + (`onSubmit` + `preventDefault` + `startTransition`), decidido por formulário conforme o + que o C139 já fez (sheets controladas; create row com dispatch manual + uncontrolled). +- Cobertura por spec e2e ou unit do form mais representativo por família (ex.: um form + longo de criação em erro mantém valores). +- Regressão: os specs existentes das famílias tocadas seguem verdes sem alteração. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — o "dado" é o comportamento de formulário já provado na + sessão C139 (mesmo nó, valor resetado, 3 formas distintas). + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** os ~21 forms listados acima em `src/components/campaign/*` + (`grep -rn 'action={submitAction}' src/components/campaign`). +- **Precedente a olhar:** `src/components/campaign/contacts/ContactFormFields.tsx` + (controlado + manual dispatch, comentário documentando o porquê) e + `ContactCreateRow.tsx` (dispatch manual); `ActivityOverlay.tsx` (controlado + manual — + precedente anterior ao C139). +- **Risco de acoplamento:** cada form é independente; o fix é mecânico por arquivo. + Forms com telefones/arrays (`PhonesFieldEditor` FORM mode) precisam conferir se o editor + interno é controlado (não é resetado pelo dispatch manual). + +## Já resolvido no simplify/critique (não reabrir) + +- Forms do domínio `contacts` (create row, create/edit sheets) — fix C139 nesta entrega, + coberto pelo e2e `campaignContacts`. + +## Explicitamente fora + +- Forms fora de `/campanha` (frontend público) — não verificados nesta colheita; reavaliar + se o padrão aparecer lá. +- Mudança de comportamento de sucesso ou de schemas — fora de escopo. +- Padronizar TODOS os forms num único helper compartilhado — decisão de engenharia do + item (pode ficar com os dois padrões C139, ou extrair helper se o lote mostrar DRY). + +## Dependências + +- `C139 (#728)` — a entrega que provou o defeito e fixou o domínio contacts (via `depends` + na Issue). diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index 4dda1e14..259ac363 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -134,6 +134,14 @@ export const E2E_AFFECTED_MANIFEST = [ ], specs: ['campaignPeople'], }, + { + prefixes: [ + `${CAMPAIGN_APP}/contatos`, + 'src/components/campaign/contacts', + 'src/utilities/contacts', + ], + specs: ['campaignContacts'], + }, // Domains without a dedicated e2e family still wake campaign home smoke so // the affected classifier cannot return mode=none on an unmapped domain dir. { @@ -164,7 +172,7 @@ export const E2E_AFFECTED_MANIFEST = [ `${CAMPAIGN_APP}/apoiadores`, `${CAMPAIGN_APP}/assessores`, `${CAMPAIGN_APP}/atualizacoes`, - `${CAMPAIGN_APP}/contatos`, + `${CAMPAIGN_APP}/meus-contatos`, `${CAMPAIGN_APP}/demandas`, `${CAMPAIGN_APP}/dobradinhas`, `${CAMPAIGN_APP}/organizacoes`, 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..bc937aa8 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/contatos/formActions.ts @@ -0,0 +1,138 @@ +'use server' + +import { + createContact, + updateContactField, + updateContactFull, +} from '@/app/(campaign)/campanha/actions/contact' +import { + nullableFormText, + repeatedPhoneFormValues, + requiredFormText, + requiredRelationshipFormValue, +} from '@/lib/formData' +import { BRAZILIAN_PHONE_DUPLICATE_MESSAGE } from '@/lib/phone' +import { + CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, + CONTACT_CELL_STAFF_MESSAGE, + CONTACT_CREATED_MESSAGE, + CONTACT_NAME_CONFLICT_MESSAGE, +} from '@/lib/schemas/contact' +import { + runCampaignFormAction, + type CampaignFormActionState, +} from '@/utilities/campaignFormActionError' + +const contactCellSafeMessages = [ + CONTACT_CELL_STAFF_MESSAGE, + CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, + CONTACT_NAME_CONFLICT_MESSAGE, + BRAZILIAN_PHONE_DUPLICATE_MESSAGE, +] as const + +/** Per-field ficha edit for the contacts page cells (C139, C116 shape). */ +export const updateContactFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + 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 () => { + // The desktop row sends one `phone`; the mobile sheet sends the + // repeatable `phones` list (PhonesFieldEditor) — the row wins when both + // are present, but the two forms never send both. + const singlePhone = nullableFormText(formData, 'phone') + const phonesList = repeatedPhoneFormValues(formData, 'phones') + const phones = phonesList.length > 0 ? phonesList : singlePhone ? [singlePhone] : [] + const result = await createContact({ + name: requiredFormText(formData, 'name'), + email: nullableFormText(formData, 'email') ?? undefined, + phones, + gender: nullableFormText(formData, 'gender') ?? undefined, + state: requiredFormText(formData, 'state'), + city: nullableFormText(formData, 'city') ?? undefined, + postalCode: nullableFormText(formData, 'postalCode') ?? undefined, + }) + return { message: CONTACT_CREATED_MESSAGE, contactID: result.contactID } + }, + safeMessages: [CONTACT_NAME_CONFLICT_MESSAGE], + genericMessage: 'Não foi possível criar o contato. Verifique os dados e tente novamente.', + }) + +/** The mobile edit sheet's single atomic ficha write (plan decision F). */ +export const updateContactFullFormAction = async ( + _state: CampaignFormActionState, + formData: FormData, +): Promise => + 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 99deaba5..7a1db262 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -1,33 +1,368 @@ import config from '@payload-config' +import { MessageCircleIcon, SearchXIcon } from 'lucide-react' import { redirect } from 'next/navigation' import { getPayload } from 'payload' -import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel' +import { ContactCreateFab } from '@/components/campaign/contacts/ContactCreateFab' +import { ContactCreateRow } from '@/components/campaign/contacts/ContactCreateRow' +import { ContactCreateProvider } from '@/components/campaign/contacts/ContactCreateState' +import { ContactFilters } from '@/components/campaign/contacts/ContactFilters' +import { ContactMobileList } from '@/components/campaign/contacts/ContactMobileList' +import { ContactPhonesCell } from '@/components/campaign/contacts/ContactPhonesCell' +import { ContactSelectCell } from '@/components/campaign/contacts/ContactSelectCell' +import { ContactSortableHead } from '@/components/campaign/contacts/ContactSortableHead' +import { DeletePersonButton } from '@/components/campaign/people/DeletePersonButton' +import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' +import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip' +import { CampaignInlineEditableCell } from '@/components/campaign/shared/CampaignInlineEditableCell' +import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' +import { + CampaignListPendingBoundary, + CampaignListResults, + CampaignTransitionAnchor, +} from '@/components/campaign/shared/CampaignListPending' +import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost' +import { + CampaignTable, + CampaignTableHead, + type CampaignTableColumn, +} from '@/components/campaign/shared/CampaignTable' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' -import { isCampaignLeader } from '@/utilities/campaignAccess' +import { CitiesByState } from '@/lib/cities' +import { whatsAppHrefForPhone } from '@/lib/phone' +import { cn } from '@/lib/utils' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' +import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' +import { + loadContactListPageData, + type ContactRowViewModel, +} from '@/utilities/contacts/contactListData' +import { + buildContactFilterHref, + clearContactListFilters, + type ContactFilterOption, +} from '@/utilities/contacts/contactListFilters' +import { + buildContactListHref, + contactGenderLabels, + resolveContactListUrl, + type ContactGender, + type ContactListState, + type ContactStateKey, +} from '@/utilities/contacts/contactListUrl' -export const dynamic = 'force-dynamic' +import { createContactFormAction, updateContactFormAction } from './formActions' export const metadata = campaignPageMetadataFromCatalog('contatos') -export default async function CampaignContatosPage() { - const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()]) +type ContactsPageProps = { + searchParams: Promise> +} - if (!isCampaignLeader(user)) { - redirect('/campanha') - } +const genderOptions: ReadonlyArray<{ value: ContactGender; label: string }> = ( + Object.keys(contactGenderLabels) as ContactGender[] +).map((value) => ({ value, label: contactGenderLabels[value] })) - const view = await loadLeaderContactsPageData(payload, user) +const stateOptions: ReadonlyArray<{ value: ContactStateKey; label: string }> = ( + Object.keys(CitiesByState) as ContactStateKey[] +).map((value) => ({ value, label: value })) + +const ContactsListEmptyState = ({ + hasFilters, + state, +}: { + hasFilters: boolean + state: ContactListState +}) => ( + + + + + 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} +
+ ) + }, + }, +] + +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/(app)/meus-contatos/page.tsx b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx new file mode 100644 index 00000000..b05e4939 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/meus-contatos/page.tsx @@ -0,0 +1,33 @@ +import config from '@payload-config' +import { redirect } from 'next/navigation' +import { getPayload } from 'payload' + +import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { isCampaignLeader } from '@/utilities/campaignAccess' +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' + +export const dynamic = 'force-dynamic' + +export const metadata = campaignPageMetadataFromCatalog('meusContatos') + +export default async function CampaignMeusContatosPage() { + const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()]) + + if (!isCampaignLeader(user)) { + redirect('/campanha') + } + + const view = await loadLeaderContactsPageData(payload, user) + + return ( + + ) +} diff --git a/src/app/(campaign)/campanha/actions/contact.ts b/src/app/(campaign)/campanha/actions/contact.ts new file mode 100644 index 00000000..e6834451 --- /dev/null +++ b/src/app/(campaign)/campanha/actions/contact.ts @@ -0,0 +1,220 @@ +'use server' + +import { revalidatePath } from 'next/cache' + +import { reorderWithPrimaryPhone } from '@/lib/phone' +import { + CONTACT_CELL_NOT_IN_SCOPE_MESSAGE, + CONTACT_CELL_STAFF_MESSAGE, + CONTACT_CREATE_STAFF_MESSAGE, + contactCreateSchema, + contactFieldUpdateSchema, + contactFullUpdateSchema, + type ContactCreateInput, + type ContactFieldUpdateInput, + type ContactFullUpdateInput, +} from '@/lib/schemas/contact' +import type { CampaignUser, Contact } from '@/payload-types' +import { isCampaignUnrestricted } from '@/utilities/campaignAccess' +import { getCampaignActionContext, reloadStaffActor } from '@/utilities/campaignActionContext' +import { assertContactNameAvailable } from '@/utilities/contacts/contactNameInvariant' +import { + withPayloadTransaction, + type PayloadTransactionRequest, +} from '@/utilities/payloadTransaction' +import type { Payload } from 'payload' + +/** + * C139 — the contacts page writes the ficha itself, so the write gate is the + * row's own read visibility: "you edit what you see". The `contact` collection + * access keeps `create`/`update` admin-only (`canManageContacts`), so every + * campaign write bypasses with `overrideAccess: true` AFTER the staff gate and + * (for an advisor) a user-threaded scope re-check — the bypass is justified by + * the gate, never standalone. + */ + +const assertContactRowEditable = async ( + payload: Payload, + actor: CampaignUser, + contactID: number, + req: PayloadTransactionRequest, +): Promise => { + if (isCampaignUnrestricted(actor)) return + + const visible = await payload.find({ + collection: 'contact', + where: { id: { equals: contactID } }, + depth: 0, + limit: 1, + pagination: false, + user: actor, + overrideAccess: false, + req, + }) + if (visible.docs.length === 0) throw new Error(CONTACT_CELL_NOT_IN_SCOPE_MESSAGE) +} + +const createContactRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ContactCreateInput, +): Promise<{ contactID: number }> => { + const data = contactCreateSchema.parse(input) + + return withPayloadTransaction(payload, async ({ req }) => { + await reloadStaffActor(payload, actor, CONTACT_CREATE_STAFF_MESSAGE, req) + + await assertContactNameAvailable(payload, req, data.name) + + const created = await payload.create({ + collection: 'contact', + data: { + name: data.name, + ...(data.email ? { email: data.email } : {}), + ...(data.phones && data.phones.length > 0 + ? { phones: data.phones.map((value) => ({ value })) } + : {}), + ...(data.gender ? { gender: data.gender } : {}), + state: data.state, + ...(data.city ? { city: data.city } : {}), + ...(data.postalCode ? { postalCode: data.postalCode } : {}), + }, + depth: 0, + // Intentional bypass: the staff gate above is the authorization; the + // collection's `create` access is admin-only by design (C139). + overrideAccess: true, + req, + }) + return { contactID: created.id } + }) +} + +export const createContact = async (input: unknown) => { + const { payload, actor } = await getCampaignActionContext() + const { contactID } = await createContactRecord(payload, actor, input as ContactCreateInput) + revalidatePath('/campanha/contatos', 'page') + return { contactID } +} + +const contactFieldData = ( + data: ContactFieldUpdateInput, +): Partial> & { + phones?: { value: string }[] +} => { + switch (data.field) { + case 'name': + return { name: data.name } + case 'email': + return { email: data.email ?? null } + case 'city': + return { city: data.city } + case 'postalCode': + // The schema transforms '' → undefined: clearing the CEP stores null. + return { postalCode: data.postalCode ?? null } + case 'gender': + return { gender: data.gender } + case 'state': + return { state: data.state } + case 'phones': + return { phones: data.phones.map((value) => ({ value })) } + case 'phone': + return { + phones: data.phone === null || data.phone === undefined ? [] : [{ value: data.phone }], + } + } +} + +const updateContactFieldRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ContactFieldUpdateInput, +): Promise => { + const data = contactFieldUpdateSchema.parse(input) + + return withPayloadTransaction(payload, async ({ req }) => { + const currentActor = await reloadStaffActor(payload, actor, CONTACT_CELL_STAFF_MESSAGE, req) + + await assertContactRowEditable(payload, currentActor, data.id, req) + + let contactData = contactFieldData(data) + if (data.field === 'name') { + await assertContactNameAvailable(payload, req, data.name, data.id) + } else if (data.field === 'phone') { + // Inline primary-phone edit (C112 shape): the new number goes first and + // the rest shifts up; clearing drops the primary phone. + const current = await payload.findByID({ + collection: 'contact', + id: data.id, + depth: 0, + select: { phones: true }, + // Intentional bypass: the scope check above established the actor's + // right over this row; the read only resolves the list to reorder. + overrideAccess: true, + req, + }) + contactData = { + phones: reorderWithPrimaryPhone(current.phones, data.phone).map((value) => ({ value })), + } + } + + // Intentional bypass: same gate as the reads above. + await payload.update({ + collection: 'contact', + id: data.id, + data: contactData, + depth: 0, + overrideAccess: true, + req, + }) + }) +} + +export const updateContactField = async (input: unknown) => { + const { payload, actor } = await getCampaignActionContext() + await updateContactFieldRecord(payload, actor, input as ContactFieldUpdateInput) + revalidatePath('/campanha/contatos', 'page') +} + +/** + * C139 — the mobile sheet's single atomic ficha write (plan decision F): one + * transaction, same gate ("edita o que vê") and same name invariant as the + * per-field ladder; every column is written from the form (empties clear). + */ +const updateContactFullRecord = async ( + payload: Payload, + actor: CampaignUser, + input: ContactFullUpdateInput, +): Promise => { + 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/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/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 = () => ( +