fix(agenda): modais usáveis no desktop — criação, sync e feed (C148)

This commit is contained in:
Francisco Solla 2026-09-12 02:07:22 -03:00
parent 581cb933a8
commit 95e4692df4
12 changed files with 447 additions and 204 deletions

View file

@ -0,0 +1 @@
**C148 — Agenda: modais usáveis no desktop (2026-09-12):** os três modais da agenda (`/campanha/atividades` e `/campanha/agenda`) param de brigar com a janela no desktop. O overlay de criação/edição, o dialog de sync Google e o dialog do feed ICS passam a espelhar as variantes `sm:` da base `ui/dialog.tsx` (`sm:max-w-3xl`/`sm:max-w-xl` + `sm:p-0`) — antes os overrides sem variante perdiam para `sm:max-w-md`/`sm:p-8`, e o modal abria com 28rem de largura e padding dobrado (as "bordas gigantes"). Os três ganham corpo rolável (`min-h-0 flex-1 overflow-y-auto`, `data-slot="dialog-scroll-body"`) com header e rodapé `shrink-0` (`data-slot="dialog-footer"`): o conteúdo nunca corta e as ações ficam sempre visíveis — o sync/feed no desktop ganhou rodapé fixo com as ações (o erro da ação fica junto delas no desktop; no mobile a composição antiga foi preservada). O par Início/Término do overlay deixa de empilhar: data+hora inline na mesma linha e selects de hora/minuto com `w-16` (o grid `sm:grid-cols-2` já existia). `ui/dialog.tsx` e os outros 7 consumidores ficam intocados; o contrato mobile (Drawer/`DrawerFooter`) não muda. Testes: asserções estruturais nos units dos três dialogs (conteúdo no corpo, ações no rodapé) e nos e2e (`campaignActivity` mede largura > 600 e o par na mesma linha; `campaignAgendaGoogleSync` encolhe a janela para 560px e prova scroll interno + rodapé visível). O `E2E_AFFECTED_MANIFEST` passa a acordar `campaignAgendaGoogleSync` e `campaignAgendaFeed` para mudanças em `src/components/campaign/activity`. Sem migration, sem access/Consent.

View file

