feat(campaign): editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (Closes #813)

This commit is contained in:
Francisco Solla 2026-08-23 20:20:01 -03:00
parent 5b4e5ecf22
commit 324ba64003
4 changed files with 133 additions and 26 deletions

View file

@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23):** na lista de assessores (`/campanha/assessores`), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela `min-w-[56rem]` com scroll horizontal). Troca do invólucro contida em `AdvisorPermissionBadge.tsx` (ponto único dos dois usos — linha de dados com `advisorId`+`formAction` e linha de rascunho com `onDraftChange` local): o badge agora abre um **Dialog central** no desktop e um **bottom drawer** no celular, selecionados por `useNarrowMeasured(640)` vivendo no próprio badge (não na tabela). O `AdvisorPermissionEditor` entra com props idênticas às de hoje — mesmos combos Visão/Edição (`advisor-permission-visibility`/`advisor-permission-editing`), nota, footer Cancelar/Salvar e regras de coerência intactos; `onSaved`/`onCancel` fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via `onDraftChange` local, sem escrita nova no servidor. A11y: `DialogTitle`/`DrawerTitle` sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (`max-h-[calc(100dvh-2rem)]`+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe `assessores/[id]/page.tsx` intocado; import de `Popover` removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em `isNarrow`" se repete em >3 overlays — extrair primitivo `ResponsiveSheet` só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; `gate:push` na entrega; PR em main com auto-merge.
**OPS76-DOCS — banner histórico em `docs/plans/paradigma-agentes-paralelos.md` (plano órfão pré-main com refs mortas) (2026-08-23):** docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção `docs/plans/paradigma-agentes-paralelos.md` (escrito 2026-07-30, era pré-main, todos os todos `status: pending` — **nunca implementado nessa forma**) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: `scripts/refresh-stage.mjs` (deletado 2026-08-01), Neon/stage (`STAGE_DATABASE_URL`, branch git `stage`, `ci-stage.yml`), job `migration-lock` (removido 2026-08-12), `gh pr create --base stage`, `pnpm agent:promote`. **Decisão da Issue #800: banner histórico, não remoção** — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→`main`→deploy gated; precedentes de remoção em `docs/plans/` são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico `docs/plans/remodelagem-pracas.md:3`). **Edição única:** blockquote `> **HISTÓRICO (2026-08-23):**` self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título `# Teqo ops` (L48, [L32–L46 do arquivo final]) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo `docs/AGENT-OPS.md` (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas em bullets com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. **Corpo do plano congelado** (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: `git diff` = 1 bloco de adição; `pnpm format:check` verde; grep pós-zero refs vivas ao plano; guard `plans-only-closes` não bloqueia (diff inclui `docs/changelog/` + agregado — não é plans-only); `pnpm gate:fast` verde; `pnpm push` com PR `--base main` e `Closes #800` + auto-merge.
**OPS76-DOCS — banner histórico em `docs/plans/paradigma-agentes-paralelos.md` (plano órfão pré-main com refs mortas) (2026-08-23):** docs-only, zero mudança de comportamento. Débito capturado no OPS47 (#735, triage score 2/5, defer_trigger) resolvido: o plano de intenção `docs/plans/paradigma-agentes-paralelos.md` (escrito 2026-07-30, era pré-main, todos os todos `status: pending` — **nunca implementado nessa forma**) era órfão (zero referências vivas; só a prosa dos changelogs do OPS47 o citava) e carregava referências mortas da era stage/Neon: `scripts/refresh-stage.mjs` (deletado 2026-08-01), Neon/stage (`STAGE_DATABASE_URL`, branch git `stage`, `ci-stage.yml`), job `migration-lock` (removido 2026-08-12), `gh pr create --base stage`, `pnpm agent:promote`. **Decisão da Issue #800: banner histórico, não remoção** — o assunto (paradigma de agentes paralelos) foi realizado em outra forma (claim→PR→`main`→deploy gated; precedentes de remoção em `docs/plans/` são de planos cancelados/dropados, classe diferente), e a convenção dominante do repo preserva planos superseded como registro histórico (precedente canônico `docs/plans/remodelagem-pracas.md:3`). **Edição única de ~9 linhas:** blockquote `> **HISTÓRICO (2026-08-23):**` self-contained inserido entre o fechamento do frontmatter YAML (L30) e o título `# Teqo ops` (L32) — data o plano como pré-main (2026-07-30), aponta o doc canônico vivo `docs/AGENT-OPS.md` (ladder de 2 ambientes: mínimo + prod, sem smoke stage), lista as refs mortas com as datas de remoção e declara explicitamente que nada do corpo é instrução corrente. **Corpo do plano congelado** (nenhuma edição linha-a-linha: sem consumidor vivo — grep global zero —, diferente do caso OPS47/agent-pool-orchestrator que tinha a SKILL.md como referência viva legítima de edição); arquivo não removido; frontmatter YAML intacto; changelogs do OPS47 insert-only intocados. Sem migration/access/Consent/UI/schema. Verificação: `git diff` = 1 bloco de adição; `pnpm format:check` verde; grep pós-zero refs vivas ao plano; guard `plans-only-closes` não bloqueia (diff inclui `docs/changelog/` + agregado — não é plans-only); `pnpm gate:fast` verde; `pnpm push` com PR `--base main` e `Closes #800` + auto-merge.

View file

@ -0,0 +1 @@
**C145 — editor de permissão do assessor em modal central (desktop) + bottom drawer (mobile) no lugar do popover (2026-08-23):** na lista de assessores (`/campanha/assessores`), o badge "Permissão" abria o editor Visão × Edição num popover ancorado que estourava o viewport (tabela `min-w-[56rem]` com scroll horizontal). Troca do invólucro contida em `AdvisorPermissionBadge.tsx` (ponto único dos dois usos — linha de dados com `advisorId`+`formAction` e linha de rascunho com `onDraftChange` local): o badge agora abre um **Dialog central** no desktop e um **bottom drawer** no celular, selecionados por `useNarrowMeasured(640)` vivendo no próprio badge (não na tabela). O `AdvisorPermissionEditor` entra com props idênticas às de hoje — mesmos combos Visão/Edição (`advisor-permission-visibility`/`advisor-permission-editing`), nota, footer Cancelar/Salvar e regras de coerência intactos; `onSaved`/`onCancel` fecham o overlay e o badge reflete o novo perfil; a linha de rascunho segue via `onDraftChange` local, sem escrita nova no servidor. A11y: `DialogTitle`/`DrawerTitle` sr-only "Permissão da conta" cumprem o Radix sem duplicar o heading visível do editor; guards de altura (`max-h-[calc(100dvh-2rem)]`+scroll no dialog, capa do drawer) garantem o "nunca estoura" do aceite. Editor embutido no detalhe `assessores/[id]/page.tsx` intocado; import de `Popover` removido. Débito adiado com gatilho: o padrão "Dialog/Drawer keyed em `isNarrow`" se repete em >3 overlays — extrair primitivo `ResponsiveSheet` só quando uma 3ª superfície precisar do mesmo wrapper. Verificação: lint/typecheck/unit + int 830 verdes; `gate:push` na entrega; PR em main com auto-merge.

View file

@ -0,0 +1,75 @@
# Impl: Assessores — editor de permissão em modal central (desktop) + bottom drawer (mobile) no lugar do popover
Status: rascunho
Atualizado em: 2026-08-23
Issue: #813
Intenção: docs/plans/permissao-assessores-modal-central.md
Appetite restante: herdado (~0,5–1 dia eng)
## Leitura da intenção
- **Outcome**: na `/campanha/assessores`, tocar no badge "Permissão" abre o editor Visão × Edição num overlay **centralizado que nunca estoura o viewport** — Dialog central no desktop, bottom drawer no celular. Só o invólucro muda; o editor, os combos, a nota e as regras de coerência do C141 ficam intactos. Salvar fecha o overlay e o badge reflete o novo perfil; a linha de assessor de rascunho segue usando `onDraftChange` local.
- **O que NÃO negociar**: editor intocado (inclusive footer Cancelar condicional + Salvar e ids `advisor-permission-visibility`/`advisor-permission-editing`); `aria-label="Permissão de ${advisorName}"` do badge preservado; editor embutido no detalhe do assessor (`[id]/page.tsx`) não participa; nenhuma escrita nova no banco (acesso/transações N/A); sinal de breakpoint **vive no badge/overlay, não na tabela**.
- **O que reavaliar**: nada de produtividade aberto — bottom drawer, "salvar fecha" e footer mantido foram assumidos na intenção. Reavaliar só decisões técnicas caras (a11y do Dialog, onde mora o `isNarrow`, guarda de altura) — detalhadas abaixo.
## Abordagem recomendada
```mermaid
flowchart TD
A[AdvisorPermissionBadge: botão + estado open local] --> B{useNarrowMeasured 640}
B -- isNarrow true --> C[Drawer bottom swipeDirection=down]
B -- isNarrow false --> D[Dialog central]
C --> E[AdvisorPermissionEditor<br/>props hoje-idênticas + onSaved/onCancel =&gt; setOpen false]
D --> E
E --> F[Salvar =&gt; onSaved =&gt; overlay fecha<br/>badge reflete; onDraftChange mantido]
```
**Opções consideradas:** A (manter Popover) | B (`isNarrow` passado por prop a partir da `AdvisorsTable`) | C (troca do invólucro **dentro** do `AdvisorPermissionBadge`, `isNarrow` via hook no próprio badge) — **recomendada C**.
**Rejeitadas (só decisões caras):**
- **A — manter Popover.** É o status quo que estoura o viewport; a tabela `min-w-[56rem]` com scroll horizontal é exatamente o sintoma que o outcome manda matar. Rejeitada pela intenção.
- **B — `isNarrow` em cada call site / prop do host.** Forçaria `AdvisorsTable` a conhecer o breakpoint e a passar o sinal nos dois usos (dados ~473 e rascunho ~351) — é o rabbit hole "estado responsivo na tabela" que a intenção manda cortar; o sinal pertence ao invólucro. Rejeitada.
- _(Não considerada: componente two-in-one responsivo novo — não pedido; a troca fica no arquivo do badge.)_
### Componentes / mudanças
- **`AdvisorPermissionBadge.tsx`** (`src/components/campaign/advisor/`): o único arquivo alterado. Troca o wrapper `Popover` por `isNarrow ? <Drawer> : <Dialog>`; adiciona `useNarrowMeasured(640)` no badge; mantém o `<button>` do trigger intacto (`aria-label` preservado) e o par `open`/`onOpenChange={setOpen}`; o editor entra com as **mesmas props** de hoje incl. `onSaved`/`onCancel` → `setOpen(false)`. Remove o import de `@/components/ui/Popover` e os estilos mortos `w-80 align="start"`.
- **`AdvisorPermissionEditor.tsx`** (`src/components/campaign/advisor/`): **não toca**. `showHeading=true` default renderiza o heading visível "Permissão da conta"; footer Cancelar (condicional) + Salvar exatamente como está.
- **`dialog.tsx`** (`src/components/ui/`, minúsculo — a intenção diz `Dialog.tsx`, não existe; usar o caminho real): somente uso, sem alteração. `DialogContent` default traz `max-w-sm`, `showCloseButton=true` (X) e requer `DialogTitle` para a11y.
- **`Drawer.tsx`** (`src/components/ui/`): somente uso, sem alteração. `swipeDirection='down'` default já é bottom-anchored; capa de altura default limita o drawer na tela.
- **`useNarrowMeasured(640)`** (`src/hooks/use-mobile.ts`): existe; usar `{ isNarrow }`, ignorar `measured` (overlay só abre por interação).
- **Migration**: sem migration.
- **Access / Consent**: N/A — nenhuma escrita nova; server action `updateAdvisorPermissionFormAction` intacta.
- **UI**: Impeccable B — shape→craft→critique→polish; shell do C123 a reusar (Dialog central + Drawer bottom), sem alterar o `ui/` kit.
## Fases verificáveis
1. **Tracer** — troca mínima do invólucro em `AdvisorPermissionBadge.tsx`: hook `useNarrowMeasured`, controle `open` idêntico, editor com props inalteradas, remoção do import de Popover. Verificar no `pnpm dev` os dois pontos da `AdvisorsTable` (rascunho ~351 e dados ~473) e o detalhe `[id]/page.tsx` **intocado**. Gate: `tsc --noEmit`, lint.
2. **UI** — crafting da superfície (sem tocar o kit): `DialogContent` com `max-h-[calc(100dvh-2rem)]` + `overflow-y-auto` (guarda anticlip em viewports baixos); `DialogTitle` **sr-only** (texto "Permissão da conta") para cumprir a a11y do Radix **sem** duplicar o heading visível do editor; `DrawerContent` com `overflow-y-auto` + padding, apoiado na capa default; `DrawerHeader`/`DrawerTitle` também sr-only; tema `data-theme="campaign"` herdado pelos portais (sem re-aplicação). Verificar desktop largo/estreito (nunca estoura), mobile bottom drawer. Gate: `tests/int/campaignAdvisorPermission.int.spec.ts` (server-side, não deve mudar), build.
3. **Gates** — `pnpm gate:fast` (lint, format, typecheck, knip, cycles, test, build). Sem migration/access, sem unit novo do badge (não há unit hoje; e2e `campaignPermissionProfile` não toca o badge). Não adicionar e2e novo neste item (appetite).
## Rabbit holes / Não escopo (engenharia)
- **Cascata de troca de popovers** no `/campanha` — fora; só o badge de permissão decide.
- **Editor inline no badge** / estado responsivo na `AdvisorsTable` — fora; o sinal `useNarrowMeasured(640)` vive no badge.
- **Two-in-one responsivo novo** / alterações no `ui/dialog.tsx` ou `ui/Drawer.tsx` — não pedido; uso apenas.
- **X do Dialog como "fechar sem salvar" adicional**: manter default `showCloseButton=true` — coerente com a superfície e com Esc/backdrop que já fecham sem salvar; não toca o footer do editor.
- **Adiado com gatilho (triage simplify):** o padrão "Dialog/Drawer keyed em `isNarrow`" já se repete em >3 overlays (`ActivityOverlay`, `CampaignNotificationBell`, `CampaignUpdatesCreateModal`, `CalendarFeedDialog`, `GoogleCalendarSyncDialog`, …). Extrair um primitivo `ResponsiveSheet` genérico em `components/ui` **agora** seria abstração prematura (cada overlay é personalizado; este badge é um call site distinto). **Gatilho:** quando uma 3ª superfície precisar do MESMO wrapper repetido → extrair; registrar como débito à parte, não bloatar esta entrega.
## Riscos e mitigação
- **Radix `Dialog` sem `DialogTitle`** → warning de a11y / screen-reader lê overlay sem nome. Mitigação: `DialogTitle` sr-only com o mesmo texto do heading do editor; sem duplicação visual.
- **Overflow em viewport muito baixo** (desktop e drawer) → `max-h` + `overflow-y-auto` no DialogContent/DrawerContent; o drawer já tem capa default. Garante o "nunca estoura" do aceite.
- **Algum seletor e2e ancorado no DOM do Popover do badge** → verificado pelo explorador: `campaignPermissionProfile.e2e` (C142) e `campaignColumnPicker.e2e` (renderiza linha, não abre o badge) não selecionam o popover. Re-conferir com grep antes do merge.
- **Editor remonta por overlay** → o editor reseta estado local a cada abertura, igual ao Popover atual; nenhuma regressão. `onDraftChange` continua local, sem escrita no servidor.
## Aceite de engenharia
- [ ] Troca de invólucro inteira dentro de `AdvisorPermissionBadge.tsx`; `AdvisorPermissionEditor.tsx`, `dialog.tsx`, `Drawer.tsx`, `use-mobile.ts` e `AdvisorsTable.tsx` sem edição.
- [ ] Badge segue com o mesmo `<button>` e `aria-label={"Permissão de ${advisorName}"}` (linha 49).
- [ ] `onSaved` e `onCancel` fecham o overlay (`setOpen(false)`); ao salvar, o badge atualiza; rascunho segue via `onDraftChange` local.
- [ ] Editor recebe props idênticas às de hoje; combos, nota, footer Cancelar/Salvar e regras de coerência intactos; ids `advisor-permission-visibility`/`advisor-permission-editing` preservados.
- [ ] Desktop → Dialog central com `DialogTitle` sr-only (sem heading duplicado) e `max-h`+scroll (nunca estoura); mobile → bottom drawer com capa e scroll.
- [ ] Editor embutido em `assessores/[id]/page.tsx` intocado.
- [ ] Import de `Popover` removido; `pnpm gate:fast` verde; `tests/int/campaignAdvisorPermission.int.spec.ts` passa sem mudança.

View file

@ -3,7 +3,9 @@
import { useState } from 'react'
import { AdvisorPermissionEditor } from '@/components/campaign/advisor/AdvisorPermissionEditor'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
import { useNarrowMeasured } from '@/hooks/use-mobile'
import {
advisorProfileLabel,
type AdvisorEditing,
@ -25,10 +27,14 @@ type AdvisorPermissionBadgeProps = {
advisorName?: string
}
const NARROW_BREAKPOINT = 640
const OVERLAY_TITLE = 'Permissão da conta'
/**
* C141 — the "Permissão" cell: a compact badge that opens the Visão × Edição
* editor in a popover. Reused in the advisors list and the new-advisor draft
* row; the detail page embeds the editor directly in its own section.
* C141/C145 — the "Permissão" cell: a compact badge that opens the Visão ×
* Edição editor in a centered dialog (desktop) or bottom drawer (mobile).
* Reused in the advisors list and the new-advisor draft row; the detail page
* embeds the editor directly in its own section.
*/
export const AdvisorPermissionBadge = ({
visibility,
@ -39,29 +45,52 @@ export const AdvisorPermissionBadge = ({
advisorName = 'conta',
}: AdvisorPermissionBadgeProps) => {
const [open, setOpen] = useState(false)
const { isNarrow } = useNarrowMeasured(NARROW_BREAKPOINT)
const close = () => setOpen(false)
const editor = (
<AdvisorPermissionEditor
visibility={visibility}
editing={editing}
advisorId={advisorId}
formAction={formAction}
onDraftChange={onDraftChange}
onSaved={close}
onCancel={close}
/>
)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold text-foreground underline-offset-4 hover:bg-accent hover:underline"
aria-label={`Permissão de ${advisorName}`}
>
{advisorProfileLabel(visibility, editing)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<AdvisorPermissionEditor
visibility={visibility}
editing={editing}
advisorId={advisorId}
formAction={formAction}
onDraftChange={onDraftChange}
onSaved={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</PopoverContent>
</Popover>
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold text-foreground underline-offset-4 hover:bg-accent hover:underline"
aria-label={`Permissão de ${advisorName}`}
>
{advisorProfileLabel(visibility, editing)}
</button>
{isNarrow ? (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="sr-only">{OVERLAY_TITLE}</DrawerTitle>
</DrawerHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4">{editor}</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
<DialogHeader>
<DialogTitle className="sr-only">{OVERLAY_TITLE}</DialogTitle>
</DialogHeader>
{editor}
</DialogContent>
</Dialog>
)}
</>
)
}