14 KiB
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/agendaabre 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);leadernã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.tsxreutilizada 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 deactivityCreateSchema/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. dateClickhoje é a única ocorrência deuseRouteremActivityAgenda— com a criação inline, o router sai do componente (simplificação real, não cerimônia).
- A intenção cita "
Abordagem recomendada
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): paramtitlee uso demunicipalityIdno href.ActivityForm.tsx:defaultValuedo título incluiinitialValues?.title(uma linha).createActivityInline(src/app/(campaign)/campanha/actions/activity.ts): wrapper{ok}chamandocreateActivityRecordcominput: ActivityCreateInput(title/municipality/startAt/endAt/locality/responsible), mapeando erro →message+fieldErrors(duplicate-title →title). RetornaActivityInlineCreateResult.ActivityInlineCreateForm+ActivityInlineCreate(client,src/components/campaign/activity/ActivityInlineCreate.tsx): o primeiro é o conteúdo (título, Início/Términodatetime-local, MunicípioStrictCombobox, Local,ResponsibleMultiSelect, erros inline viaFieldError/Alert, rodapé "Mais detalhes" + "Salvar" com spinner); o segundo é o contêiner que escolhePopover(comPopoverAnchorfixo no ponto do clique) ouDrawer, controlaopen, chamacreateActivityInlinee notificaonCreatedno sucesso. ReusaStrictCombobox(mapeandomunicipalityOptions→{ value: String(id), label: name }),searchActivityResponsibleOptionsAction,formatIsoAsBahiaDateTimeInput/parseBahiaDateTimeInput.ActivityAgenda.tsx:handleDateClick→activitySlotPrefill+ abre o overlay (estadodraft { startAt, endAt }+anchor { x, y }+isNarrow); removeuseRouter; sucesso do overlay →setReloadCount(n+1)(refetch da janela, contrato dos filtros); feliz: nenhumredirect. Nova propmunicipalityOptions: RelationOption[](já carregada na page).agenda/page.tsx: passamunicipalityOptionsparaActivityAgenda.- 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). SemConsent(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
- Schema/server (maior parte do appetite): helper
activitySlotPrefill+ prefill title/municipalityId +createActivityInline+ unit (helper, URLs, prefill, action int). - UI (Impeccable C — fluxo novo sobre tela existente):
ActivityInlineCreateForm+ contêiner Popover/Drawer → shape → craft → critique → polish. Fine-tuning doisNarrow/breakpoint. - Gates:
pnpm gate:fastna iteração; e2e do fluxo inline (desktop) e atualização do teste antigo de slot→redirect;pnpm pushno 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);buildActivityCreateHrefcomtitle/municipalityId;parseActivityCreatePrefill.title(trim, cap 160, drop inválido). - Unit interação (
tests/unit/activityAgendaInteractions.unit.spec.tsx): mock do FullCalendar ganhadateClick; asserta que clicar abre o overlay (não usa maisrouterPush); salvar chamacreateActivityInlinee, 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 comresponsiblepolimó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 comdatetime-localpré-preenchido (30min), preencher título + município, Salvar → evento aparece sem navegar; novo teste "Mais detalhes" →/campanha/atividades/novacom 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 detalhescobre. - 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:
CommandDialogdoResponsibleMultiSelectabre 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
PopoverAnchorusanado pontoclientX/clientYfixo: 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.
StrictComboboxde município com 435 opções no popover: filtro por inicio de palavra já cobre; ostate.municipalityativo 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,
leaderintocado - Invariantes AGENTS/engineering-standards: escrita multi-collection via
createActivityRecordjá transacional comreq;overrideAccess: falsecomuserherda o caminho abaixado; identificadores em inglês / strings pt-BR; sem migration; semConsentnovo - Testes de domínio: unit (slotPrefill/URLs/prefill), unit interação (dateClick→overlay→create→refetch), int (
createActivityInlineroles/escopo/duplicate), e2e (percentual do fluxo desktop + teste antigo atualizado)