@ -0,0 +1,117 @@
# Impl: Agenda: modais usáveis no desktop (criação de atividade, sync e feed)
Status: rascunho
Atualizado em: 2026-09-12
Issue: #933
Intenção: docs/plans/c148-modais-agenda-desktop.md
Appetite restante: herdado — ~0,5–1 dia eng. Correção local de encaixe em 3 componentes + testes; sem migration, sem schema, sem access/Consent.
## Leitura da intenção
- **Outcome:** no desktop, os três modais da agenda (criação/edição, sync Google, feed ICS) abrem com largura/padding coerentes, nunca cortam conteúdo (corpo rola internamente com o rodapé de ações sempre visível) e o par Início/Término do modal de criação fica lado a lado com data+hora na mesma linha. Mobile intocado.
- **O que NÃO negociar:** (1) mobile dos três modais não regride — o DOM do Drawer/folha atual (overlay `h-[calc(100dvh-1rem)]` + corpo `min-h-0 flex-1 overflow-y-auto`; sync/feed `max-h-[85vh]` + corpo rolável + `DrawerFooter`) permanece exatamente o de hoje; (2) lógica de validação/salvamento e dados intocados; (3) nenhum outro modal do app muda — `ui/dialog.tsx` e seus 10 consumidores intactos; (4) sem migration/Consent (dado interno de staff, `activity` não tem Consent).
- **O que reavaliar:** a hipótese central da intenção está **confirmada**, com mecânica precisa: `cn` = clsx + tailwind-merge resolve conflito de mesma variante (`grid`→`flex`, `p-6`→`p-0`, `gap-4`→`gap-0`, `max-w-sm`→`max-w-2xl`), mas **não** derruba `sm:max-w-md`/`sm:p-8` da base quando o override não tem `sm:`; no CSS do Tailwind v4 a utility com variante é emitida depois e vence em ≥640px. Correção = espelhar a variante (`sm:max-w-3xl sm:p-0`), deixando o twMerge apagar as da base — sem guerra de ordem. Dois defeitos vizinhos no mesmo caminho: `w-full` no overlay anula o gutter de 2rem da base (`w-[calc(100vw-2rem)]`), e `max-h-[calc(100dvh-14rem)]` é número mágico acoplado às alturas de header/footer. O "empilhamento" do par data/hora é sintoma da largura de 28rem (o grid já é `sm:grid-cols-2`; o componente já renderiza data+hora inline), não falta de estrutura — não há layout novo a inventar.
- **Fora de escopo reafirmado:** a divergência de breakpoint (overlay 640 via `useNarrowMeasured(640)` em `ActivityCreateOverlayHost.tsx:35`/`ActivityEditOverlayHost.tsx:34` vs sync/feed 768 via `useIsMobile()` em `src/hooks/use-mobile.ts:3`) **não** será unificada nesta entrega; as fronteiras atuais são preservadas.
## Abordagem recomendada
```mermaid
flowchart LR
A[ActivityOverlay desktop] --> A1["DialogContent: sm:max-w-3xl sm:p-0<br/>remove w-full e max-w-2xl"]
A1 --> A2["form min-h-0 flex-1 + corpo min-h-0 flex-1 overflow-y-auto<br/>(morre max-h-[calc(100dvh-14rem)])"]
A2 --> A3["Início/Término em sm:grid-cols-2<br/>data+hora inline por campo"]
B[Sync Google / Feed ICS desktop] --> B1["DialogContent: flex max-h-[calc(100dvh-2rem)]<br/>flex-col p-0 sm:max-w-xl sm:p-0"]
B1 --> B2["header shrink-0 / corpo rolável /<br/>footer shrink-0 com as ações"]
B2 --> B3["mobile: composição atual intacta<br/>(body com ações + DrawerFooter)"]
C["ui/dialog.tsx"] -. "não tocar" .-> A
C -. "não tocar" .-> B
```
**Opções consideradas:** A) override local com variantes `sm:` explícitas nos 3 modais | B) mudar a base `ui/dialog.tsx` (largura/padding/scroll default) | C) extrair um shell compartilhado "modal desktop com corpo rolável" para os 3 call sites.
**Recomendação:** A — cada modal já tem conteúdo, padding e largura próprios; a correção é de classe, e o padrão a replicar já existe no repo (QuickActions: `flex max-h-[min(85dvh,40rem)] … overflow-hidden` + filho `overflow-y-auto`, `CampaignQuickActionsOverlay.tsx:166-186`; Advisor: `max-h-[calc(100dvh-2rem)] overflow-y-auto`, `AdvisorPermissionBadge.tsx:86`). Zero superfície de regressão fora da agenda.
**Rejeitadas:**
- **B — mexer em `ui/dialog.tsx`:** base de 10 imports (`Command`, `PetitionSuccessDialog`, `CampaignNotificationBell`, `CampaignQuickActionsOverlay`, `MunicipalityV2StatusReasonDialog`, `CampaignUpdatesCreateModal`, `AdvisorPermissionBadge` + os 3 da agenda). Mudar `sm:max-w-md`/`sm:p-8`/scroll default é regressão silenciosa em telas não auditadas; a própria intenção marca como rabbit hole.
- **C — shell compartilhado:** seria um pass-through raso (classes + 2 slots) para 3 modais heterogêneos (overlay `p-0` + cards; sync/feed padding próprio e `max-w-xl`), sem conhecimento encapsulado — depth check "pass-through raso → não criar". O repo já resolve isso por receita inline repetida (QuickActions, Advisor, sheet mobile). **Gatilho de revisitação:** 4º modal da agenda com o mesmo corpo rolável, ou adoção do padrão por outro domínio.
### Decisões de engenharia
1. **Onde a rolagem do overlay desktop acontece.** Opções: A) `form` `min-h-0 flex-1` + corpo `min-h-0 flex-1 overflow-y-auto`, herdando o `max-h` do `DialogContent` (padrão do sheet mobile e do QuickActions) | B) manter `max-h-[calc(100dvh-14rem)]` e só corrigir largura/padding. **Recomendação: A** — o magic number é a causa do corte quando header/footer mudam de altura; A torna a geometria derivada (`100dvh - 2rem` no content + header/footer `shrink-0`). **Rejeitada:** B porque preserva o acoplamento que gerou o defeito.
2. **Sync/feed: rodapé fixo vs conteúdo inteiro rolável.** Opções: A) header/body/footer em flex, ações extraídas para footer `shrink-0` (draft cena 2; aceite exige "rodapé de ações permanece visível") | B) `overflow-y-auto` no `DialogContent` inteiro (padrão Advisor). **Recomendação: A** — só A satisfaz o aceite; B rola as ações para fora. **Rejeitada:** B (mais simples, porém falha o aceite explícito).
3. **Disposição Início/Término (questão aberta de produto).** Opções: A) dois campos lado a lado, cada um data+hora inline (draft cena 1) | B) um campo por linha com data+hora inline. **Recomendação: A** — o grid `sm:grid-cols-2` (`ActivityOverlay.tsx:355`) já existe; com a largura recuperada cada coluna ganha ~20rem e o gatilho de data (~10rem) mostra `07/08/2026` sem truncar. **Rejeitada:** B (perde a largura recuperada; vira fallback). **Gatilho de revisitação:** se no gate a faixa 640–767px ficar ilegível, cair para B nessa faixa (não antes).
4. **Larguras.** Overlay: `sm:max-w-3xl` (48rem, cena 1) vs `sm:max-w-2xl` (42rem, hipótese da intenção). Sync/feed: `sm:max-w-xl` (36rem, cena 2) vs manter `sm:max-w-lg` (32rem). **Recomendação: 48rem/36rem** — o rascunho UI é o gate aprovado; "largura generosa" e "ampliar pouco" estão materializados nas cenas. **Rejeitadas:** manter 42rem/32rem — a diferença é uma classe e o alvo está no gate.
5. **Como preservar o mobile exatamente.** Opções: A) dividir o `body` em `content` + `actionRow` e recompor o ramo mobile na mesma ordem de hoje | B) footer desktop com `position: sticky` dentro do corpo atual. **Recomendação: A** — DOM mobile idêntico (os unit mobile e os e2e C103/C94/C114 são a prova), sem sticky frágil com padding negativo. **Rejeitada:** B (quebra com `space-y`/padding do content; menos explícito que o flex).
### Componentes / mudanças
- **`ActivityOverlay.tsx`** (só caminho desktop):
- `DialogContent` (`:601`): `flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0` → `flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0` (remove `w-full` para voltar o gutter `w-[calc(100vw-2rem)]` da base; `sm:` espelhado derruba `sm:max-w-md`/`sm:p-8` via twMerge).
- `DialogHeader` desktop (`:602`): + `shrink-0` (mantém `border-b px-6 py-4`).
- `form` (`:442`): ramo desktop `flex flex-col` → `flex min-h-0 flex-1 flex-col` (igual ao sheet).
- corpo (`:451-456`): ramo desktop `max-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6` → `min-h-0 flex-1 overflow-y-auto overscroll-contain p-6`; + `data-slot="dialog-scroll-body"`.
- rodapé (`:520-525`): classes inalteradas (`shrink-0` já existe); + `data-slot="dialog-footer"`.
- **`GoogleCalendarSyncDialog.tsx`:**
- separar o `body` (`:186-286`) em `content` (sem as ações) + `actions` por estado (`not-configured` → nenhuma; `disabled` → Reativar; `synced`/`paused` → Abrir Google Calendar quando `paused` + Sincronizar agora + Desativar). `actionError` vai para o rodapé desktop (junto da ação que falhou) e **fica na posição atual no mobile** (entre content e actions).
- mobile (`:296-310`): composição idêntica à de hoje — `DrawerContent max-h-[85vh]` + `<div className="overflow-y-auto px-4 pb-2">{content}{actionError}{actionRow}</div>` + `DrawerFooter` Fechar.
- desktop (`:313-323`): `DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0"`; `DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left"`; corpo `<div data-slot="dialog-scroll-body" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"><div className="space-y-4">{content}</div></div>`; rodapé `<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">{actionError}{actionRow}</div>` (não renderiza quando ambos vazios — `not-configured`).
- **`CalendarFeedDialog.tsx`:**
- mesmo split: `content` + `actions` (criado: Fechar desktop + Abrir Google Calendar; senão: Cancelar desktop + Gerar link); erro do form continua no corpo, junto do input.
- mobile (`:216-233`) idêntico; desktop (`:236-248`) com o mesmo shell do sync e `sm:max-w-xl`.
- **`ActivityDateTimeField.tsx`:** uma linha — wrapper dos selects (`:154`) `isNarrow ? 'w-16' : 'w-20'` → `w-16` (o draft cena 1 usa `w-16`; o mobile já era `w-16`, então só o desktop muda). Mantém o rótulo completo da data mesmo na faixa 640–767px. Nenhuma outra mudança no componente.
- **`src/components/ui/dialog.tsx`:** **sem mudança** — base intacta para os 10 consumidores.
- **Testes:**
- unit — `tests/unit/activityOverlay.unit.spec.tsx`, `googleCalendarSyncDialog.unit.spec.tsx`, `calendarFeedDialog.unit.spec.tsx`: asserções estruturais novas (não de classe): botão de ação dentro de `[data-slot="dialog-footer"]` e **fora** de `[data-slot="dialog-scroll-body"]`; conteúdo no corpo. O caminho desktop é o default dos 3 specs (`matchMedia` mockado `matches:false`; overlay `isNarrow=false`).
- e2e — `campaignActivity.e2e.spec.ts` (jornada desktop `:51`): após abrir o modal, `boundingBox` da largura `> 600` (guarda do "estreito"; hoje 448) e `|Δy| < 4` entre Início e Término — o aceite "não empilha" vira asserção. `campaignAgendaGoogleSync.e2e.spec.ts` (teste synced `:138`): encolher o viewport para 1280×560, manter o diálogo aberto, assertar `Sincronizar agora` visível + `[data-slot="dialog-scroll-body"]` com `scrollHeight > clientHeight` + box do diálogo dentro do viewport. Feed: sem e2e novo (mesmo shell do sync; coberto pelo unit estrutural); os e2e existentes (`campaignAgendaFeed.e2e.spec.ts:18` e `:73`) seguem como regressão.
- **Changelog:** `docs/changelog/<data>-c148.md` — uma entrada curta da entrega (nunca editar o agregado; `pnpm changelog:build` gera).
- **Migration:** sem migration (nenhum schema/field).
- **Access / Consent:** N/A — dado interno de staff; nenhum write path muda.
- **UI:** Impeccable B — shape já fixado no rascunho (`docs/plans/c148-modais-agenda-desktop-ui-draft.html`, cenas 1–3); craft = classes desta entrega; critique = gate visual nas 3 cenas (1280px e 390px); polish só se o gate pedir. Shells reusados: padrão do Drawer mobile (header/body/footer com `min-h-0 flex-1 overflow-y-auto`) e o par `flex max-h … overflow-hidden` + filho rolável do QuickActions.
### Dados → forma (se aplicável)
N/A — a entrega não apresenta dado novo (pergunta 3 de data-presentation não se aplica); é encaixe de modal.
## Fases verificáveis
1. **Tracer — overlay desktop** (quota ~40%): classes em `ActivityOverlay.tsx` (DialogContent/header/form/corpo) + `data-slot` + a linha do `w-16` em `ActivityDateTimeField.tsx`. Verificação: `pnpm test:unit` do `activityOverlay.unit.spec.tsx` verde + abrir `/campanha/atividades` no browser com edit draft longo (todas as seções) e conferir: largura 48rem, padding contido, corpo rola, rodapé visível, Início/Término na mesma linha. Gate visual da cena 1.
2. **Sync + feed desktop** (quota ~40%): split `content`/`actions` nos dois dialogs + shell header/body/footer + `sm:max-w-xl`; conferir no browser a cena 2 e o mobile inalterado (cena 3). Verificação: units dos dois dialogs verdes, incluindo as asserções estruturais novas.
3. **Testes + gates** (quota ~20%): estender os e2e das duas superfícies (asserções acima); rodar os e2e afetados (`campaignActivity`, `campaignAgendaFeed`, `campaignAgendaGoogleSync` + mobile da agenda); `pnpm gate:fast`; `pnpm push` (gate:ci completo sem e2e — o e2e é passo explícito da skill).
## Rabbit holes / Não escopo (engenharia)
- Não tocar `ui/dialog.tsx` nem os outros 7 consumidores; não criar `DialogFooter` na base.
- Não unificar os breakpoints 640 vs 768 (`useNarrowMeasured(640)` vs `useIsMobile()`).
- Não extrair shell/constante compartilhada para os 3 modais (ver Decisão C).
- Não redesenhar o formulário: o draft é ilustrativo (Tipo/Status não existem no form real); só encaixe.
- Não mexer no Popover do calendário (`ActivityDateTimeField.tsx:209`, `max-h-[22rem]`) nem no `[data-slot="popover-content"]` dos e2e.
- Não trocar mais nada em `ActivityDateTimeField` além do `w-16` (sem reestruturar a linha data+hora).
- Não guerrear ordem de CSS na mão (`!important`, reordenar classes): usar `sm:` + twMerge.
- Não tocar no mobile (Drawer/`DrawerFooter`/nested sheet) — só a recomposição que preserve o DOM.
## Riscos e mitigação
- **Regressão mobile por refatorar o `body`:** mitigação — compor `content + actionError + actionRow` na mesma ordem de hoje no ramo mobile e manter unit/e2e mobile (C103/C94/C114) verdes; revisão de diff focada no ramo `isMobile`/`isNarrow`.
- **`min-h-0` faltando em algum elo** → corpo não rola e corta: mitigação — cadeia `DialogContent (max-h + flex-col) → form (flex-1 min-h-0) → corpo (flex-1 min-h-0 overflow-y-auto)`; testar com edit draft cheio (tasks + demandas) numa janela baixa (1280×560).
- **Asserção de largura do e2e frágil:** mitigação — threshold `> 600` (não o valor exato) e comentário apontando o aceite; o par usa `|Δy| < 4`.
- **e2e de sync em viewport baixo flaky** (conteúdo pode não transbordar): mitigação — viewport 560px com o estado `synced` completo (link + instruções + edições) garante overflow; assert de `scrollHeight > clientHeight` no corpo marcado por `data-slot`.
- **`sm:p-0` remover padding de que o header dependa:** mitigação — header/body/footer ganham padding próprio (`px-6 py-4`) e o X absoluto (`right-3 top-3`) é conferido no gate.
- **Erro de ação sair da vista no desktop:** mitigação — `actionError` vai para o rodapé, ao lado da ação; no mobile fica onde está.
- **Unit estrutural acoplar a `data-slot`:** mitigação — os atributos seguem o padrão de `dialog-content`/`drawer-content` já usado por e2e; sem asserts de classe.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: 3 modais com largura/padding coerentes; corpo rolável + rodapé visível; Início/Término lado a lado; mobile intocado.
- [ ] Invariantes AGENTS/engineering-standards: sem migration, sem access/Consent, `ui/dialog.tsx` intacto, contrato mobile preservado, zero warnings, sem dead code (nenhum helper órfão).
- [ ] Testes de domínio previstos: unit existentes verdes + asserções estruturais novas nos 3 specs; int N/A (nenhuma fronteira Payload/DB muda); e2e afetados rodados antes do push.
## Self-score (decision-quality)
| Critério | Nota | Justificativa |
| ------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1. Decisões caras têm rejeitadas? | 5/5 | Onde corrigir (A/B/C), técnica de rolagem, rodapé fixo vs scroll total, larguras e fallback A/B do par — todas com rejeitadas explícitas e gatilho de revisitação. |
| 2. Cabe no appetite? | 5/5 | 3 componentes + testes, sem schema/migration; correção local de classes e composição. |
| 3. Rabbit holes nomeados? | 5/5 | Base global, unificação de breakpoint, shell novo, redesign do form, popover/E2E, sticky. |
| 4. Depth check: reusa shells/helpers? | 5/5 | Reusa o padrão existente (sheet mobile, QuickActions, Advisor); nenhum módulo novo. |
| 5. Intenção permanece satisfeita? | 5/5 | Engenharia não reescreveu o outcome: encaixe, não redesign; mobile preservado por contrato de DOM. |
**Total: 5/5 (gate ≥4).**

