feat(C91): cria evento inline no calendário da agenda (popover/drawer no slot clicado)
This commit is contained in:
parent
09d44c1b9d
commit
77673147ca
14 changed files with 916 additions and 37 deletions
115
docs/plans/agenda-criar-evento-inline-impl.md
Normal file
115
docs/plans/agenda-criar-evento-inline-impl.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -100,7 +100,7 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
|
|||
}}
|
||||
/>
|
||||
<CampaignListResults>
|
||||
<ActivityAgenda state={state} />
|
||||
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />
|
||||
</CampaignListResults>
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignPageShell>
|
||||
|
|
|
|||
|
|
@ -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<string, string[]> }
|
||||
|
||||
/**
|
||||
* 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<ActivityInlineCreateResult> => {
|
||||
try {
|
||||
await createActivity(input)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, ...mapActivityInlineCreateError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export const updateActivity = async (
|
||||
input: ActivityUpdateInput,
|
||||
demandDrafts: ActivityDemandDraft[] = [],
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null)
|
||||
const calendarRef = useRef<CalendarRef>(null)
|
||||
const narrowRef = useRef<boolean | null>(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<string | null>(null)
|
||||
const [isNarrow, setIsNarrow] = useState(false)
|
||||
const [createDraft, setCreateDraft] = useState<ActivityInlineCreateDraft | null>(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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivityInlineCreate
|
||||
draft={createDraft}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={state}
|
||||
municipalityOptions={municipalityOptions}
|
||||
onClose={() => setCreateDraft(null)}
|
||||
onCreated={() => {
|
||||
setCreateDraft(null)
|
||||
setReloadCount((count) => count + 1)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ const ActivityFormFields = ({
|
|||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={submittedTitle ?? activity?.title}
|
||||
defaultValue={submittedTitle ?? initialValues?.title ?? activity?.title}
|
||||
minLength={2}
|
||||
maxLength={160}
|
||||
className="min-h-11"
|
||||
|
|
|
|||
324
src/components/campaign/activity/ActivityInlineCreate.tsx
Normal file
324
src/components/campaign/activity/ActivityInlineCreate.tsx
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { searchActivityResponsibleOptionsAction } from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
|
||||
import { createActivityInline } from '@/app/(campaign)/campanha/actions/activity'
|
||||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { ResponsibleMultiSelect } from '@/components/campaign/shared/ResponsibleMultiSelect'
|
||||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import {
|
||||
formatBahiaDateTimeLabel,
|
||||
formatIsoAsBahiaDateTimeInput,
|
||||
parseBahiaDateTimeInput,
|
||||
} from '@/lib/campaignTime'
|
||||
import { activityResponsibleSchema, type ActivityCreateInput } from '@/lib/schemas/activity'
|
||||
import { buildActivityCreateHref, type ActivityAgendaState } from '@/utilities/activityUi'
|
||||
import type { StrictComboboxOption } from '@/utilities/territory/territoryComboboxOptions'
|
||||
|
||||
/**
|
||||
* C91 — the Google-Calendar-like quick create anchored on a calendar slot.
|
||||
* One content component is rendered inside a Popover (desktop, anchored at the
|
||||
* click) or a bottom Drawer (mobile); saving never navigates and the parent
|
||||
* refetches the visible window (`onCreated`).
|
||||
*/
|
||||
|
||||
export type ActivityInlineCreateDraft = {
|
||||
startAt: string
|
||||
endAt: string
|
||||
anchor: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
type ActivityInlineCreateProps = {
|
||||
draft: ActivityInlineCreateDraft | null
|
||||
isNarrow: boolean
|
||||
agendaState: ActivityAgendaState
|
||||
municipalityOptions: RelationOption[]
|
||||
onClose: () => 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<Record<string, string[]>>({})
|
||||
const [failedMessage, setFailedMessage] = useState<string | null>(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<HTMLFormElement>) => {
|
||||
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<string, string[]> = {}
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
<Field data-invalid={Boolean(errorFor('title'))}>
|
||||
<FieldLabel htmlFor="inline-title">Título *</FieldLabel>
|
||||
<Input
|
||||
id="inline-title"
|
||||
value={title}
|
||||
onChange={(event) => 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') ? (
|
||||
<FieldError id="inline-title-error">{errorFor('title')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="inline-startAt">Início *</FieldLabel>
|
||||
<Input
|
||||
id="inline-startAt"
|
||||
type="datetime-local"
|
||||
value={start}
|
||||
onChange={(event) => setStart(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'inline-startAt-error' : undefined}
|
||||
/>
|
||||
{errorFor('startAt') ? (
|
||||
<FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(errorFor('endAt'))}>
|
||||
<FieldLabel htmlFor="inline-endAt">Término</FieldLabel>
|
||||
<Input
|
||||
id="inline-endAt"
|
||||
type="datetime-local"
|
||||
value={end}
|
||||
onChange={(event) => setEnd(event.target.value)}
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('endAt'))}
|
||||
aria-describedby={errorFor('endAt') ? 'inline-endAt-error' : undefined}
|
||||
/>
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field data-invalid={Boolean(errorFor('municipality'))}>
|
||||
<FieldLabel htmlFor="inline-municipality">Município *</FieldLabel>
|
||||
<StrictCombobox
|
||||
id="inline-municipality"
|
||||
options={inlineMunicipalityOptions(municipalityOptions)}
|
||||
value={municipalityValue}
|
||||
onValueChange={setMunicipalityValue}
|
||||
error={errorFor('municipality')}
|
||||
/>
|
||||
{errorFor('municipality') ? (
|
||||
<FieldError id="inline-municipality-error">{errorFor('municipality')}</FieldError>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inline-locality">Local (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="inline-locality"
|
||||
value={locality}
|
||||
onChange={(event) => setLocality(event.target.value)}
|
||||
maxLength={160}
|
||||
placeholder="Bairro, endereço ou referência"
|
||||
className="min-h-11"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ResponsibleMultiSelect
|
||||
name="responsiblesJson"
|
||||
label="Responsáveis"
|
||||
search={searchActivityResponsibleOptionsAction}
|
||||
/>
|
||||
|
||||
{failedMessage ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{failedMessage}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="mt-1 flex items-center justify-between gap-3 border-t pt-3">
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={moreDetailsHref}>Mais detalhes</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
{saving ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
|
||||
Salvar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActivityInlineCreate = ({
|
||||
draft,
|
||||
isNarrow,
|
||||
agendaState,
|
||||
municipalityOptions,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: ActivityInlineCreateProps) => {
|
||||
if (!draft) return null
|
||||
|
||||
const form = (
|
||||
<ActivityInlineCreateForm
|
||||
startAt={draft.startAt}
|
||||
endAt={draft.endAt}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={municipalityOptions}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isNarrow) {
|
||||
return (
|
||||
<Drawer open onOpenChange={(next) => (next ? undefined : onClose())} showSwipeHandle>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Nova atividade</DrawerTitle>
|
||||
<DrawerDescription>{formatBahiaDateTimeLabel(draft.startAt)}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-6">{form}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open onOpenChange={(next) => (next ? undefined : onClose())}>
|
||||
<div
|
||||
className="fixed size-0"
|
||||
style={{ left: draft.anchor?.x ?? 0, top: draft.anchor?.y ?? 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<PopoverAnchor />
|
||||
</div>
|
||||
<PopoverContent align="start" sideOffset={8} className="w-96 p-4">
|
||||
{form}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
24
src/utilities/activityInlineErrors.ts
Normal file
24
src/utilities/activityInlineErrors.ts
Normal file
|
|
@ -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<string, string[]>
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<ActivityCreatePrefill, 'municipalityId'> = {},
|
||||
prefill: Partial<ActivityCreatePrefill> = {},
|
||||
): 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<number>,
|
||||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(<ActivityAgenda state={{}} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
162
tests/unit/activityInlineCreate.unit.spec.tsx
Normal file
162
tests/unit/activityInlineCreate.unit.spec.tsx
Normal file
|
|
@ -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(
|
||||
<ActivityInlineCreate
|
||||
draft={draft}
|
||||
isNarrow={isNarrow}
|
||||
agendaState={agendaState}
|
||||
municipalityOptions={MUNICIPALITIES}
|
||||
onClose={onClose}
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
31
tests/unit/activityInlineErrors.unit.spec.ts
Normal file
31
tests/unit/activityInlineErrors.unit.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue