From 3e9390230b7a18437f5636b29e1afc503a2d6754 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 21:57:52 -0300 Subject: [PATCH] =?UTF-8?q?feat(B197):=20e-mail=20oculto=20por=20padr?= =?UTF-8?q?=C3=A3o=20nas=20listas=20de=20pessoas=20=E2=80=94=20default=20B?= =?UTF-8?q?17=20nas=205=20listas=20+=20coluna=20pr=C3=B3pria=20em=20pessoa?= =?UTF-8?q?s/apoiadores=20+=20picker=20em=20assessores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 2 + .../ocultar-email-por-padrao-listas-impl.md | 79 +++++++++++++++++++ .../campanha/(app)/assessores/page.tsx | 32 +++++++- .../campanha/(app)/pessoas/page.tsx | 25 +++--- .../campaign/advisor/AdvisorFilters.tsx | 5 +- .../campaign/advisor/AdvisorsTable.tsx | 46 +++++++---- .../campaign/supporter/SupporterList.tsx | 8 ++ src/lib/campaignColumnVisibility.ts | 11 +++ .../supporter/supporterViewModels.ts | 2 + tests/e2e/campaignColumnPicker.e2e.spec.ts | 70 ++++++++++++++++ .../campaignColumnVisibility.unit.spec.ts | 34 +++++++- 11 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 docs/plans/ocultar-email-por-padrao-listas-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index f82eb61f..9368b52f 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os ``) e `AdvisorsTable` (client) passou a receber `columnVisibility` com ``/`` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md). + **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). **Recently resolved (2026-08-10):** **OPS33** (Issue #595) — `worktree next` **claima deterministicamente** antes de criar o worktree: mesma fila/ordem e lock otimista do `pnpm agent:claim`, com o claim extraído para `scripts/lib/agent-github.mjs` (`claimIssue` — lock + flip + comentário; `claimBriefLines` — brief compartilhado; `claimQueueEntry` — shape para o reopen; `claimTargetVerdict` — decisão pura testada) e o `agent-claim.mjs` virou CLI fino com a **mesma saída**; claim falhou (fila vazia/race/`--issue` inválido) → motivo e saída **sem worktree órfão** (branch validada antes do claim; hint de falha pós-claim). `--issue ` claima a Issue direcionada (`ready`) ou **reabre** uma já claimada (`in-progress` — sem re-claim, idempotente; recupera também falha pós-claim); a saída imprime o brief e avisa "já claimada — NÃO rodar `pnpm agent:claim`". O **launch** do terminal agora envia `/work-issue --issue ` (prompt citado na diretiva) e a função `worktree()` de `.agents/shell/worktree.sh` passou a **tokenizar a diretiva via xargs** (quote-aware, nunca eval) — o valor do `--prompt` carrega espaço. `--stay` suprime cd+launch mas o claim acontece; o comando `/worktree` roda o mesmo script (claim incluso, sem launch). Guard `-B` contra reset de commits de sessão no reopen. Pool intacto (`claimIssueForPool` coordenado). Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue` (sai "read-only"), AGENT-OPS. Sem migration/dados/access/UI. Intenção [`worktree-next-claima-issue-deterministicamente.md`](plans/worktree-next-claima-issue-deterministicamente.md); impl [`worktree-next-claima-issue-deterministicamente-impl.md`](plans/worktree-next-claima-issue-deterministicamente-impl.md). diff --git a/docs/plans/ocultar-email-por-padrao-listas-impl.md b/docs/plans/ocultar-email-por-padrao-listas-impl.md new file mode 100644 index 00000000..b37ebaa1 --- /dev/null +++ b/docs/plans/ocultar-email-por-padrao-listas-impl.md @@ -0,0 +1,79 @@ +# Impl: Ocultar a coluna de e-mail por padrão nas listas de pessoas + +Status: aprovado +Atualizado em: 2026-08-10 +Issue: #627 +Intenção: docs/plans/ocultar-email-por-padrao-listas.md +Appetite restante: herdado (~0,5–1 dia eng) + +## Leitura da intenção + +- **Outcome:** nas cinco listas de pessoas (lideranças, dobradinhas, pessoas, apoiadores, assessores) a coluna de e-mail nasce oculta para quem nunca tocou no seletor de colunas, e qualquer um pode religá-la pela mecânica B17 já existente — escolha lembrada por dispositivo (cookie `campaign_columns`). Na lista de pessoas, o e-mail sai da coluna "Contato" (que fica só com telefone) e vira coluna própria oculta. Na lista de assessores (hoje sem seletor), a mecânica B17 chega junto: ocultar por padrão + religar. +- **O que NÃO negociar:** default oculto nas cinco listas; religar manualmente sempre possível (assessores incluído); quem já tem escolha salva no cookie continua com a escolha inteira respeitada (contrato atual do mecanismo — o default vale só para quem nunca mexeu); nada de reordenar colunas, redesenhar tabelas, mexer em fichas/cards mobile ou em outras colunas. +- **O que reavaliar:** a intenção assume "só muda o default" nas quatro listas com seletor. Confirmado na leitura do código: `email` já é coluna própria em lideranças (`liderancas/page.tsx:160`) e dobradinhas (`dobradinhas/page.tsx:163`); em pessoas está embutida na célula "Contato" (`pessoas/page.tsx:135-149`) e em apoiadores **não existe coluna** (`SupporterList.tsx`) — precisa nascer (oculta). Assessores tem tabela própria client-side sem seletor (`AdvisorsTable.tsx`) — o encaixe é o ponto de maior risco. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[campaignColumnVisibility.ts
+ assessores em CAMPAIGN_LIST_IDS] --> B[defaults por lista
email oculto nas 5] + B --> C[liderancas/dobradinhas
só default] + B --> D[pessoas: split Contato
+ coluna email própria] + B --> E[apoiadores: coluna email nova
oculta por padrão] + B --> F[assessores: picker B17
+ th/td condicionais] + C & D & E --> G[(cookie campaign_columns)] + F --> G +``` + +**Opções consideradas:** A (defaults no mecanismo B17 + encaixe assessores) | B (merge do default com cookie existente) | C (converter AdvisorsTable para `CampaignTable`) +**Recomendação:** A — o mecanismo já resolve "cookie ausente → default" (`resolveCampaignColumnVisibility`); as quatro listas `CampaignTable` viram uma linha no `DEFAULT_HIDDEN_COLUMN_IDS`; assessores ganha a MESMA mecânica (picker + cookie) sem reescrever a tabela. +**Rejeitadas:** + +- **B (merge default+cookie):** quebra o contrato B17 documentado ("hiding is stored, never the visible set") e a decisão de produto assumida ("quem já tem escolha salva continua" — `resolvedCampaignColumnVisibility` já a implementa). Usuário que mexeu no seletor de `pessoas` antes vê o e-mail visível; o default vale para quem nunca tocou. É o aceite. +- **C (portar AdvisorsTable para CampaignTable):** a tabela tem modos client-side que o shell não modela (linha-draft de criação com validação, edição inline debounced por célula). Redesenho de superfície — corte explícito da intenção ("sem refactor da superfície"). +- **D (pessoas: esconder só a linha de e-mail dentro da célula Contato):** fora da mecânica de colunas — quem religa não ganha nada e o picker fica mentindo. Intenção recomenda A (coluna própria) e o código dá suporte trivial. + +### Componentes / mudanças + +- **`DEFAULT_HIDDEN_COLUMN_IDS`** (`src/lib/campaignColumnVisibility.ts:70`): `liderancas: ['email']`, `dobradinhas: ['email']`, `pessoas: ['email']`, `apoiadores: ['email']`, `assessores: ['email']` — um comentário apontando B197. +- **`CAMPAIGN_LIST_IDS`** (`campaignColumnVisibility.ts:20`): `+ 'assessores'` (primeira lista sem seletor a entrar — o id vira chave do cookie e label do picker). +- **`liderancas/page.tsx` / `dobradinhas/page.tsx`:** zero mudança — o default resolve. (Verificar no e2e.) +- **`pessoas/page.tsx`:** célula `contact` perde o `` de e-mail (fica só telefone, `—` quando vazio); nova coluna `{ id: 'email', label: 'E-mail', cell: row.email ?? '—' }` logo após `contact` (posição de leitura natural; oculta no primeiro paint). Card mobile intocado (anti-goal). +- **`SupporterList.tsx`:** nova coluna `{ id: 'email', label: 'E-mail', cell: (s) => s.email ?? '—' }` após `city` — `SupporterListItemViewModel.email` já existe (`supporterViewModels.ts:29`); `supporterPickerColumns` deriva e ganha a coluna automaticamente. Card mobile intocado. +- **`assessores/page.tsx`:** `readCampaignColumnVisibility('assessores')`; `trailing={}` no `AdvisorFilters`; `columnVisibility` passado ao `AdvisorsTable`. +- **`AdvisorFilters.tsx`:** prop `trailing?: ReactNode` repassada ao `CampaignListOmnibox` (slot já existe — padrão de `PeopleFilters`/`StateDeputyFilters`). +- **`AdvisorsTable.tsx`** (client): prop `columnVisibility: CampaignColumnVisibility`; `emailHidden = hiddenColumnIds.includes('email')`; `` "E-mail" e o `` da linha só renderizam quando visível. **Exceção única:** a linha-draft de criação mantém o input de e-mail SEMPRE (e-mail é obrigatório para criar — `saveDraft` valida; coluna oculta esconde leitura, não o formulário). Em modo edição, a coluna oculta esconde também o input de e-mail — quem quer editar liga a coluna (um clique no picker; comportamento consistente com o pedido). +- **Picker de assessores:** array estático no page (`advisorPickerColumns`): Nome (mandatory), E-mail, Celular, Municípios, Ações — rótulos idênticos aos ``. +- **Migration:** sem migration (apresentação pura; nada de schema/access/Consent; páginas já dinâmicas por auth — sem cache a invalidar). +- **Access / Consent:** nenhum. +- **UI:** Impeccable B — reusa integralmente os shells existentes (picker, cookie, trailing); nenhuma superfície nova. + +## Fases verificáveis + +1. **Mecanismo (lib + unit):** `CAMPAIGN_LIST_IDS` + defaults; atualizar `campaignColumnVisibility.unit.spec.ts` (o teste da linha 35 que espera `assessores` **dropped** pelo parser agora passa a guardá-la; novos asserts de default por lista e do contrato "cookie existente vence o default"). Gate: `pnpm test` (unit do arquivo). +2. **Quatro listas CampaignTable:** pessoas (split + coluna), apoiadores (coluna nova), liderancas/dobradinhas (nada a tocar — conferência visual/e2e). Gate: `pnpm exec tsc --noEmit` + unit. +3. **Assessores (encaixe):** page + AdvisorFilters + AdvisorsTable (th/td condicionais + exceção do draft). Gate: `tsc` + e2e focado. +4. **E2e:** novo `test.describe('E-mail oculto por padrão (B197)')` em `campaignColumnPicker.e2e.spec.ts` — em `/campanha/liderancas` o header "E-mail" nasce ausente, o picker mostra `1 oculta`, religar pinta a coluna e o reload persiste; em `/campanha/assessores` a coluna nasce oculta, o picker existe, religa e persiste, e a linha-draft continua com o input de e-mail. Gate: `pnpm test:e2e` (spec). +5. **Gates finais:** `pnpm gate:fast` (lint zero, format, tsc, knip, cycles), `pnpm test` (unit+int), `pnpm test:e2e` (arquivo), `pnpm build` local; CHANGELOG-AGENTS (uma entrada curta) + planos commitados. + +## Rabbit holes / Não escopo (engenharia) + +- Merge default↔cookie por coluna (B rejeitada — contrato B17 intacto). +- Portar `AdvisorsTable` para `CampaignTable` (C rejeitada — superfície). +- "Já que estamos aqui": esconder telefone/municípios, reordenar, redesenhar cards mobile — fora de escopo (intenção). +- Sincronizar com C112 (telefones, mesmas páginas): serialização externa — nada a fazer neste código. +- Não mexer na largura `w-[24%]` do th de e-mail de assessores (table-fixed redistribui quando a coluna some). + +## Riscos e mitigação + +- **AdvisorsTable (client) dessincronizado do cookie:** o picker commita com `router.refresh()`; o server re-renderiza com o cookie novo e passa `hiddenColumnIds` de volta — mesma fonte de verdade das outras listas. Mitigação extra: prop derivada no server, nunca estado local no client. +- **Linha-draft com 5 células vs header com 4 quando oculto:** desalinhamento visual só na linha de formulário; aceito (campo obrigatório). Comentário no código explicando a exceção. +- **Staff com cookie antigo vê e-mail nas 4 listas:** decisão de produto assumida ("escolha existente é respeitada"); para assessores ninguém tem cookie ainda → 100% nascendo oculto. +- **Teste unit existente que pina o parser descartando `assessores`:** atualização explícita (fase 1) — é o contrato MUDANDO de propósito. +- **E2e pesado (duas rotas novas no spec do picker):** spec focado, `test.slow()` no assessores (rota de compilação dev), padrão REFRESH do arquivo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (5 listas, religável, cookie respeitado, sem outras mudanças de coluna) +- [ ] Invariantes AGENTS/engineering-standards (nada de schema/access/Consent/migration; páginas dinâmicas sem cache) +- [ ] Testes previstos: unit do mecanismo (defaults + parser assessores) e e2e do encaixe assessores + default nas listas diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index 87dc3c96..6cfca92c 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -4,12 +4,17 @@ import { getPayload } from 'payload' import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters' import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable' +import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, CampaignListResults, } from '@/components/campaign/shared/CampaignListPending' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { + toCampaignColumnPickerColumns, + type CampaignColumnPickerColumn, +} from '@/lib/campaignColumnVisibility' import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' import { hasAdvisorListActiveFilters } from '@/utilities/advisor/advisorListFilters' @@ -18,6 +23,7 @@ import { loadAdvisorListPageData, parseAdvisorListParams, } from '@/utilities/advisorData' +import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { firstValue } from '@/utilities/campaignListUrl' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' @@ -34,6 +40,17 @@ type AdvisorsPageProps = { searchParams: Promise> } +// B197 — labels mirror the `` copy of `AdvisorsTable`; the table's own +// columns are hand-rolled (not `CampaignTableColumn`), so the picker menu is +// spelled out here instead of derived. +const advisorPickerColumns: readonly CampaignColumnPickerColumn[] = [ + { id: 'name', label: 'Nome', mandatory: true }, + { id: 'email', label: 'E-mail' }, + { id: 'phone', label: 'Celular' }, + { id: 'municipalities', label: 'Municípios' }, + { id: 'actions', label: 'Ações' }, +] + export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) { const rawSearchParams = await searchParams const [, payload] = await Promise.all([ @@ -43,7 +60,8 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) const state = parseAdvisorListParams(rawSearchParams) const autoCreateDraft = firstValue(rawSearchParams[ADVISOR_QUICK_CREATE_PARAM]) === '1' - const [{ rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([ + const [columnVisibility, { rows, totalDocs, totalPages }, municipalityIndex] = await Promise.all([ + readCampaignColumnVisibility('assessores'), loadAdvisorListPageData(payload, state), loadMunicipalityPortfolioIndex(), ]) @@ -58,13 +76,23 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) return ( - + + } + /> ( - - {row.phone ? ( - {formatBrazilianPhoneInput(row.phone)} - ) : null} - - {row.email ?? '—'} - - - ), + cell: (row) => + row.phone ? ( + {formatBrazilianPhoneInput(row.phone)} + ) : ( + — + ), + }, + { + // B197 — the email left the "Contato" cell (which now carries only the + // phone) and became its own column, hidden by default. The mobile cards + // still show it as the no-phone fallback, untouched. + id: 'email', + label: 'E-mail', + cellClassName: 'max-w-56 whitespace-normal', + cell: (row) => (row.email ? row.email : —), }, { id: 'assessora', diff --git a/src/components/campaign/advisor/AdvisorFilters.tsx b/src/components/campaign/advisor/AdvisorFilters.tsx index d6fc0f16..aa3b9e8c 100644 --- a/src/components/campaign/advisor/AdvisorFilters.tsx +++ b/src/components/campaign/advisor/AdvisorFilters.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' import { CampaignListOmnibox, @@ -22,9 +22,11 @@ import { export const AdvisorFilters = ({ state, municipalityFilterOptions, + trailing, }: { state: AdvisorListState municipalityFilterOptions: AdvisorFilterOption[] + trailing?: ReactNode }) => { const { navigate, isPending } = useCampaignListFilterNavigation({ state, @@ -94,6 +96,7 @@ export const AdvisorFilters = ({ onClearAll={() => { runAction(clearAdvisorOmnibox(state)) }} + trailing={trailing} /> ) diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx index 88bee994..32032355 100644 --- a/src/components/campaign/advisor/AdvisorsTable.tsx +++ b/src/components/campaign/advisor/AdvisorsTable.tsx @@ -34,6 +34,7 @@ import { TableHeader, TableRow, } from '@/components/ui/Table' +import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility' import { campaignInlineInputClassName } from '@/lib/campaignInlineInput' import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio' import { @@ -57,6 +58,8 @@ type AdvisorsTableProps = { rows: AdvisorRowViewModel[] municipalityIndex: MunicipalityPortfolioIndexEntry[] hasQuery: boolean + /** B197 — hidden column ids from the `campaign_columns` cookie (server truth). */ + columnVisibility: CampaignColumnVisibility updateProfileAction: ( state: CampaignFormActionState, formData: FormData, @@ -92,6 +95,7 @@ export const AdvisorsTable = ({ rows, municipalityIndex, hasQuery, + columnVisibility, updateProfileAction, municipalitiesAction, createAction, @@ -103,6 +107,12 @@ export const AdvisorsTable = ({ const [draft, setDraft] = useState(null) const [isCreating, startCreate] = useTransition() + // B197 — email starts hidden on this list (cookie default); the picker + // writes the cookie and the server re-renders with a fresh value. The + // create-draft row below is the ONE exception: email is required to create + // an advisor, so its input renders even while the column is hidden. + const emailHidden = columnVisibility.hiddenColumnIds.includes('email') + const refresh = () => router.refresh() // One array per row per render of `rows`, not per render of this table: the @@ -211,7 +221,7 @@ export const AdvisorsTable = ({ Nome - E-mail + {!emailHidden ? E-mail : null} Celular Municípios @@ -340,22 +350,24 @@ export const AdvisorsTable = ({ )} - - {editing ? ( - - ) : ( - - )} - + {!emailHidden ? ( + + {editing ? ( + + ) : ( + + )} + + ) : null} {editing ? ( > = label: 'Cidade', cell: (supporter) => supporter.city ?? '—', }, + { + // B197 — born hidden by default; the picker can turn it on. The mobile + // card keeps no email (unchanged surface). + id: 'email', + label: 'E-mail', + cellClassName: 'max-w-56 whitespace-normal', + cell: (supporter) => (supporter.email ? supporter.email : '—'), + }, { id: 'municipality', label: 'Município', diff --git a/src/lib/campaignColumnVisibility.ts b/src/lib/campaignColumnVisibility.ts index 2403f09d..0dfefea5 100644 --- a/src/lib/campaignColumnVisibility.ts +++ b/src/lib/campaignColumnVisibility.ts @@ -26,6 +26,9 @@ const CAMPAIGN_LIST_IDS = [ 'apoiadores', 'territorios', 'pessoas', + // B197 — the advisors surface is a hand-rolled client table (no + // `CampaignTable`), but it joined the picker with the same cookie key. + 'assessores', ] as const export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number] @@ -72,6 +75,14 @@ const DEFAULT_HIDDEN_COLUMN_IDS: Partial diff --git a/src/utilities/supporter/supporterViewModels.ts b/src/utilities/supporter/supporterViewModels.ts index ba079531..f69230a2 100644 --- a/src/utilities/supporter/supporterViewModels.ts +++ b/src/utilities/supporter/supporterViewModels.ts @@ -9,6 +9,7 @@ export type SupporterListItemViewModel = { id: number name: string phone: string + email: string | null city: string | null municipalityName: string | null municipalitySlug: string | null @@ -57,6 +58,7 @@ export const toSupporterListItemViewModel = ( id: supporter.id, name: contact?.name ?? 'Contato sem nome', phone: contact?.phone ?? '', + email: contact?.email ?? null, city: contact?.city ?? null, municipalityName: municipality?.name ?? null, municipalitySlug: municipality?.slug ?? null, diff --git a/tests/e2e/campaignColumnPicker.e2e.spec.ts b/tests/e2e/campaignColumnPicker.e2e.spec.ts index f7987401..16d1fcb8 100644 --- a/tests/e2e/campaignColumnPicker.e2e.spec.ts +++ b/tests/e2e/campaignColumnPicker.e2e.spec.ts @@ -111,3 +111,73 @@ test.describe('Seletor de colunas', () => { await expect(mandatory).toBeDisabled() }) }) + +test.describe('E-mail oculto por padrão nas listas de pessoas (B197)', () => { + test('lideranças nasce sem a coluna de e-mail; o picker religa e a escolha persiste', async ({ + campaign, + page, + }) => { + test.slow() + await page.setViewportSize({ width: 1800, height: 900 }) + + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora B197'), + }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/liderancas') + + // A fresh device starts with the default: email hidden, picker saying so. + const emailHeader = page.getByRole('columnheader', { name: 'E-mail', exact: true }) + await expect(emailHeader).toHaveCount(0) + const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }) + await expect(picker).toBeVisible() + await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible() + + // Re-enabling paints the column and the choice survives a reload. + await picker.click() + await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click() + await page.keyboard.press('Escape') + await expect(emailHeader).toBeVisible(REFRESH) + await page.reload() + await expect(emailHeader).toBeVisible(REFRESH) + }) + + test('assessores ganha o picker com o e-mail oculto; o draft de criação mantém o input', async ({ + campaign, + page, + }) => { + test.slow() + await page.setViewportSize({ width: 1800, height: 900 }) + + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora B197 Assessores'), + }) + const advisorName = fixtures.value('Assessor B197') + await fixtures.createCampaignUser('advisor', { name: advisorName }) + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/assessores') + + // The row renders without the email column; the picker is there and says 1 hidden. + await expect(page.getByRole('link', { name: advisorName, exact: true })).toBeVisible() + const emailHeader = page.getByRole('columnheader', { name: 'E-mail', exact: true }) + await expect(emailHeader).toHaveCount(0) + const picker = page.getByRole('button', { name: /Mostrar ou ocultar colunas/ }) + await expect(picker).toBeVisible() + await expect(page.getByRole('button', { name: /1 oculta/ })).toBeVisible() + + // The create-draft row keeps its email input while the column is hidden + // (email is required to create an advisor). + await page.getByRole('button', { name: 'Novo assessor', exact: true }).click() + await expect(page.getByLabel('E-mail do novo assessor')).toBeVisible() + + // Re-enabling paints the column and the choice survives a reload. + await picker.click() + await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click() + await page.keyboard.press('Escape') + await expect(emailHeader).toBeVisible(REFRESH) + await page.reload() + await expect(emailHeader).toBeVisible(REFRESH) + }) +}) diff --git a/tests/unit/campaignColumnVisibility.unit.spec.ts b/tests/unit/campaignColumnVisibility.unit.spec.ts index e9c33f61..14d23066 100644 --- a/tests/unit/campaignColumnVisibility.unit.spec.ts +++ b/tests/unit/campaignColumnVisibility.unit.spec.ts @@ -32,7 +32,7 @@ describe('parseCampaignHiddenColumns', () => { }) it('drops unknown lists and column ids that are not identifiers', () => { - expect(parseCampaignHiddenColumns('assessores:email|municipios:kind')).toEqual({ + expect(parseCampaignHiddenColumns('desconhecida:email|municipios:kind')).toEqual({ municipios: ['kind'], }) expect(parseCampaignHiddenColumns('municipios:kind~