View file

@ -99,7 +99,10 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/activityOmnibox',
'src/lib/activityQuickActions',
],
specs: ['campaignActivity'],
// C148 — the activity components host the agenda's three modals: the
// create/edit overlay (campaignActivity), the Google sync dialog and the
// iCal feed dialog. A change there must wake all three surfaces.
specs: ['campaignActivity', 'campaignAgendaGoogleSync', 'campaignAgendaFeed'],
},
{
prefixes: [

View file

@ -151,7 +151,7 @@ export const ActivityDateTimeField = ({
['Minuto', minute, setMinute],
] as const
).map(([part, value, onChange]) => (
<div key={part} className={cn('flex flex-col gap-1', isNarrow ? 'w-16' : 'w-20')}>
<div key={part} className="flex w-16 flex-col gap-1">
{!isNarrow ? (
<span className="text-xs font-medium text-muted-foreground select-none">{part}</span>
) : null}

View file

@ -436,11 +436,7 @@ const ActivityOverlayForm = ({
const demandsSection = <ActivityDemandFields error={errorFor('demandsJson')} />
return (
<form
onSubmit={handleSubmit}
noValidate
className={sheet ? 'flex min-h-0 flex-1 flex-col' : 'flex flex-col'}
>
<form onSubmit={handleSubmit} noValidate className="flex min-h-0 flex-1 flex-col">
{isEdit ? <input type="hidden" name="id" value={editDraft?.id ?? ''} /> : null}
<input type="hidden" name="status" value="confirmado" />
{allDay ? <input type="hidden" name="allDay" value="on" /> : null}
@ -449,10 +445,11 @@ const ActivityOverlayForm = ({
<input type="hidden" name="municipality" value={municipalityValue} />
<div
data-slot="dialog-scroll-body"
className={
sheet
? 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-6'
: 'max-h-[calc(100dvh-14rem)] flex-1 overflow-y-auto overscroll-contain p-6'
: 'min-h-0 flex-1 overflow-y-auto overscroll-contain p-6'
}
>
{sheet ? (
@ -518,6 +515,7 @@ const ActivityOverlayForm = ({
</div>
<div
data-slot="dialog-footer"
className={
sheet
? 'flex shrink-0 items-center justify-between gap-3 border-t px-4 py-3'
@ -598,8 +596,8 @@ export const ActivityOverlay = ({
</Drawer>
) : (
<Dialog open onOpenChange={(next) => (next ? undefined : onClose())}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl sm:p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle>{isEdit ? 'Editar atividade' : 'Nova atividade'}</DialogTitle>
{request.kind === 'create' ? (
<DialogDescription>{formatBahiaDateTimeLabel(request.startAt)}</DialogDescription>

View file

@ -92,127 +92,125 @@ export const CalendarFeedDialog = ({
onOpenChange(false)
}
const body = (
const content = createdUrl ? (
<>
{createdUrl ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="feed-url">
Link de import
</label>
<div className="flex gap-2">
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
</div>
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}
{'+'}
{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
</div>
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Fechar
</Button>
) : null}
<Button asChild>
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="feed-url">
Link de import
</label>
<div className="flex gap-2">
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
</label>
<Input
id="feed-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Só deputado presente"
maxLength={120}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-muted-foreground">
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
Reuniões em Salvador{'"'})
</p>
</div>
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}
{'+'}
{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
</div>
</>
) : (
<>
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
</label>
<Input
id="feed-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Só deputado presente"
maxLength={120}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-muted-foreground">
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
Reuniões em Salvador{'"'})
</p>
</div>
{feeds.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Seus feeds ativos</p>
<ul className="space-y-1">
{feeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{feed.label}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void onRevokeFeed(feed.id)}
aria-label={`Revogar feed ${feed.label}`}
>
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancelar
</Button>
) : null}
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
{feeds.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Seus feeds ativos</p>
<ul className="space-y-1">
{feeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{feed.label}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void onRevokeFeed(feed.id)}
aria-label={`Revogar feed ${feed.label}`}
>
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
</div>
)}
</>
)
const actions = createdUrl ? (
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Fechar
</Button>
) : null}
<Button asChild>
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
</div>
) : (
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancelar
</Button>
) : null}
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleClose} showSwipeHandle>
@ -224,7 +222,12 @@ export const CalendarFeedDialog = ({
Teqo serão refletidas automaticamente.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
<div className="overflow-y-auto px-4 pb-2">
<div className="space-y-4">
{content}
{actions}
</div>
</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
@ -235,15 +238,23 @@ export const CalendarFeedDialog = ({
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
<DialogDescription>
Gere um link de import para sincronizar a agenda com o Google Calendar. Alterações em
Teqo serão refletidas automaticamente.
</DialogDescription>
</DialogHeader>
{body}
<div
data-slot="dialog-scroll-body"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
>
<div className="space-y-4">{content}</div>
</div>
<div data-slot="dialog-footer" className="shrink-0 border-t px-6 py-4">
{actions}
</div>
</DialogContent>
</Dialog>
)

View file

@ -183,48 +183,42 @@ export const GoogleCalendarSyncDialog = ({
</Button>
)
let body: React.ReactNode
let content: React.ReactNode
let actions: React.ReactNode = null
if (state.status === 'not-configured') {
body = (
<div className="space-y-4">
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
espelho, um administrador precisa concluir a configuração:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
content = (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
<p className="text-amber-800">
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Para ativar o
espelho, um administrador precisa concluir a configuração:
</p>
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
{NOT_CONFIGURED_RUNBOOK.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
)
} else if (state.status === 'disabled') {
body = (
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
enviado ao Google enquanto estiver desativada.
</div>
{actionError && (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
)}
<div className="flex justify-end">
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
Reativar
</Button>
</div>
content = (
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
enviado ao Google enquanto estiver desativada.
</div>
)
actions = (
<div className="flex justify-end">
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
Reativar
</Button>
</div>
)
} else {
const paused = state.status === 'paused'
body = (
<div className="space-y-4">
content = (
<>
{paused ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
@ -251,40 +245,41 @@ export const GoogleCalendarSyncDialog = ({
{linkBlock}
{instructions}
{reverseEditBlock}
{actionError && (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
)}
<div className="flex justify-end gap-2">
{paused ? (
<Button asChild variant="outline">
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
) : null}
{syncButton}
<Button
type="button"
variant="outline"
onClick={() => void handleSetDisabled(true)}
disabled={isBusy}
>
Desativar
</>
)
actions = (
<div className="flex justify-end gap-2">
{paused ? (
<Button asChild variant="outline">
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
</div>
) : null}
{syncButton}
<Button
type="button"
variant="outline"
onClick={() => void handleSetDisabled(true)}
disabled={isBusy}
>
Desativar
</Button>
</div>
)
}
const errorNotice = actionError ? (
<p role="alert" className="text-sm text-red-600">
{actionError}
</p>
) : null
const description =
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos, e edições feitas nele voltam para o Teqo. Quem segue recebe aviso conforme as próprias configurações.'
@ -301,7 +296,13 @@ export const GoogleCalendarSyncDialog = ({
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
<div className="overflow-y-auto px-4 pb-2">
<div className="space-y-4">
{content}
{errorNotice}
{actions}
</div>
</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
@ -312,12 +313,23 @@ export const GoogleCalendarSyncDialog = ({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{body}
<div
data-slot="dialog-scroll-body"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
>
<div className="space-y-4">{content}</div>
</div>
{errorNotice || actions ? (
<div data-slot="dialog-footer" className="shrink-0 space-y-3 border-t px-6 py-4">
{errorNotice}
{actions}
</div>
) : null}
</DialogContent>
</Dialog>
)

View file

@ -69,6 +69,20 @@ test.describe('Atividades — registro-fundação', () => {
// The central modal (desktop) hosts every section of the old full form.
const modal = page.getByRole('dialog', { name: 'Nova atividade' })
await expect(modal).toBeVisible()
// C148 — encaixe desktop: largura generosa (não os 28rem que a base
// `sm:max-w-md` impunha) e Início/Término lado a lado na mesma linha.
const modalBox = await modal.boundingBox()
expect(modalBox?.width ?? 0).toBeGreaterThan(600)
const pairBoxes = await modal.evaluate((dialog) => {
const start = dialog.querySelector('#overlay-startAt')?.getBoundingClientRect()
const end = dialog.querySelector('#overlay-endAt')?.getBoundingClientRect()
return start && end
? { startY: start.top, endY: end.top, startX: start.left, endX: end.left }
: null
})
if (!pairBoxes) throw new Error('overlay start/end boxes unavailable')
expect(Math.abs(pairBoxes.startY - pairBoxes.endY)).toBeLessThan(4)
expect(pairBoxes.endX).toBeGreaterThan(pairBoxes.startX)
await page.getByLabel('Título *').fill(activityTitle)
// The municipality filter prefills the field; the times come from the slot.
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)

View file

@ -170,6 +170,21 @@ test.describe('Agenda — sincronização Google (C114/C122)', () => {
await expect(dialog.getByRole('button', { name: 'Copiar link' })).toBeVisible()
await expect(dialog.getByText(/Como adicionar ao Google Calendar:/)).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Desativar' })).toBeVisible()
// C148 — na janela baixa o corpo rola dentro do modal e o rodapé de ações
// permanece visível (antes o conteúdo vazava para fora da tela sem scroll).
await page.setViewportSize({ width: 1280, height: 560 })
// O `100dvh` reflui no frame seguinte ao resize; poll até o box caber.
await expect
.poll(async () => {
const box = await dialog.boundingBox()
return box ? Math.round(box.y + box.height) : Number.POSITIVE_INFINITY
})
.toBeLessThanOrEqual(560)
const body = dialog.locator('[data-slot="dialog-scroll-body"]')
await expect(body).toBeVisible()
expect(await body.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeGreaterThan(0)
await expect(dialog.getByRole('button', { name: 'Sincronizar agora' })).toBeVisible()
})
test.describe('mobile (FAB)', () => {

View file

@ -317,6 +317,20 @@ describe('ActivityOverlay — criação (modal central)', () => {
expect(onClose).toHaveBeenCalledTimes(1)
expect(mocks.createOverlay).not.toHaveBeenCalled()
})
it('no modal desktop, mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => {
renderOverlay({ agendaState: { municipality: 12 } })
const dialog = screen.getByRole('dialog', { name: 'Nova atividade' })
const body = dialog.querySelector('[data-slot="dialog-scroll-body"]')
const footer = dialog.querySelector('[data-slot="dialog-footer"]')
expect(body).toBeTruthy()
expect(footer).toBeTruthy()
expect(body!.contains(screen.getByLabelText('Título *'))).toBe(true)
expect(body!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(false)
expect(footer!.contains(screen.getByRole('button', { name: 'Salvar' }))).toBe(true)
expect(footer!.contains(screen.getByRole('button', { name: 'Cancelar' }))).toBe(true)
})
})
describe('ActivityOverlay — edição (modal central)', () => {

View file

@ -6,6 +6,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
type DialogProps = Parameters<typeof CalendarFeedDialog>[0]
const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret'
const renderDialog = (overrides: Partial<DialogProps> = {}) => {
const onCreateFeed = vi.fn()
const onRevokeFeed = vi.fn()
@ -72,7 +74,6 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => {
})
it('creates the filterless feed through onCreateFeed with the chosen label', async () => {
const FEED_URL = 'https://teqo.dev/campanha/agenda/ical/secret'
const { onCreateFeed } = renderDialog()
onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL })
@ -85,4 +86,35 @@ describe('CalendarFeedDialog (C93 — no filters needed)', () => {
await waitFor(() => expect(onCreateFeed).toHaveBeenCalledWith('Agenda completa'))
await within(dialog).findByDisplayValue(FEED_URL)
})
it('mantém o conteúdo no corpo rolável e as ações no rodapé (C148)', () => {
renderDialog()
const dialog = screen.getByRole('dialog')
const body = dialog.querySelector('[data-slot="dialog-scroll-body"]')
const footer = dialog.querySelector('[data-slot="dialog-footer"]')
expect(body).toBeTruthy()
expect(footer).toBeTruthy()
expect(body!.contains(screen.getByLabelText('Nome do feed'))).toBe(true)
expect(body!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(false)
expect(footer!.contains(screen.getByRole('button', { name: 'Gerar link' }))).toBe(true)
})
it('no estado criado, o link fica no corpo e as ações no rodapé (C148)', async () => {
const { onCreateFeed } = renderDialog()
onCreateFeed.mockResolvedValue({ ok: true, feedUrl: FEED_URL })
const dialog = screen.getByRole('dialog')
fireEvent.change(within(dialog).getByLabelText('Nome do feed'), {
target: { value: 'Agenda completa' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'Gerar link' }))
const link = await within(dialog).findByDisplayValue(FEED_URL)
const body = dialog.querySelector('[data-slot="dialog-scroll-body"]')
const footer = dialog.querySelector('[data-slot="dialog-footer"]')
expect(body!.contains(link)).toBe(true)
expect(body!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(false)
expect(footer!.contains(screen.getByRole('link', { name: /Abrir Google Calendar/ }))).toBe(true)
})
})

View file

@ -21,6 +21,19 @@ const syncedState = (
...overrides,
})
const notConfiguredState = (): GoogleCalendarSyncActionResult => ({
ok: true,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: null,
})
const renderDialog = (state: GoogleCalendarSyncActionResult) =>
render(
<GoogleCalendarSyncDialog
@ -86,23 +99,36 @@ describe('GoogleCalendarSyncDialog — seção "Edições pelo Google" (C115)',
})
it('não mostra a seção no estado não configurado', () => {
renderDialog({
ok: true,
status: 'not-configured',
calendarId: null,
lastSyncedAt: null,
lastSuccessAt: null,
lastErrorAt: null,
lastError: null,
pushChannelExpiresAt: null,
pushChannelError: null,
addLink: null,
})
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(within(dialog).queryByText(/Edições pelo Google/)).toBeNull()
})
})
describe('GoogleCalendarSyncDialog — encaixe desktop (C148)', () => {
it('mantém o conteúdo no corpo rolável e as ações no rodapé', () => {
renderDialog(syncedState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
const body = dialog.querySelector('[data-slot="dialog-scroll-body"]')
const footer = dialog.querySelector('[data-slot="dialog-footer"]')
expect(body).toBeTruthy()
expect(footer).toBeTruthy()
expect(body!.contains(screen.getByText(/Edições pelo Google/))).toBe(true)
expect(body!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(false)
expect(footer!.contains(screen.getByRole('button', { name: /Sincronizar agora/ }))).toBe(true)
expect(footer!.contains(screen.getByRole('button', { name: 'Desativar' }))).toBe(true)
})
it('não renderiza rodapé quando não há ação (não configurado)', () => {
renderDialog(notConfiguredState())
const dialog = screen.getByRole('dialog', { name: /Agenda da Campanha no Google/ })
expect(dialog.querySelector('[data-slot="dialog-footer"]')).toBeNull()
expect(dialog.querySelector('[data-slot="dialog-scroll-body"]')).toBeTruthy()
})
})
describe('ActivityUpdateFeed — atribuição do registro reverso (C115)', () => {
const renderFeed = (updates: Parameters<typeof ActivityUpdateFeed>[0]['updates']) =>
render(<ActivityUpdateFeed updates={updates} />)