mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 18:53:06 +00:00
115 lines
14 KiB
Markdown
115 lines
14 KiB
Markdown
# Impl: Criar evento inline no calendário da agenda (estilo Google Calendar)
|
||
|
||
Status: aprovado
|
||
Atualizado em: 2026-08-08
|
||
Issue: #428
|
||
Intenção: docs/plans/agenda-criar-evento-inline.md
|
||
Appetite restante: herdado (~1–1,5 dia eng)
|
||
Model: composer-2.5 (sessão: DeepSeek V4 Flash — família Flash, pareia com `model-local: deepseek-v4-flash-high`; segue sem pausa)
|
||
|
||
## Leitura da intenção
|
||
|
||
- **Outcome:** clicar num slot vazio de `/campanha/agenda` abre criação **inline** (popover no desktop, bottom sheet no mobile) com dia/horário do slot pré-preenchidos e editáveis; campos mínimos título + município (+ local e responsáveis opcionais); salvar **não navega** e o evento **aparece já** na agenda; "Mais detalhes" vai ao formulário completo pré-preenchido.
|
||
- **O que NÃO negociar:** não vira formulário completo no overlay; salvar não navega; seletor de responsáveis **não cadastra** entidade nova (só combina entidades existentes de candidato/assessor/coordenador - `campaignUser`, `leadership`, `stateDeputy`); `leader` não acessa agenda; reusa componentes shadcn (popover, drawer, inputs) — sem primitiva paralela.
|
||
- **O que reavaliar (hipóteses da intenção):**
|
||
- A intenção cita "`ActivityForm.tsx` reutilizada só no 'Mais detalhes'". Revisão: `ActivityForm` é o formulário completo multi-card — **não** é base do overlay. O overlay é um componente novo mínimo; a referência de campos/validação vem de `activityCreateSchema`/`activityFormData.ts`, não do JSX do form.
|
||
- A intenção supõe "state local do FullCalendar" como o único mecanismo. O contrato já decidido é **refetch da janela** no fechamento (`setReloadCount`) — que é exatamente o mesmo mecanismo que o reschedule usa. Não há "inserção otimista" a inventar.
|
||
- `dateClick` hoje é a **única** ocorrência de `useRouter` em `ActivityAgenda` — com a criação inline, o router sai do componente (simplificação real, não cerimônia).
|
||
|
||
## Abordagem recomendada
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
click["dateClick(info)"] -->|"slotPrefill(info)"| prefill["{startAt,endAt} (30min / 09:00–10:00 no mês)"]
|
||
click -->|"narrowRef?"| container{"isNarrow"}
|
||
container -->|"não (desktop)"| popover["Popover + PopoverAnchor no ponto do clique"]
|
||
container -->|"sim (mobile)"| drawer["Drawer bottom sheet"]
|
||
popover --> form["ActivityInlineCreateForm (mesmo conteúdo)"]
|
||
drawer --> form
|
||
form -->|"Salvar"| action["createActivityInline (server action)"]
|
||
action -->|"{ok:true}"| close["fecha overlay + setReloadCount(n+1)"]
|
||
action -->|"{ok:false}"| inline["erros inline + toast · overlay aberto"]
|
||
form -->|"Mais detalhes"| href["buildActivityCreateHref(+ title/municipalityId) → /nova"]
|
||
```
|
||
|
||
**Opções consideradas:** A (recommendada, abaixo) | B | C …
|
||
**Recomendação:** A — overlay mínimo novo reusando todo o maquinário existente: `createActivityRecord` (transação + access), `popover`/`drawer`, `ResponsibleMultiSelect` + `searchActivityResponsibleOptionsAction`, `StrictCombobox` sobre `municipalityOptions`, intervalo +30min (recomendação 1 da intenção), refetch da janela no fechamento (decisão da intenção). Cobre o aceite sem abrir segunda página de criação nem nova rota.
|
||
**Rejeitadas:** ver Decisões D1–D5.
|
||
|
||
### Decisões de engenharia
|
||
|
||
**D1 — Superfície server da criação inline.**
|
||
Opções: A) nova server action `createActivityInline` em `src/app/(campaign)/campanha/actions/activity.ts`, retornando `{ ok: true } | { ok: false; message; fieldErrors? }` (mesmo contrato de resultado do `rescheduleActivity`) | B) cliente chama `createActivity` (existente) e mapeia erro no try/catch | C) rota JSON nova.
|
||
Recomendação: **A** — `rescheduleActivity` já estabelece o padrão `{ok}` de ação chamada pelo calendário; `createActivity` lança erro (serve `createActivityFormAction`, que faz redirect) e **não** tem mapeamento de erro amigável (ZodError/unique). O wrapper A chama `createActivityRecord` (transação + access por `overrideAccess: false` + `user`, byte a byte o caminho do form completo), detecta o duplicate-title (constante `'Já existe uma atividade com este título.'`, mesma string do formAction) e mapeia `fieldErrors` de `title`/`municipality` para dentro do overlay.
|
||
Rejeitadas: B — duplica o mapeamento de erro no cliente e vaza `ZodError` cru; C — rota JSON onde a ação de server já existe (over-d escopo).
|
||
|
||
**D2 — Onde vive o overlay.**
|
||
Opções: A) tudo inline em `ActivityAgenda.tsx` | B) componente novo `ActivityInlineCreate.tsx` em `src/components/campaign/activity/` | C) reusar `ActivityForm` com variante minimalista.
|
||
Recomendação: **B** — `ActivityAgenda` já tem ~340 linhas e é dona do FullCalendar (resize/refetch/estado). O overlay é uma unidade coesa (prefill, estado do form mínimo, ação, erros) que merece módulo próprio; o conteúdo único `ActivityInlineCreateForm` é renderizado dentro do **Popover** (desktop) ou do **Drawer** (mobile) conforme `isNarrow`.
|
||
Rejeitadas: A — incha o componente de calendário com form state; C — `ActivityForm` é o formulário completo (tarefas/orgs/demandas); antiviral da intenção é o overlay **não** virar o form completo.
|
||
|
||
**D3 — Contêiner do overlay (desktop vs mobile).**
|
||
Opções: A) desktop `Popover`+`PopoverAnchor` no ponto do clique (`info.jsEvent.clientX/Y`) / mobile `Drawer` bottom; conteúdo compartilhado | B) `Dialog` único para os dois | C) `Sheet` lateral.
|
||
Recomendação: **A** — é o único que cumpre "popover abre naquele ponto" (aceite explícito) e "bottom sheet" (aceite explícito), reusando as primitivas existentes (`Popover` com `PopoverAnchor`, `Drawer`). A seleção usa o mesmo breakpoint do ResizeObserver atual (`MOBILE_BREAKPOINT_PX = 640`), espelhado em estado `isNarrow`.
|
||
Rejeitadas: B — modal central não é ancorado ao ponto e quebra o aspecto de Google Calendar; C — `Sheet` lateral não é bottom sheet no mobile nem popover no desktop.
|
||
|
||
**D4 — Intervalo pré-preenchido.**
|
||
Opções: A) `info.dateStr` (já cherry-pickado pelo snap do FullCalendar, slot 30min) → end = start + 30min; allDay (mês) → 09:00–10:00 como hoje | B) +1h sempre | C) meia-hora sempre, mês também.
|
||
Recomendação: **A** — é a recomendação 1 da intenção ("mesma letra do Google Calendar": o clique no slot tipado vira o próprio slot). Helper puro `activitySlotPrefill` em `activityUi.ts` (testável, usa `parseBahiaDateTimeInput` já importado lá). O teste e2e existente que afirma 1h no slot (comportamento antigo) é **atualizado** para 30min.
|
||
Rejeitadas: B/C — divergem do que o plano de intenção travou.
|
||
|
||
**D5 — "Mais detalhes" preserva o rascunho.**
|
||
Opções: A) preencher o form completo via URL — `startAt`/`endAt`/`municipality` (já suportados) + **`title`** (parâmetro novo) | B) guardar rascunho em memória/estado global.
|
||
Recomendação: **A** — `ActivityCreatePrefill` ganha `title?: string`; `buildActivityCreateHref` passa `title` e passa a usar o `municipalityId` (o tipo já o declarava, o builder o omitia); `parseActivityCreatePrefill` valida `title` (trim ≤ 160); `ActivityFormFields` lê `initialValues?.title` no `defaultValue` do campo título (ordem: `submittedTitle ?? initialValues?.title ?? activity?.title`).
|
||
Rejeitadas: B — estado efêmero não sobrevive à navegação; a URL já é o contrato de prefill do form completo.
|
||
|
||
### Componentes / mudanças
|
||
|
||
- **`activitySlotPrefill`** (`src/utilities/activityUi.ts`): `({ allDay, dateStr }) => { startAt, endAt }` — allDay → `parseBahiaDateTimeInput(`${date[0..9]}T09:00`)` + 1h (hoje); timeGrid → `dateStr` + 30min. Puro, exportado.
|
||
- **`ActivityCreatePrefill.title` + `buildActivityCreateHref` + `parseActivityCreatePrefill`** (`src/utilities/activityUi.ts`): param `title` e uso de `municipalityId` no href.
|
||
- **`ActivityForm.tsx`**: `defaultValue` do título inclui `initialValues?.title` (uma linha).
|
||
- **`createActivityInline`** (`src/app/(campaign)/campanha/actions/activity.ts`): wrapper `{ok}` chamando `createActivityRecord` com `input: ActivityCreateInput` (title/municipality/startAt/endAt/locality/responsible), mapeando erro → `message` + `fieldErrors` (duplicate-title → `title`). Retorna `ActivityInlineCreateResult`.
|
||
- **`ActivityInlineCreateForm` + `ActivityInlineCreate`** (client, `src/components/campaign/activity/ActivityInlineCreate.tsx`): o primeiro é o _conteúdo_ (título, Início/Término `datetime-local`, Município `StrictCombobox`, Local, `ResponsibleMultiSelect`, erros inline via `FieldError`/`Alert`, rodapé "Mais detalhes" + "Salvar" com spinner); o segundo é o _contêiner_ que escolhe `Popover` (com `PopoverAnchor` fixo no ponto do clique) ou `Drawer`, controla `open`, chama `createActivityInline` e notifica `onCreated` no sucesso. Reusa `StrictCombobox` (mapeando `municipalityOptions` → `{ value: String(id), label: name }`), `searchActivityResponsibleOptionsAction`, `formatIsoAsBahiaDateTimeInput`/`parseBahiaDateTimeInput`.
|
||
- **`ActivityAgenda.tsx`**: `handleDateClick` → `activitySlotPrefill` + abre o overlay (estado `draft { startAt, endAt }` + `anchor { x, y }` + `isNarrow`); remove `useRouter`; sucesso do overlay → `setReloadCount(n+1)` (refetch da janela, contrato dos filtros); feliz: nenhum `redirect`. Nova prop `municipalityOptions: RelationOption[]` (já carregada na page).
|
||
- **`agenda/page.tsx`**: passa `municipalityOptions` para `ActivityAgenda`.
|
||
- **Migration:** nenhuma (nenhuma mudança de schema).
|
||
- **Access / Consent:** nenhum novo — a criação inline herda o access do `createActivityRecord` (`overrideAccess: false` + `user`; advisor restrito ao portfolio pelo mesmo caminho do form completo). Sem `Consent` (dado interno de staff).
|
||
|
||
### Dados → forma
|
||
|
||
N/A — affordance de **escrita** sobre a agenda; a intenção já declara `Dados: N/A`. Nenhuma métrica/série/ranking novo.
|
||
|
||
## Fases verificáveis
|
||
|
||
1. **Schema/server** (maior parte do appetite): helper `activitySlotPrefill` + prefill title/municipalityId + `createActivityInline` + unit (helper, URLs, prefill, action int).
|
||
2. **UI** (Impeccable C — fluxo novo sobre tela existente): `ActivityInlineCreateForm` + contêiner Popover/Drawer → shape → craft → critique → polish. Fine-tuning do `isNarrow`/breakpoint.
|
||
3. **Gates**: `pnpm gate:fast` na iteração; e2e do fluxo inline (desktop) e atualização do teste antigo de slot→redirect; `pnpm push` no fechamento (com este impl.md no commit).
|
||
|
||
### Testes
|
||
|
||
- **Unit** (`tests/unit/activityUi.unit.spec.ts`): `activitySlotPrefill` (timeGrid +30min; allDay 09:00–10:00; datas inválidas → fallback seguro); `buildActivityCreateHref` com `title`/`municipalityId`; `parseActivityCreatePrefill.title` (trim, cap 160, drop inválido).
|
||
- **Unit interação** (`tests/unit/activityAgendaInteractions.unit.spec.tsx`): mock do FullCalendar ganha `dateClick`; asserta que clicar abre o overlay (não usa mais `routerPush`); salvar chama `createActivityInline` e, no sucesso, dispara refetch da janela; na falha mantém o overlay aberto + toast.
|
||
- **Int** (`tests/int/campaignActivity.int.spec.ts`): `createActivityInline` — coordinator/candidate criam com `responsible` polimórfico (multi) e sem; advisor cria dentro do portfolio e é negado fora do escopo (mesmo caminho de access do form completo); título duplicado → `{ ok:false, fieldErrors.title }` com a string do formAction; payload mínimo inválido → fieldErrors.
|
||
- **E2E** (`tests/e2e/campaignActivity.e2e.spec.ts`): **atualizar** o teste "leva o slot semanal para o formulário existente" → "abre a criação inline no slot clicado": clicar no slot abre o popover com `datetime-local` pré-preenchido (30min), preencher título + município, Salvar → evento aparece sem navegar; novo teste "Mais detalhes" → `/campanha/atividades/nova` com título/município/horários pré-preenchidos. (Drawer mobile: coberto por unit de componente se viável; e2e mobile fica fora do appetite curto — decisão de escopo.)
|
||
|
||
## Rabbit holes / Não escopo (engenharia)
|
||
|
||
- Overlay vira formulário completo (tarefas/organizações/demandas) → anti-goal, `Mais detalhes` cobre.
|
||
- Seletor de responsável cadastrar entidade nova (padrão B154) → anti-goal explícito.
|
||
- Inserção otimista local complexa no FullCalendar → contrato decidido é refetch da janela.
|
||
- Double-nested modal: `CommandDialog` do `ResponsibleMultiSelect` abre por cima do Drawer/Popover (portais separados). Risco de usabilidade em mobile (nested modal). Sem nova primitiva no v1; se janky no teste manual, swap do contêiner do seletor vira follow-up — não estica o popover.
|
||
- Posicionamento do `PopoverAnchor` usanado ponto `clientX/clientY` fixo: risco de borda (clique perto da margem). Mitigação: `align="start"` + `sideOffset`; radix reposiciona via content parts; aceitável.
|
||
- Mês: criação no slot do dia cheio → 09:00–10:00 (comportamento atual, inalterado).
|
||
|
||
## Riscos e mitigação
|
||
|
||
- **Quebra do teste e2e antigo (slot → redirect):** esperado — o comportamento mudou por aceite; substituir pelo fluxo inline, não remover a cobertura.
|
||
- **Nested CommandDialog sobre o Drawer (mobile):** reuso do componente existente por DRY; se o teste manual mostrar janky, degradar para abrir o seletor sem dialog (Command inline) num follow-up curto.
|
||
- **Refetch pós-criação parecer lento:** mesmo contrato dos filtros (mais barato que o estado local duplicado); se no app parecer lento, a intenção autoriza voltar para inserção local — medir, não presumir.
|
||
- **`StrictCombobox` de município com 435 opções no popover:** filtro por inicio de palavra já cobre; o `state.municipality` ativo vem pré-selecionado (opção inicial da intenção).
|
||
|
||
## Aceite de engenharia
|
||
|
||
- [ ] Aceite de produto da intenção coberto: clique no slot abre inline (popover desktop / sheet mobile), horários do slot pré-preenchidos e editáveis, título+município mínimos, local e responsáveis opcionais, seletor polimórfico multi (C90) sem cadastro novo, salvar não navega e evento aparece (refetch), falha fica no overlay + toast, "Mais detalhes" pré-preenche o form completo, `leader` intocado
|
||
- [ ] Invariantes AGENTS/engineering-standards: escrita multi-collection via `createActivityRecord` já transacional com `req`; `overrideAccess: false` com `user` herda o caminho abaixado; identificadores em inglês / strings pt-BR; sem migration; sem `Consent` novo
|
||
- [ ] Testes de domínio: unit (slotPrefill/URLs/prefill), unit interação (dateClick→overlay→create→refetch), int (`createActivityInline` roles/escopo/duplicate), e2e (percentual do fluxo desktop + teste antigo atualizado)
|