mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #639 from fsolla/B197-ocultar-a-coluna-de-e-mail-por-padrao-nas-listas-de-pes
B197 — Ocultar a coluna de e-mail por padrão nas listas de pessoas
This commit is contained in:
commit
419ba39ecc
11 changed files with 281 additions and 33 deletions
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
**Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md).
|
||||
|
||||
**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 `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` 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:<base>` 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 <N>` 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 <N>` (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).
|
||||
|
|
|
|||
79
docs/plans/ocultar-email-por-padrao-listas-impl.md
Normal file
79
docs/plans/ocultar-email-por-padrao-listas-impl.md
Normal file
|
|
@ -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<br/>+ assessores em CAMPAIGN_LIST_IDS] --> B[defaults por lista<br/>email oculto nas 5]
|
||||
B --> C[liderancas/dobradinhas<br/>só default]
|
||||
B --> D[pessoas: split Contato<br/>+ coluna email própria]
|
||||
B --> E[apoiadores: coluna email nova<br/>oculta por padrão]
|
||||
B --> F[assessores: picker B17<br/>+ 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 `<span>` 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={<CampaignColumnPickerTrailing columnVisibility={...} columns={advisorPickerColumns} />}` 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')`; `<TableHead>` "E-mail" e o `<TableCell>` 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 `<th>`.
|
||||
- **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
|
||||
|
|
@ -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<Record<string, string | string[] | undefined>>
|
||||
}
|
||||
|
||||
// B197 — labels mirror the `<th>` 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 (
|
||||
<CampaignPageShell>
|
||||
<CampaignListPendingBoundary>
|
||||
<AdvisorFilters state={state} municipalityFilterOptions={municipalityFilterOptions} />
|
||||
<AdvisorFilters
|
||||
state={state}
|
||||
municipalityFilterOptions={municipalityFilterOptions}
|
||||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={toCampaignColumnPickerColumns(advisorPickerColumns)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<CampaignListResults>
|
||||
<AdvisorsTable
|
||||
rows={rows}
|
||||
municipalityIndex={municipalityIndex}
|
||||
hasQuery={hasActiveFilters}
|
||||
columnVisibility={columnVisibility}
|
||||
updateProfileAction={updateAdvisorProfileFormAction}
|
||||
municipalitiesAction={setAdvisorMunicipalitiesFormAction}
|
||||
createAction={createAdvisorFormAction}
|
||||
|
|
|
|||
|
|
@ -136,16 +136,21 @@ const peopleColumns = ({
|
|||
id: 'contact',
|
||||
label: 'Contato',
|
||||
cellClassName: 'whitespace-normal',
|
||||
cell: (row) => (
|
||||
<span className="flex flex-col">
|
||||
{row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : null}
|
||||
<span className={row.phone ? 'text-xs text-muted-foreground' : undefined}>
|
||||
{row.email ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
cell: (row) =>
|
||||
row.phone ? (
|
||||
<span className="font-medium tabular-nums">{formatBrazilianPhoneInput(row.phone)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
// 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 : <span className="text-muted-foreground">—</span>),
|
||||
},
|
||||
{
|
||||
id: 'assessora',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<DraftAdvisor | null>(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 = ({
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">Nome</TableHead>
|
||||
<TableHead className="w-[24%]">E-mail</TableHead>
|
||||
{!emailHidden ? <TableHead className="w-[24%]">E-mail</TableHead> : null}
|
||||
<TableHead className="w-[14%]">Celular</TableHead>
|
||||
<TableHead>Municípios</TableHead>
|
||||
<TableHead className="w-28 text-right">
|
||||
|
|
@ -340,22 +350,24 @@ export const AdvisorsTable = ({
|
|||
</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={row.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${row.name}`}
|
||||
placeholderEmailFallback={row.email}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : (
|
||||
<CampaignCopyableCell value={emailShown} label="E-mail" />
|
||||
)}
|
||||
</TableCell>
|
||||
{!emailHidden ? (
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
advisorId={row.id}
|
||||
field="email"
|
||||
type="email"
|
||||
defaultValue={row.email ?? ''}
|
||||
placeholder="E-mail"
|
||||
ariaLabel={`E-mail de ${row.name}`}
|
||||
placeholderEmailFallback={row.email}
|
||||
formAction={updateProfileAction}
|
||||
/>
|
||||
) : (
|
||||
<CampaignCopyableCell value={emailShown} label="E-mail" />
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell>
|
||||
{editing ? (
|
||||
<AdvisorDebouncedTextCell
|
||||
|
|
|
|||
|
|
@ -102,6 +102,14 @@ const supporterColumns: Array<CampaignTableColumn<SupporterListItemViewModel>> =
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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<Record<CampaignListId, readonly string[
|
|||
// B175 — Cobertura is the deepest rung (P3) and only shows when the actor
|
||||
// opts in from the picker, mirroring the municipios precedent.
|
||||
territorios: ['cobertura'],
|
||||
// B197 — the person lists open without the email column; the channel of the
|
||||
// day is phone/WhatsApp. Anyone can re-enable it from the picker, and a
|
||||
// device with an existing choice keeps it (stored wins over default).
|
||||
liderancas: ['email'],
|
||||
dobradinhas: ['email'],
|
||||
pessoas: ['email'],
|
||||
apoiadores: ['email'],
|
||||
assessores: ['email'],
|
||||
}
|
||||
|
||||
const isCampaignListId = (value: string): value is CampaignListId =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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~<script>~trend')).toEqual({
|
||||
|
|
@ -76,8 +76,8 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
})
|
||||
|
||||
it('keeps the existing empty default for every other list', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', {})).toEqual({
|
||||
listId: 'liderancas',
|
||||
expect(resolveCampaignColumnVisibility('organizacoes', {})).toEqual({
|
||||
listId: 'organizacoes',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
|
@ -92,6 +92,34 @@ describe('resolveCampaignColumnVisibility', () => {
|
|||
hiddenColumnIds: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('hides the email column by default on every person list (B197)', () => {
|
||||
for (const listId of [
|
||||
'liderancas',
|
||||
'dobradinhas',
|
||||
'pessoas',
|
||||
'apoiadores',
|
||||
'assessores',
|
||||
] as const) {
|
||||
expect(resolveCampaignColumnVisibility(listId, {})).toEqual({
|
||||
listId,
|
||||
hiddenColumnIds: ['email'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a stored choice on a person list winning over the email default (B197)', () => {
|
||||
expect(resolveCampaignColumnVisibility('liderancas', { liderancas: [] })).toEqual({
|
||||
listId: 'liderancas',
|
||||
hiddenColumnIds: [],
|
||||
})
|
||||
expect(
|
||||
resolveCampaignColumnVisibility('assessores', { assessores: ['email', 'phone'] }),
|
||||
).toEqual({
|
||||
listId: 'assessores',
|
||||
hiddenColumnIds: ['email', 'phone'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleHiddenColumn', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue