mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
Merge pull request #679 from fsolla/B197+-assessores-menu-do-picker-de-colunas-derivado-da-mesma
B197+: picker de colunas de assessores derivado da mesma fonte do AdvisorsTable
This commit is contained in:
commit
15461d7abf
6 changed files with 203 additions and 30 deletions
|
|
@ -4,9 +4,11 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `<th>` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o `<th>` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
|
||||
**Recently resolved (2026-08-11):** **C116** (Issue #655) — `/campanha/pessoas` desktop vira **edit-where-you-see**: toda célula (exceto Ações) é um input permanente sem destaque — salva no blur/Enter, Esc descarta, vazio mostra "—" apagado. **Nome** mantém o valor como link para a ficha (`/campanha/liderancas/[id]`) com o mecanismo travado no gate: input invisível (`text-transparent`, z-0) sobre o link (z-10, `pointer-events-auto`, `w-auto` — cobre só o texto; o resto da célula é a área clicável do input), draft renderizado **no texto do link** (o valor real do input nunca é o display), clique no link navega, teclado/área fora do texto edita. **Células de municípios** (Assessora, Lidera, Aliada em) e **Assessorado** usam chips internos com: grupo novo **"Salvador (19)"** (`buildMunicipalityPortfolioChips` ganha kind `city`, checado antes do território — senão as 19 ZEs colapsariam como "Metropolitano de Salvador"), **chip batch clicável expande in loco** (estado de apresentação do wrapper `PeopleMunicipalityCell` — clique expande, mouse-leave colapsa se todos os membros seguem atribuídos; X num membro remove e impede o recolapso), **"+X"** no toggle de overflow do B170, `quiet` (sem tint de hover/foco; indicador de foco discreto só no input), commit por **match exato** no Enter/espaço (digitar "salvador-ze-2"+Enter adiciona a ZE certa, não a pre-selecionada — regra da intenção). Escrita: `updatePersonContactRecord` (escopo "edita o que vê" — assessor só com entidade da pessoa na carteira; `contactFieldUpdateSchema` ganhou variante `city`, convivendo com `phones` do C112), `setPersonAssessoraMembershipRecord` (unrestricted-only, single-account — multi-conta nega com mensagem segura e célula read-only; toggle extraído para `toggleMunicipalityAdvisorMembership` em `municipality.ts`, reusado pelo record B27) e `setPersonAdvisorMembershipRecord` (union person-centric: delta de assessor nas DUAS entidades quando ambas existem). Matriz por papel na página: assessor edita Lidera/Aliada com `addableIds`=carteira e **read-only** em Assessora/Assessorado (regras das collections donas); tooltips de label (WhatsApp/Convidar/Apagar) via `CampaignHoverTooltip`; `CampaignListSheetProvider` na tabela (guard miss #52 — wrapper de composição registrado no `sharedSheetHostConventions`). Mobile intacto. Sem migration. Testes: unit (grupo Salvador + expand + match exato; guard do sheet host), int novo `campaignPersonCellEditing` (11 — escopo por papel, assessora single/multi/sem-conta, union nas duas entidades), e2e `campaignPeople` 4/4. Intenção [`pessoas-edicao-inplace-lista.md`](plans/pessoas-edicao-inplace-lista.md); impl [`pessoas-edicao-inplace-lista-impl.md`](plans/pessoas-edicao-inplace-lista-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **B198** (Issue #660) — o drawer mobile do Sollinha fecha ao navegar por link interno: tocar num link `/campanha…` da resposta fecha o drawer **no toque** (antes de a página carregar) e a navegação segue no mesmo tab; desktop inalterado (painel fica aberto); link externo (nova aba) não fecha o drawer da aba de origem; reload não reabre (o close persiste `open: false`). **Engrenagem:** a decisão pura mora no owner `src/lib/ai/markdownLinks.ts` — regex `^\/campanha(?:\/|$)` promovido a `isCampaignInternalLink` (era `APP_INTERNAL_LINK` no renderer) + `shouldCloseDrawerOnLinkClick(href, isMobile, event)` (fecha ⇔ interno && mobile && clique simples — ignora ctrl/meta/shift/alt, middle-click e `defaultPrevented`, pois abrem nova aba e a origem mantém o chat); o `markdownComponents` estático do `CampaignAIChat` virou `useMemo` no corpo (acesso ao `ctx.isMobile`/`setOpen`; identidade estável, recomputa só no crossing de breakpoint). Sessão/restauração intactas (fechar com `requestOpen(false)` marca `openBy:'user'` com `open:false` — inerte, o restore só reabre com `open:true`). Testes: unit (12 — interno/externo, viewport, modifiers, middle, prevented) + e2e no `campaignSollinhaContext` (interno fecha+navega sem `load`+reload não reabre; externo mantém drawer e abre popup interceptado). Nota: dev-mode local flakeou no prewarm/compile cold — e2e rodado no modo CI (`E2E_PROD=1` com `NEXT_DIST_DIR=.next/e2e`, 7/7). **Débito registrado no gate (B199/#672, `depends` #660):** race pré-existente do persist effect (só grava com `status==='ready'`) — fechar o drawer mid-stream e recarregar antes do settle reabre o drawer "sozinho" (classe OPS22); o tap-no-link do B198 amplia a superfície. Intenção [`sollinha-drawer-fecha-link-mobile.md`](plans/sollinha-drawer-fecha-link-mobile.md); impl [`sollinha-drawer-fecha-link-mobile-impl.md`](plans/sollinha-drawer-fecha-link-mobile-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **C114** (Issue #635) — Agenda da Campanha espelhada no Google Calendar com push Teqo→Google (API oficial, service account). **Motor:** reconciliação completa por passada (`runCampaignCalendarSync`) com **id determinístico de evento** (`teqo` + base32hex do id — charset do Google) → upsert idempotente sem estado por evento; janela 90d passado/365d futuro (precedente do feed iCal), espelho cheio do escopo do staff (sem intersecção de advisor — agenda oficial), cancelado/deletado no Teqo **apaga o evento no Google** (sem estado cancelado visível a quem segue); conteúdo comparado por instante absoluto (imune a fuso do calendário) → passadas repetidas não tocam o Google; eventos estrangeiros do calendário nunca são tocados (decode completo — um `teqo3` manual não é nosso). **Triggers (sem cron — Hobby):** hook `afterChange`/`afterDelete` na `activity` (só campos espelhados, com normalização de shapes populados vs depth-0; tarefas não disparam; **nunca lança** — Teqo independe do Google; timeout 15s por request), auto-retry na view da agenda quando `paused` (uma vez por carregamento), botão "Sincronizar agora", e **auto-resync por config** (D7): trocar `calendarId`/reativar na collection `googleCalendarSync` re-conciliar sozinho (guard por comparação de campo quebra o loop de re-entrada); scan de delete com lookback de 730d (cancelado antigo não vira fantasma; atividade viva fora da janela mantém o evento). **Estado:** collection `googleCalendarSync` (uma linha; `calendarId` admin-only, `disabledAt` staff, timestamps system-write; **status derivado** `not-configured | disabled | synced | paused` em lib pura) + **chave da service account só em env** (`GOOGLE_CALENDAR_SERVICE_ACCOUNT_KEY`, base64 do JSON do GCP — sem credencial no DB); sem env → `not-configured` com runbook de ops (criar calendário na conta da campanha, público no link, SA editor, calendarId no Painel). **Transporte:** `jose` (novo dep) + fetch → Calendar API v3 REST (token JWT assertion, cache em módulo, 401 re-minta uma vez, 409 de insert idempotente; `end` obrigatório → atividade sem `endAt` vira 1h). **UI (Impeccable B, padrão C94):** pill no header da agenda (sincronizado/pausado/não configurado/desativado) + FAB mobile + diálogo com os 4 estados do canvas (link público de adição `calendar/r?cid=webcal…` + instruções + sync agora + desativar; falha de action visível). **Sem participantes no v1** (decisão de produto do gate: aviso segue as configs de quem segue; service account exigiria domain-wide delegation — segue para o C115/Google→Teqo). DRY com o feed: janela/where/nomes de município/description compartilhados (`calendarFeed.ts` como dono da janela). Migration hand-written (o gerador re-emitia no-ops pré-existentes de `campaign_user.contact_id`). Testes: unit (mapping/derivação/guards com normalização/client com fetch fake/hook wiring via `vi.mock`), int (engine com stub in-memory: cria/converge/atualiza só drift/cancela deleta/delete limpa fantasma/falha→pausado→recupera/troca de calendarId), e2e (pill não-configurado + runbook, sem dependência externa). Débito de cobertura (estados disabled/paused/synced + ação) registrado como #666 (depends #635). Intenção [`c114-google-calendar-push-agenda-oficial.md`](plans/c114-google-calendar-push-agenda-oficial.md); impl [`c114-google-calendar-push-agenda-oficial-impl.md`](plans/c114-google-calendar-push-agenda-oficial-impl.md).
|
||||
|
||||
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
|
||||
|
|
|
|||
133
docs/plans/escala-dry-pos-b197-impl.md
Normal file
133
docs/plans/escala-dry-pos-b197-impl.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Impl: Assessores — menu do picker de colunas derivado da mesma fonte do AdvisorsTable
|
||||
|
||||
Status: concluído (executado 2026-08-11)
|
||||
Atualizado em: 2026-08-11
|
||||
Issue: #642
|
||||
Intenção: docs/plans/escala-dry-pos-b197.md
|
||||
Appetite restante: ~0,5 dia (herdado)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o menu do picker de colunas de assessores nasce da mesma fonte
|
||||
que os `<th>` da tabela — adicionar coluna num lugar atualiza o outro de
|
||||
graça. Zero mudança de comportamento.
|
||||
- **O que NÃO negociar:** mesmos ids (`name` mandatory, `email`, `phone`,
|
||||
`municipalities`, `actions` — contrato do cookie `campaign_columns`), mesmos
|
||||
labels pt-BR, mesma ordem, exceção do draft intacta (input de e-mail na
|
||||
linha-draft mesmo com a coluna oculta), e o não-escopo travado de não portar
|
||||
o `AdvisorsTable` para `CampaignTable`.
|
||||
- **O que reavaliar:** o plano de intenção já recomenda a Opção A (fonte única
|
||||
no módulo da tabela); a reavaliação aqui é de engenharia fina — onde a
|
||||
constante vive, como o `<th>` consome, como o `emailHidden` deriva.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S[advisorTableColumns<br/>AdvisorsTable.tsx] --> H[<th> via resolveVisibleColumns]
|
||||
S --> P[advisorPickerColumns =<br/>toCampaignColumnPickerColumns]
|
||||
P --> T[CampaignColumnPickerTrailing<br/>assessores/page.tsx]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C | D
|
||||
**Recomendação:** A — porque segue o precedente em produção
|
||||
(`supporterColumns`/`supporterPickerColumns` em `SupporterList.tsx` — o RSC de
|
||||
`apoiadores` importa a constante serializável do módulo do componente),
|
||||
reusa os helpers B17 (`toCampaignColumnPickerColumns`, `resolveVisibleColumns`)
|
||||
e mantém o diff em 2 arquivos.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — lib nova (`src/lib/advisorTableColumns.ts`):** só há dois consumidores
|
||||
e um é o mesmo módulo que renderiza; módulo lib com 1 call site real é
|
||||
camada sem volatilidade (depth check — pass-through).
|
||||
- **C — status quo (comentário):** o achado que originou a Issue é exatamente
|
||||
que o comentário é insuficiente; rejeitada pela intenção.
|
||||
- **D — extrair só id/label/mandatory e manter widths/sr-only no JSX:** deixa
|
||||
dois lugares para tocar ao adicionar coluna (layout do header) e obrigaria
|
||||
exceção manual no map para o caso `Ações`; a classe de header é propriedade
|
||||
da coluna, declarada junto.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Fonte única em `AdvisorsTable.tsx` (client).** Novo tipo local
|
||||
`AdvisorTableColumn { id, label, mandatory?, headerClassName?,
|
||||
visuallyHiddenLabel? }` e constante `advisorTableColumns` com as 5 colunas
|
||||
atuais (incluindo `w-[20%]`/`w-[24%]`/`w-[14%]`/`w-28 text-right` e
|
||||
`visuallyHiddenLabel: true` em `Ações`, que preserva o `<span class="sr-only">`).
|
||||
2. **`<th>` consomem `resolveVisibleColumns(advisorTableColumns,
|
||||
columnVisibility.hiddenColumnIds)`** — o mesmo helper B17 das outras listas:
|
||||
filtra por cookie respeitando `mandatory`, e `emailHidden` passa a derivar
|
||||
de `visibleColumns.some(id === 'email')`. **Descoberta do /simplify (fix
|
||||
incorporado):** o corpo renderiza as células incondicionalmente (só `email`
|
||||
tem `<td>` condicional + exceção do draft), então `phone`/`municipalities`/
|
||||
`actions` são `mandatory: true` — um id escondido por cookie velho derruba
|
||||
o `<th>` sobre um corpo de 5 células (table-fixed) se não for; o checkbox
|
||||
do picker vira "sempre visível" (honesto em vez do toggle inerte do B197).
|
||||
O corpo (células + exceção do draft) fica intocado.
|
||||
3. \*\*`export const advisorPickerColumns = toCampaignColumnPickerColumns(advisorTableColumns)`
|
||||
no módulo da tabela — mesma forma de `supporterPickerColumns`.
|
||||
4. **`assessores/page.tsx` importa `advisorPickerColumns`** e passa direto ao
|
||||
`CampaignColumnPickerTrailing`; removidos o array local, o comentário do
|
||||
espelho e os imports órfãos (`toCampaignColumnPickerColumns`,
|
||||
`CampaignColumnPickerColumn`).
|
||||
5. **e2e `campaignColumnPicker` (assessores):** asserts novos pinando o
|
||||
contrato mandatory — `Nome`/`Celular` "sempre visível" (checked + disabled).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`AdvisorsTable.tsx`** (`src/components/campaign/advisor/`): declara a
|
||||
fonte única, exporta `advisorPickerColumns`, header por map; corpo intacto.
|
||||
- **`assessores/page.tsx`** (`src/app/(campaign)/campanha/(app)/assessores/`):
|
||||
consome a fonte exportada.
|
||||
- **Migration:** sem migration.
|
||||
- **Access / Consent:** sem mudança (acesso já gateado no page).
|
||||
- **UI:** Impeccable A — sem superfície nova; refactor estrutura que preserva
|
||||
DOM/aria (sr-only de `Ações` mantido).
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Forma escolhida: colunas como dados serializáveis no módulo do componente,
|
||||
convertidas na fonte via helper B17. Alternativas rejeitadas: lib separada
|
||||
(sem call site extra) e manutenção do espelho (o próprio defeito).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Fonte única + header** — `AdvisorsTable.tsx`: tipo + constante + export +
|
||||
map do header com `resolveVisibleColumns`; `emailHidden` derivado.
|
||||
2. **Consumo no page** — `assessores/page.tsx`: import + remoção do array
|
||||
local e imports órfãos.
|
||||
3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`,
|
||||
`pnpm test` (unit: `campaignColumnVisibility` — lib intacta), e2e
|
||||
`campaignColumnPicker` (spec de assessores cobre exatamente os comportamentos
|
||||
preservados: default oculto, draft com input, religar, persistência);
|
||||
`pnpm exec knip` (removidos imports órfãos), `pnpm check:cycles`.
|
||||
4. **Fechamento** — status do plano de intenção → concluído; entrada curta em
|
||||
`docs/CHANGELOG-AGENTS.md`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não portar `AdvisorsTable` para `CampaignTable` (decisão travada do B197).
|
||||
- Não mexer em labels/ordem/ids — contrato do cookie `campaign_columns`.
|
||||
- Não criar coluna nova, não tocar células do corpo além de `emailHidden`
|
||||
derivado.
|
||||
- Sem novo unit test: o comportamento é idêntico e coberto pelo e2e existente;
|
||||
testar a constante exigiria importar o módulo client inteiro (lucide/next/
|
||||
sonner) — custo sem retorno para um espelho que deixou de existir.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Import de client module em RSC:** precedente em produção
|
||||
(`supporterPickerColumns` em `apoiadores/page.tsx`); valor serializável
|
||||
(array de `{id,label,mandatory}`).
|
||||
- **Layout `table-fixed`:** classes de largura movem para o mesmo `<th>`, na
|
||||
mesma ordem — DOM e classes idênticos, só que gerados por map com `key`.
|
||||
- **`emailHidden` divergente do header:** eliminado por construção — deriva do
|
||||
mesmo `visibleColumns` que renderiza o header.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (mesma fonte, zero mudança)
|
||||
- [x] Invariantes AGENTS/engineering-standards (edit the owner: a tabela já
|
||||
possui o concern do menu; reusa lib B17)
|
||||
- [x] Testes de domínio: sem mudança de access/write path; e2e existente é o
|
||||
guard do comportamento
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Escala e DRY pós-B197 (picker de colunas de assessores)
|
||||
|
||||
Status: pendente (capture-review-debts pós-B197, gate humano confirmado)
|
||||
Status: concluído (Issue #642, impl aprovado e executado 2026-08-11)
|
||||
Atualizado em: 2026-08-11
|
||||
Item do roadmap: fill-in **B197+** pós-[#627](https://github.com/fsolla/teqo/issues/627)
|
||||
Impeccable: A — sem superfície UI nova (DRY/estrutura)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import config from '@payload-config'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters'
|
||||
import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
|
||||
import { AdvisorsTable, advisorPickerColumns } from '@/components/campaign/advisor/AdvisorsTable'
|
||||
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
|
||||
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
|
||||
import {
|
||||
|
|
@ -11,10 +11,6 @@ import {
|
|||
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'
|
||||
|
|
@ -40,17 +36,6 @@ 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([
|
||||
|
|
@ -82,7 +67,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
|
|||
trailing={
|
||||
<CampaignColumnPickerTrailing
|
||||
columnVisibility={columnVisibility}
|
||||
columns={toCampaignColumnPickerColumns(advisorPickerColumns)}
|
||||
columns={advisorPickerColumns}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,11 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/Table'
|
||||
import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
|
||||
import {
|
||||
resolveVisibleColumns,
|
||||
toCampaignColumnPickerColumns,
|
||||
type CampaignColumnVisibility,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
|
||||
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
|
||||
import {
|
||||
|
|
@ -54,6 +58,37 @@ type DraftAdvisor = {
|
|||
municipalityIds: number[]
|
||||
}
|
||||
|
||||
// B197+ — single source for the `<th>`s and the picker menu: a column added
|
||||
// here appears in both, and `resolveVisibleColumns` keeps the cookie filtering
|
||||
// honest (a `mandatory` column is never dropped). Only what the body renders
|
||||
// conditionally is hideable: the cells are hand-wired per column id (draft row
|
||||
// AND data row), so `email` is the sole non-mandatory column — the others must
|
||||
// stay mandatory or a hidden id would drop the header under a 5-cell body.
|
||||
type AdvisorTableColumn = {
|
||||
id: string
|
||||
label: string
|
||||
mandatory?: boolean
|
||||
headerClassName?: string
|
||||
/** Renders the label visually-hidden — the icon-only actions column. */
|
||||
visuallyHiddenLabel?: boolean
|
||||
}
|
||||
|
||||
const advisorTableColumns: readonly AdvisorTableColumn[] = [
|
||||
{ id: 'name', label: 'Nome', mandatory: true, headerClassName: 'w-[20%]' },
|
||||
{ id: 'email', label: 'E-mail', headerClassName: 'w-[24%]' },
|
||||
{ id: 'phone', label: 'Celular', mandatory: true, headerClassName: 'w-[14%]' },
|
||||
{ id: 'municipalities', label: 'Municípios', mandatory: true },
|
||||
{
|
||||
id: 'actions',
|
||||
label: 'Ações',
|
||||
mandatory: true,
|
||||
headerClassName: 'w-28 text-right',
|
||||
visuallyHiddenLabel: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const advisorPickerColumns = toCampaignColumnPickerColumns(advisorTableColumns)
|
||||
|
||||
type AdvisorsTableProps = {
|
||||
rows: AdvisorRowViewModel[]
|
||||
municipalityIndex: MunicipalityPortfolioIndexEntry[]
|
||||
|
|
@ -108,10 +143,15 @@ export const AdvisorsTable = ({
|
|||
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')
|
||||
// writes the cookie and the server re-renders with a fresh value. B197+:
|
||||
// the visible set is what the `<th>`s render, so this exception below can
|
||||
// never disagree with the header. The create-draft row is the ONE exception:
|
||||
// email is required to create an advisor, so its input renders always.
|
||||
const visibleColumns = resolveVisibleColumns(
|
||||
advisorTableColumns,
|
||||
columnVisibility.hiddenColumnIds,
|
||||
)
|
||||
const emailHidden = !visibleColumns.some((column) => column.id === 'email')
|
||||
|
||||
const refresh = () => router.refresh()
|
||||
|
||||
|
|
@ -220,13 +260,15 @@ export const AdvisorsTable = ({
|
|||
<Table className="min-w-[56rem] table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[20%]">Nome</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">
|
||||
<span className="sr-only">Ações</span>
|
||||
</TableHead>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableHead key={column.id} className={column.headerClassName}>
|
||||
{column.visuallyHiddenLabel ? (
|
||||
<span className="sr-only">{column.label}</span>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -172,6 +172,17 @@ test.describe('E-mail oculto por padrão nas listas de pessoas (B197)', () => {
|
|||
await page.getByRole('button', { name: 'Novo assessor', exact: true }).click()
|
||||
await expect(page.getByLabel('E-mail do novo assessor')).toBeVisible()
|
||||
|
||||
// B197+ — the table body only conditions the email column, so every other
|
||||
// column is mandatory in the single source: their checkboxes are locked.
|
||||
await picker.click()
|
||||
const nameCheckbox = page.getByRole('checkbox', { name: 'Nome sempre visível' })
|
||||
await expect(nameCheckbox).toBeChecked()
|
||||
await expect(nameCheckbox).toBeDisabled()
|
||||
const phoneCheckbox = page.getByRole('checkbox', { name: 'Celular sempre visível' })
|
||||
await expect(phoneCheckbox).toBeChecked()
|
||||
await expect(phoneCheckbox).toBeDisabled()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Re-enabling paints the column and the choice survives a reload.
|
||||
await picker.click()
|
||||
await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click()
|
||||
|
|
|
|||
Loading…
Reference in a new issue