diff --git a/docs/plans/agenda-criar-evento-inline-impl.md b/docs/plans/agenda-criar-evento-inline-impl.md new file mode 100644 index 00000000..cd4fc235 --- /dev/null +++ b/docs/plans/agenda-criar-evento-inline-impl.md @@ -0,0 +1,115 @@ +# 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) diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index aabe3130..95521a18 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -100,7 +100,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) { }} /> - + diff --git a/src/app/(campaign)/campanha/actions/activity.ts b/src/app/(campaign)/campanha/actions/activity.ts index dc850dd7..299133e0 100644 --- a/src/app/(campaign)/campanha/actions/activity.ts +++ b/src/app/(campaign)/campanha/actions/activity.ts @@ -24,6 +24,7 @@ import { } from '@/lib/schemas/activity' import type { Activity, CampaignUser } from '@/payload-types' import type { ParsedTourDraftFormData } from '@/utilities/activityFormData' +import { mapActivityInlineCreateError } from '@/utilities/activityInlineErrors' import { loadActivityAgendaEventsData } from '@/utilities/activityPageData' import { canCampaignUserRescheduleActivity, isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campaignActionContext' @@ -464,6 +465,28 @@ export const rescheduleActivity = async ( } } +export type ActivityInlineCreateResult = + | { ok: true } + | { ok: false; message: string; fieldErrors?: Record } + +/** + * C91 — server surface for the agenda's inline quick create. Goes through the + * same `createActivityRecord` (one transaction, `overrideAccess: false` with + * the actor, the advisor's portfolio scope inherited from the full form) but + * returns a result instead of throwing, so the overlay can stay open on a + * failure — same `{ ok }` contract as `rescheduleActivity`. + */ +export const createActivityInline = async ( + input: ActivityCreateInput, +): Promise => { + try { + await createActivity(input) + return { ok: true } + } catch (error) { + return { ok: false, ...mapActivityInlineCreateError(error) } + } +} + export const updateActivity = async ( input: ActivityUpdateInput, demandDrafts: ActivityDemandDraft[] = [], diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index f5794b0e..43b1f5db 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -19,7 +19,6 @@ import '@fullcalendar/react/themes/classic/theme.css' import timeGridPlugin from '@fullcalendar/react/timegrid' import { UserRoundCheckIcon } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' @@ -27,14 +26,22 @@ import { loadActivityAgendaEvents, rescheduleActivity, } from '@/app/(campaign)/campanha/actions/activity' +import { + ActivityInlineCreate, + type ActivityInlineCreateDraft, +} from '@/components/campaign/activity/ActivityInlineCreate' +import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' import { Button } from '@/components/ui/button' -import { parseBahiaDateTimeInput } from '@/lib/campaignTime' import { ACTIVITY_RESCHEDULE_FAILED_MESSAGE, activityStatusLabels, type ActivityStatus, } from '@/lib/schemas/activity' -import { buildActivityCreateHref, type ActivityAgendaState } from '@/utilities/activityUi' +import { + activitySlotPrefill, + buildActivityCreateHref, + type ActivityAgendaState, +} from '@/utilities/activityUi' import type { ActivityAgendaEvent } from '@/utilities/activityViewModels' import './ActivityAgenda.css' @@ -98,8 +105,13 @@ const renderEventContent = ({ event, timeText }: EventDisplayInfo) => { ) } -export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { - const router = useRouter() +export const ActivityAgenda = ({ + state, + municipalityOptions = [], +}: { + state: ActivityAgendaState + municipalityOptions?: RelationOption[] +}) => { const containerRef = useRef(null) const calendarRef = useRef(null) const narrowRef = useRef(null) @@ -111,6 +123,8 @@ export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { const [isLoading, setIsLoading] = useState(true) const [savingCount, setSavingCount] = useState(0) const [loadError, setLoadError] = useState(null) + const [isNarrow, setIsNarrow] = useState(false) + const [createDraft, setCreateDraft] = useState(null) useEffect(() => { mountedRef.current = true @@ -129,6 +143,7 @@ export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { const isNarrow = container.getBoundingClientRect().width < MOBILE_BREAKPOINT_PX const previous = narrowRef.current narrowRef.current = isNarrow + setIsNarrow((current) => (current === isNarrow ? current : isNarrow)) if (previous !== null && isNarrow === previous) return const viewType = api.view.type if (isNarrow && viewType === 'timeGridWeek') { @@ -243,27 +258,21 @@ export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { [persistSchedule], ) - const handleDateClick = useCallback( - (info: DateClickInfo) => { - if (info.allDay) { - const startAt = parseBahiaDateTimeInput(`${info.dateStr.slice(0, 10)}T09:00`) - const endAt = startAt - ? new Date(new Date(startAt).getTime() + 3_600_000).toISOString() - : undefined - router.push(buildActivityCreateHref(state, { startAt: startAt ?? undefined, endAt })) - return - } - const start = new Date(info.dateStr) - const end = new Date(start.getTime() + 3_600_000) - router.push( - buildActivityCreateHref(state, { - startAt: start.toISOString(), - endAt: end.toISOString(), - }), - ) - }, - [router, state], - ) + const handleDateClick = useCallback((info: DateClickInfo) => { + const prefill = activitySlotPrefill(info) + if (!prefill) return + + const jsEvent = info.jsEvent as MouseEvent | undefined + const hasPoint = typeof jsEvent?.clientX === 'number' && typeof jsEvent?.clientY === 'number' + const anchor = hasPoint + ? { x: (jsEvent as MouseEvent).clientX, y: (jsEvent as MouseEvent).clientY } + : (() => { + const rect = containerRef.current?.getBoundingClientRect() + return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null + })() + + setCreateDraft({ ...prefill, anchor }) + }, []) const emptyHref = buildActivityCreateHref(state) @@ -335,6 +344,18 @@ export const ActivityAgenda = ({ state }: { state: ActivityAgendaState }) => { headingLevel={2} /> + + setCreateDraft(null)} + onCreated={() => { + setCreateDraft(null) + setReloadCount((count) => count + 1) + }} + /> ) } diff --git a/src/components/campaign/activity/ActivityForm.tsx b/src/components/campaign/activity/ActivityForm.tsx index cccfbfc7..23778147 100644 --- a/src/components/campaign/activity/ActivityForm.tsx +++ b/src/components/campaign/activity/ActivityForm.tsx @@ -168,7 +168,7 @@ const ActivityFormFields = ({ void + onCreated: () => void +} + +const inlineMunicipalityOptions = (options: RelationOption[]): StrictComboboxOption[] => + options.map((option) => ({ value: String(option.id), label: option.name })) + +const parseInlineResponsibles = ( + raw: FormDataEntryValue | null, +): ActivityCreateInput['responsible'] => { + if (typeof raw !== 'string') return undefined + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return undefined + } + if (!Array.isArray(parsed) || parsed.length === 0) return undefined + + const entries: Array<{ + relationTo: 'campaignUser' | 'leadership' | 'stateDeputy' + value: number + }> = [] + for (const item of parsed) { + const result = activityResponsibleSchema.safeParse(item) + if (result.success) entries.push(result.data) + } + return entries.length > 0 ? entries : undefined +} + +const ActivityInlineCreateForm = ({ + startAt, + endAt, + agendaState, + municipalityOptions, + onCreated, +}: { + startAt: string + endAt: string + agendaState: ActivityAgendaState + municipalityOptions: RelationOption[] + onCreated: () => void +}) => { + const [title, setTitle] = useState('') + const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt)) + const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt)) + const [locality, setLocality] = useState('') + const preselectedMunicipalityId = + agendaState.municipality && + municipalityOptions.some((option) => option.id === agendaState.municipality) + ? String(agendaState.municipality) + : '' + const [municipalityValue, setMunicipalityValue] = useState(preselectedMunicipalityId) + const [fieldErrors, setFieldErrors] = useState>({}) + const [failedMessage, setFailedMessage] = useState(null) + const [saving, setSaving] = useState(false) + + const errorFor = (name: string) => fieldErrors[name]?.[0] + + const moreDetailsHref = buildActivityCreateHref(agendaState, { + startAt, + endAt, + municipalityId: municipalityValue ? Number(municipalityValue) : undefined, + title: title.trim() || undefined, + }) + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + const formData = new FormData(event.currentTarget) + const trimmedTitle = title.trim() + const municipalityId = Number(municipalityValue) + const startIso = parseBahiaDateTimeInput(start) + const endIso = end ? parseBahiaDateTimeInput(end) : undefined + + const nextFieldErrors: Record = {} + if (!trimmedTitle) { + nextFieldErrors.title = ['Informe o título do compromisso.'] + } else if (trimmedTitle.length < 2) { + nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.'] + } + if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.'] + if (!startIso) nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.'] + if (startIso && endIso && new Date(endIso) <= new Date(startIso)) { + nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.'] + } + if (Object.keys(nextFieldErrors).length > 0) { + setFieldErrors(nextFieldErrors) + return + } + + setFieldErrors({}) + setFailedMessage(null) + setSaving(true) + try { + const responsible = parseInlineResponsibles(formData.get('responsiblesJson')) + const result = await createActivityInline({ + title: trimmedTitle, + municipality: municipalityId, + startAt: startIso as string, + ...(endIso ? { endAt: endIso } : {}), + ...(locality.trim() ? { locality: locality.trim() } : {}), + ...(responsible ? { responsible } : {}), + }) + if (result.ok) { + onCreated() + return + } + setSaving(false) + setFieldErrors(result.fieldErrors ?? {}) + setFailedMessage(result.message) + toast.error(result.message) + } catch { + setSaving(false) + const message = 'Não foi possível criar o compromisso. Tente novamente.' + setFailedMessage(message) + toast.error(message) + } + } + + return ( +
+ + Título * + setTitle(event.target.value)} + maxLength={160} + minLength={2} + placeholder="Ex.: Café com apoiadores" + className="min-h-11" + autoComplete="off" + aria-invalid={Boolean(errorFor('title'))} + aria-describedby={errorFor('title') ? 'inline-title-error' : undefined} + /> + {errorFor('title') ? ( + {errorFor('title')} + ) : null} + + +
+ + Início * + setStart(event.target.value)} + className="min-h-11" + aria-invalid={Boolean(errorFor('startAt'))} + aria-describedby={errorFor('startAt') ? 'inline-startAt-error' : undefined} + /> + {errorFor('startAt') ? ( + {errorFor('startAt')} + ) : null} + + + Término + setEnd(event.target.value)} + className="min-h-11" + aria-invalid={Boolean(errorFor('endAt'))} + aria-describedby={errorFor('endAt') ? 'inline-endAt-error' : undefined} + /> + {errorFor('endAt') ? ( + {errorFor('endAt')} + ) : null} + +
+ + + Município * + + {errorFor('municipality') ? ( + {errorFor('municipality')} + ) : null} + + + + Local (opcional) + setLocality(event.target.value)} + maxLength={160} + placeholder="Bairro, endereço ou referência" + className="min-h-11" + /> + + + + + {failedMessage ? ( + + {failedMessage} + + ) : null} + +
+ + +
+ + ) +} + +export const ActivityInlineCreate = ({ + draft, + isNarrow, + agendaState, + municipalityOptions, + onClose, + onCreated, +}: ActivityInlineCreateProps) => { + if (!draft) return null + + const form = ( + + ) + + if (isNarrow) { + return ( + (next ? undefined : onClose())} showSwipeHandle> + + + Nova atividade + {formatBahiaDateTimeLabel(draft.startAt)} + +
{form}
+
+
+ ) + } + + return ( + (next ? undefined : onClose())}> + + + {form} + + + ) +} diff --git a/src/utilities/activityInlineErrors.ts b/src/utilities/activityInlineErrors.ts new file mode 100644 index 00000000..9720299d --- /dev/null +++ b/src/utilities/activityInlineErrors.ts @@ -0,0 +1,24 @@ +/** + * C91 — safe-message mapping for the agenda's inline quick create. Pure, so + * both the server action and the unit tests share one source of truth: a DB + * unique-title violation surfaces as a field error named like the full form's, + * anything else collapses to a generic message. + */ +export const ACTIVITY_DUPLICATE_TITLE_MESSAGE = 'Já existe uma atividade com este título.' +export const ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE = + 'Não foi possível criar o compromisso. Tente novamente.' + +export type ActivityInlineCreateErrorResult = { + message: string + fieldErrors?: Record +} + +export const mapActivityInlineCreateError = (error: unknown): ActivityInlineCreateErrorResult => { + if (error instanceof Error && /já existe|unique|duplicate key/i.test(error.message)) { + return { + message: ACTIVITY_DUPLICATE_TITLE_MESSAGE, + fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] }, + } + } + return { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE } +} diff --git a/src/utilities/activityUi.ts b/src/utilities/activityUi.ts index 3478ebeb..9c35c840 100644 --- a/src/utilities/activityUi.ts +++ b/src/utilities/activityUi.ts @@ -1,6 +1,7 @@ import type { Where } from 'payload' import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths' +import { parseBahiaDateTimeInput } from '@/lib/campaignTime' import { isContactSearchQueryReady, normalizeContactSearchQuery } from '@/lib/contactSearchQuery' import { activityStatuses, @@ -148,20 +149,46 @@ export type ActivityCreatePrefill = { startAt?: string endAt?: string municipalityId?: number + title?: string } export const buildActivityCreateHref = ( agendaState: ActivityAgendaState, - prefill: Omit = {}, + prefill: Partial = {}, ): string => { const params = new URLSearchParams() if (prefill.startAt) params.set('startAt', prefill.startAt) if (prefill.endAt) params.set('endAt', prefill.endAt) - if (agendaState.municipality) params.set('municipality', String(agendaState.municipality)) + const municipalityId = prefill.municipalityId ?? agendaState.municipality + if (municipalityId) params.set('municipality', String(municipalityId)) + if (prefill.title) params.set('title', prefill.title) params.set('returnTo', buildActivityAgendaHref(agendaState)) return `/campanha/atividades/nova?${params.toString()}` } +/** + * C91 — the inline create overlay prefills the slot the staff clicked. The + * FullCalendar `dateClick` already snaps the click to a slot boundary, so for + * the week/day grids the interval is the slot itself (30 min); an all-day + * click (month grid) falls back to the historical 09:00–10:00 window. + */ +export const activitySlotPrefill = (info: { + allDay: boolean + dateStr: string +}): { startAt: string; endAt: string } | null => { + if (info.allDay) { + const startAt = parseBahiaDateTimeInput(`${info.dateStr.slice(0, 10)}T09:00`) + if (!startAt) return null + return { startAt, endAt: new Date(new Date(startAt).getTime() + 3_600_000).toISOString() } + } + const start = new Date(info.dateStr) + if (Number.isNaN(start.getTime())) return null + return { + startAt: start.toISOString(), + endAt: new Date(start.getTime() + 1_800_000).toISOString(), + } +} + export const parseActivityAgendaReturnHref = ( value: string | string[] | undefined, accessibleMunicipalityIDs: ReadonlySet, @@ -212,10 +239,14 @@ export const parseActivityCreatePrefill = ( const municipalityId = rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined + const title = normalizedText(firstValue(params.title)) + const trimmedTitle = title && title.length <= 160 ? title : undefined + return { ...(startAt ? { startAt } : {}), ...(endAt ? { endAt } : {}), ...(municipalityId ? { municipalityId } : {}), + ...(trimmedTitle ? { title: trimmedTitle } : {}), } } diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index e3544f8a..2a7840da 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -137,13 +137,18 @@ test.describe('Agenda — calendário operacional', () => { await expect(campaignPageChrome(page, title)).toBeVisible() }) - test('leva o slot semanal para o formulário existente', async ({ campaign, page }) => { + test('cria compromisso inline no slot clicado, sem sair da agenda', async ({ + campaign, + page, + }) => { const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.claimMunicipality() + const title = fixtures.value('Café inline na agenda') await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`) + const dayCell = page.getByRole('gridcell').nth(1) await dayCell.scrollIntoViewIfNeeded() const [slotBox, dayBox] = await Promise.all([ @@ -158,7 +163,61 @@ test.describe('Agenda — calendário operacional', () => { }, }) + // The click no longer navigates: the inline overlay opens at the slot. + await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) + const startInput = page.getByLabel('Início *') + await expect(startInput).toBeVisible() + const [startValue, endValue] = await Promise.all([ + startInput.inputValue(), + page.getByLabel('Término').inputValue(), + ]) + const [startAt, endAt] = [startValue, endValue].map(parseBahiaDateTimeInput) + expect(startAt).not.toBeNull() + expect(endAt).not.toBeNull() + if (!startAt || !endAt) throw new Error('O overlay não recebeu o intervalo do slot.') + expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000) + await expect(page.getByLabel('Município *')).toHaveValue(municipality.name) + + await page.getByLabel('Título *').fill(title) + await page.getByRole('button', { name: 'Salvar' }).click() + + await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 }) + await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) + }) + + test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + const title = fixtures.value('Detalhes inline da agenda') + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`) + + const dayCell = page.getByRole('gridcell').nth(1) + await dayCell.scrollIntoViewIfNeeded() + const [slotBox, dayBox] = await Promise.all([ + page.locator('[data-time="14:00:00"]').last().boundingBox(), + dayCell.boundingBox(), + ]) + if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.') + await dayCell.click({ + position: { + x: dayBox.width / 2, + y: slotBox.y - dayBox.y + slotBox.height / 2, + }, + }) + + await expect(page.getByLabel('Título *')).toBeVisible() + await page.getByLabel('Título *').fill(title) + await page.getByRole('link', { name: 'Mais detalhes' }).click() + await expect(page).toHaveURL(/\/campanha\/atividades\/nova\?startAt=/) + await expect(page.getByLabel('Título *')).toHaveValue(title) + await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id)) const [startValue, endValue] = await Promise.all([ page.getByLabel('Início *').inputValue(), page.getByLabel('Término').inputValue(), @@ -167,11 +226,6 @@ test.describe('Agenda — calendário operacional', () => { expect(startAt).not.toBeNull() expect(endAt).not.toBeNull() if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.') - expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(3_600_000) - await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id)) - await expect(page.getByRole('link', { name: 'Cancelar' })).toHaveAttribute( - 'href', - `/campanha/agenda?municipality=${municipality.id}`, - ) + expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000) }) }) diff --git a/tests/unit/activityAgendaInteractions.unit.spec.tsx b/tests/unit/activityAgendaInteractions.unit.spec.tsx index d93bfb52..0644490b 100644 --- a/tests/unit/activityAgendaInteractions.unit.spec.tsx +++ b/tests/unit/activityAgendaInteractions.unit.spec.tsx @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loadEvents: vi.fn(), reschedule: vi.fn(), + createInline: vi.fn(), + searchResponsibles: vi.fn(), revertDrop: vi.fn(), revertResize: vi.fn(), routerPush: vi.fn(), @@ -15,6 +17,11 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({ loadActivityAgendaEvents: (...args: unknown[]) => mocks.loadEvents(...args), rescheduleActivity: (...args: unknown[]) => mocks.reschedule(...args), + createActivityInline: (...args: unknown[]) => mocks.createInline(...args), +})) + +vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({ + searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query), })) vi.mock('next/navigation', () => ({ @@ -25,6 +32,12 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess }, })) +type DateClickInfo = { + allDay: boolean + dateStr: string + jsEvent: MouseEvent +} + type ScheduleMutationInfo = { event: { id: string; startStr: string; endStr: string } revert: () => void @@ -34,6 +47,7 @@ type CalendarProps = { datesSet: (info: { startStr: string; endStr: string }) => void eventDrop: (info: ScheduleMutationInfo) => void eventResize: (info: ScheduleMutationInfo) => void + dateClick: (info: DateClickInfo) => void } vi.mock('@fullcalendar/react', () => { @@ -54,6 +68,19 @@ vi.mock('@fullcalendar/react', () => { return createElement( 'div', null, + createElement( + 'button', + { + type: 'button', + onClick: () => + props.dateClick({ + allDay: false, + dateStr: '2026-08-07T13:00:00-03:00', + jsEvent: new MouseEvent('click'), + }), + }, + 'Clicar slot', + ), createElement( 'button', { type: 'button', onClick: () => props.eventDrop({ event, revert: mocks.revertDrop }) }, @@ -107,4 +134,19 @@ describe('ActivityAgenda schedule failures', () => { await waitFor(() => expect(mocks.revertResize).toHaveBeenCalledOnce()) expect(mocks.toastError).toHaveBeenCalledWith('Remarcação recusada.') }) + + it('abre a criação inline no clique de um slot vazio, sem navegar', async () => { + mocks.loadEvents.mockResolvedValue([]) + render() + + await waitFor(() => expect(mocks.loadEvents).toHaveBeenCalled()) + expect(mocks.routerPush).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Clicar slot' })) + + const startInput = await screen.findByLabelText('Início *') + expect((startInput as HTMLInputElement).value).toBe('2026-08-07T13:00') + expect((screen.getByLabelText('Término') as HTMLInputElement).value).toBe('2026-08-07T13:30') + expect(mocks.routerPush).not.toHaveBeenCalled() + }) }) diff --git a/tests/unit/activityInlineCreate.unit.spec.tsx b/tests/unit/activityInlineCreate.unit.spec.tsx new file mode 100644 index 00000000..6edb18b8 --- /dev/null +++ b/tests/unit/activityInlineCreate.unit.spec.tsx @@ -0,0 +1,162 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createInline: vi.fn(), + searchResponsibles: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock('@/app/(campaign)/campanha/actions/activity', () => ({ + createActivityInline: (...args: unknown[]) => mocks.createInline(...args), +})) + +vi.mock('@/app/(campaign)/campanha/(app)/atividades/contactSearchActions', () => ({ + searchActivityResponsibleOptionsAction: (query: string) => mocks.searchResponsibles(query), +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError, success: mocks.toastSuccess }, +})) + +import { + ActivityInlineCreate, + type ActivityInlineCreateDraft, +} from '@/components/campaign/activity/ActivityInlineCreate' +import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect' +import type { ActivityAgendaState } from '@/utilities/activityUi' + +const DRAFT: ActivityInlineCreateDraft = { + startAt: '2026-08-07T16:00:00.000Z', + endAt: '2026-08-07T16:30:00.000Z', + anchor: { x: 100, y: 200 }, +} + +const MUNICIPALITIES: RelationOption[] = [ + { id: 12, name: 'Camaçari' }, + { id: 13, name: 'Ilhéus' }, +] + +const renderOverlay = ({ + draft = DRAFT, + agendaState = {}, + isNarrow = false, + onCreated = vi.fn(), + onClose = vi.fn(), +}: { + draft?: ActivityInlineCreateDraft | null + agendaState?: ActivityAgendaState + isNarrow?: boolean + onCreated?: () => void + onClose?: () => void +} = {}) => + render( + , + ) + +describe('ActivityInlineCreate — overlay de criação inline', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('preenche horários do slot e usa o município do filtro ativo', () => { + renderOverlay({ agendaState: { municipality: 12 } }) + + expect((screen.getByLabelText('Início *') as HTMLInputElement).value).toBe('2026-08-07T13:00') + expect((screen.getByLabelText('Término') as HTMLInputElement).value).toBe('2026-08-07T13:30') + expect((screen.getByLabelText('Município *') as HTMLInputElement).value).toBe('Camaçari') + }) + + it('cria sem navegar e avisa o pai para recarregar a janela', async () => { + const onCreated = vi.fn() + mocks.createInline.mockResolvedValue({ ok: true }) + renderOverlay({ agendaState: { municipality: 12 }, onCreated }) + + fireEvent.change(screen.getByLabelText('Título *'), { + target: { value: 'Café com apoiadores' }, + }) + fireEvent.change(screen.getByLabelText('Local (opcional)'), { + target: { value: 'Centro histórico' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Salvar' })) + + await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1)) + expect(mocks.createInline).toHaveBeenCalledWith({ + title: 'Café com apoiadores', + municipality: 12, + startAt: '2026-08-07T16:00:00.000Z', + endAt: '2026-08-07T16:30:00.000Z', + locality: 'Centro histórico', + }) + expect(onCreated).toHaveBeenCalledTimes(1) + }) + + it('bloqueia salvar sem município e não invoca a ação', async () => { + const onCreated = vi.fn() + mocks.createInline.mockResolvedValue({ ok: true }) + renderOverlay({ agendaState: {}, onCreated }) + + fireEvent.change(screen.getByLabelText('Título *'), { + target: { value: 'Café com apoiadores' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Salvar' })) + + await waitFor(() => expect(screen.getByText('Informe o município.')).toBeTruthy()) + expect(mocks.createInline).not.toHaveBeenCalled() + expect(onCreated).not.toHaveBeenCalled() + }) + + it('mantém o overlay aberto com erro inline quando a criação falha', async () => { + const onCreated = vi.fn() + const message = 'Já existe uma atividade com este título.' + mocks.createInline.mockResolvedValue({ + ok: false, + message, + fieldErrors: { title: [message] }, + }) + renderOverlay({ agendaState: { municipality: 12 }, onCreated }) + + fireEvent.change(screen.getByLabelText('Título *'), { + target: { value: 'Café com apoiadores' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Salvar' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(message)) + await waitFor(() => expect(screen.getAllByText(message).length).toBeGreaterThan(0)) + expect(onCreated).not.toHaveBeenCalled() + }) + + it('monta o href de "Mais detalhes" com município e título digitados', () => { + renderOverlay({ agendaState: { municipality: 12 } }) + + fireEvent.change(screen.getByLabelText('Título *'), { + target: { value: 'Café com apoiadores' }, + }) + const link = screen.getByRole('link', { name: 'Mais detalhes' }) + expect(link.getAttribute('href')).toBe( + '/campanha/atividades/nova?startAt=2026-08-07T16%3A00%3A00.000Z&endAt=2026-08-07T16%3A30%3A00.000Z&municipality=12&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12', + ) + }) + + it('não renderiza nada sem um rascunho de slot', () => { + const { container } = renderOverlay({ draft: null }) + expect(container.firstChild).toBeNull() + }) + + it('renderiza o mesmo conteúdo no bottom sheet mobile', () => { + renderOverlay({ agendaState: { municipality: 12 }, isNarrow: true }) + + expect(screen.getByLabelText('Título *')).toBeTruthy() + expect((screen.getByLabelText('Início *') as HTMLInputElement).value).toBe('2026-08-07T13:00') + expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy() + }) +}) diff --git a/tests/unit/activityInlineErrors.unit.spec.ts b/tests/unit/activityInlineErrors.unit.spec.ts new file mode 100644 index 00000000..65ae4dd1 --- /dev/null +++ b/tests/unit/activityInlineErrors.unit.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { + ACTIVITY_DUPLICATE_TITLE_MESSAGE, + ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE, + mapActivityInlineCreateError, +} from '@/utilities/activityInlineErrors' + +describe('activity inline create error mapping', () => { + it('surfaces a DB unique-title violation as a title field error', () => { + expect( + mapActivityInlineCreateError( + new Error('duplicate key value violates unique constraint "activity_slug_unique"'), + ), + ).toEqual({ + message: ACTIVITY_DUPLICATE_TITLE_MESSAGE, + fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] }, + }) + expect(mapActivityInlineCreateError(new Error('já existe uma atividade'))).toEqual({ + message: ACTIVITY_DUPLICATE_TITLE_MESSAGE, + fieldErrors: { title: [ACTIVITY_DUPLICATE_TITLE_MESSAGE] }, + }) + }) + + it('collapses every other failure to the generic message', () => { + const generic = { message: ACTIVITY_INLINE_GENERIC_FAILURE_MESSAGE } + expect(mapActivityInlineCreateError(new Error('Boom'))).toEqual(generic) + expect(mapActivityInlineCreateError('string')).toEqual(generic) + expect(mapActivityInlineCreateError(undefined)).toEqual(generic) + }) +}) diff --git a/tests/unit/activityUi.unit.spec.ts b/tests/unit/activityUi.unit.spec.ts index fda97033..2e7d1aae 100644 --- a/tests/unit/activityUi.unit.spec.ts +++ b/tests/unit/activityUi.unit.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { + activitySlotPrefill, activityTabs, buildActivityAgendaHref, buildActivityAgendaSearchParams, @@ -307,4 +308,54 @@ describe('activity create prefill', () => { ), ).toEqual({ startAt: '2026-08-07T13:00:00.000Z' }) }) + + it('carries the inline title into the creation URL (C91)', () => { + expect( + buildActivityCreateHref( + { tag: 'Comício' }, + { + startAt: '2026-08-07T13:00:00.000Z', + endAt: '2026-08-07T13:30:00.000Z', + municipalityId: 3, + title: 'Café com apoiadores', + }, + ), + ).toBe( + '/campanha/atividades/nova?startAt=2026-08-07T13%3A00%3A00.000Z&endAt=2026-08-07T13%3A30%3A00.000Z&municipality=3&title=Caf%C3%A9+com+apoiadores&returnTo=%2Fcampanha%2Fagenda%3Ftag%3DCom%25C3%25ADcio', + ) + }) + + it('prefers the inline municipality over the agenda filter', () => { + expect(buildActivityCreateHref({ municipality: 12 }, { municipalityId: 3 })).toBe( + '/campanha/atividades/nova?municipality=3&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12', + ) + }) + + it('parses a bounded inline title and drops an oversized one', () => { + expect(parseActivityCreatePrefill({ title: ' Café com apoiadores ' }, new Set())).toEqual({ + title: 'Café com apoiadores', + }) + expect(parseActivityCreatePrefill({ title: 'a'.repeat(200) }, new Set())).toEqual({}) + }) +}) + +describe('activity slot prefill (C91)', () => { + it('uses the snapped slot interval for time grids', () => { + expect(activitySlotPrefill({ allDay: false, dateStr: '2026-08-07T13:00:00-03:00' })).toEqual({ + startAt: '2026-08-07T16:00:00.000Z', + endAt: '2026-08-07T16:30:00.000Z', + }) + }) + + it('falls back to the 09:00–10:00 window on an all-day click', () => { + expect(activitySlotPrefill({ allDay: true, dateStr: '2026-08-07' })).toEqual({ + startAt: '2026-08-07T12:00:00.000Z', + endAt: '2026-08-07T13:00:00.000Z', + }) + }) + + it('returns null for an unparseable date', () => { + expect(activitySlotPrefill({ allDay: false, dateStr: 'não é uma data' })).toBeNull() + expect(activitySlotPrefill({ allDay: true, dateStr: 'não é uma data' })).toBeNull() + }) }) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index a8aff7ff..1a246f54 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -379,6 +379,7 @@ describe('src/utilities top-level is pinned', () => { 'activityDetailPageData.ts', 'activityDetailTabUi.ts', 'activityFormData.ts', + 'activityInlineErrors.ts', 'activityOmnibox.ts', 'activityLeadershipOptions.ts', 'activityPageData.ts',