mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
5b4e5ecf22
commit
324ba64003
4 changed files with 133 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-23-c145.md
Normal file
1
docs/changelog/2026-08-23-c145.md
Normal 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.
|
||||
75
docs/plans/permissao-assessores-modal-central-impl.md
Normal file
75
docs/plans/permissao-assessores-modal-central-impl.md
Normal 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 => setOpen false]
|
||||
D --> E
|
||||
E --> F[Salvar => onSaved => 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.
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue