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 (
+
+ )
+}
+
+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)}
+
+