Pular para o conteúdo principal

Backend mockado e arquitetura do módulo Eventos — Intranet (TEC-533)

Módulo Eventos da Intranet (TEC-533): gestores (owner/admin/comunicacao) criam, editam e cancelam eventos; associados (role member) se inscrevem, com vagas limitadas por ordem de inscrição. Módulo 100% frontend nesta etapa — dados mockados, sem backend em desenvolvimento nem em review. Mesmo padrão spec-driven e mesma estrutura de pastas do módulo Enquetes (enquetes/_lib/mock/).

Componentes shadcn já existentes

calendar, table, avatar (inclui AvatarGroup/AvatarGroupCount — usado no RF010-III, avatares do modal de confirmação), badge, select, dialog, alert-dialog, textarea, skeleton, popover, tabs, empty-state — todos já em packages/ui/src/shadcn/.

Não existe time-picker dedicado no projeto — RF015-III ("Horário") usa <input type="time"> nativo. O date picker do modal reaproveita o padrão de enquetes/_components/create-poll-modal.tsx (Popover + Calendar + date-fns).

Tipos TypeScript

export type EventFormat = 'presencial' | 'online' | 'hibrido';

/**
* Os 6 status do RF005. `inscrito` NÃO é um status "de banco" — é a
* sobreposição por perfil (associado + já inscrito) em cima do status real
* do evento, resolvida por `resolveEventStatus` (mesmo espírito de
* `shouldRevealResults` em Enquetes).
*/
export type EventStatus =
| 'vagas_abertas'
| 'inscrito'
| 'vagas_encerradas'
| 'em_breve'
| 'encerrado'
| 'cancelado';

export type EventsTab = 'todos' | 'inscritos' | 'encerrados';

/** Status da inscrição individual (RF020) — inferido dos prints de Inscritos, sem RF/RUX textual explícito. */
export type AttendeeStatus = 'confirmado' | 'pendente';

export interface EventListItem {
id: string;
title: string;
date: string; // ISO (YYYY-MM-DD)
time: string; // HH:mm
location: string;
format: EventFormat;
totalSpots: number;
remainingSpots: number;
totalSubscribers: number;
/** Antecipa campo que o backend provavelmente vai precisar (RF005-IV: "evento ainda não aberto para inscrição") — não está em nenhum RF/schema real ainda. */
registrationOpensAt: string | null;
cancelledAt: string | null;
/** Quem cancelou — tooltip da aba Encerrados (fix pós-review, ver seção final). */
cancelledBy: string | null;
description: string;
status: EventStatus;
/**
* Fonte única de verdade pra exibir "Inscrever-se" (RF009). Não basta
* checar `status === 'vagas_abertas'` — `resolveEventStatus` não é
* role-aware além do caso `inscrito` (ver fix pós-review).
*/
canSubscribe: boolean;
isSubscribed: boolean;
createdAt: string;
createdBy: string;
}

export interface EventAttendee {
id: string;
userId: string;
userName: string;
company: string; // "Concessionária" (RF020)
subscribedAt: string; // ISO date
status: AttendeeStatus;
}

export interface EventDetail extends EventListItem {
attendees: EventAttendee[];
}

export interface EventsContext {
canManage: boolean;
}

export interface ListEventsFilters {
tab: EventsTab;
search?: string;
format?: EventFormat;
date?: string; // clicar num dia do calendário filtra por ele
}

/** Espelha o envelope de retorno de `authActionClient` (next-safe-action). */
export interface ActionResult<T> {
data?: T;
serverError?: string;
}

Camada de mock

Mesma estrutura de enquetes/_lib/mock/, com comentário // MOCK: ... no topo do service listando o que muda quando o backend de Eventos existir (cada função vira uma Server Action real com a mesma assinatura, só ganhando accountSlug resolvido pela rota).

// events-mock-service.ts
async function getEventsMock(filters: ListEventsFilters): Promise<ActionResult<EventListItem[]>>
async function getEventContextMock(): Promise<ActionResult<EventsContext>>
async function getEventDetailMock(eventId: string): Promise<ActionResult<EventDetail>>
async function createEventMock(input: CreateEventInput): Promise<ActionResult<EventListItem>>
async function updateEventMock(input: UpdateEventInput): Promise<ActionResult<EventListItem>>
async function subscribeToEventMock(eventId: string): Promise<ActionResult<EventListItem>> // simula RF010-V: se vagas=0 no momento da chamada, retorna serverError
async function cancelSubscriptionMock(eventId: string): Promise<ActionResult<EventListItem>>
async function cancelEventMock(eventId: string): Promise<ActionResult<EventListItem>>
async function exportAttendeesListMock(eventId: string): Promise<ActionResult<{ triggered: true }>> // mantida por completude — a exportação real usa export-attendees.ts client-side, não esta action
  • Todas com await delay(300-700ms) artificial.
  • subscribeToEventMock tem o valor mágico de erro (force-error no id) E a simulação real de corrida de vagas (RF010-V): remainingSpots é recalculado no momento da chamada (não usa o valor que a UI tinha em cache quando o modal abriu) — no evento de teste evt-race, a primeira confirmação encontra as vagas esgotadas por "outro associado".
  • "Ver todos" (RF021) não chama uma função nova — EventDetail.attendees já vem completo de getEventDetailMock; o corte "Mostrando X de Y" é um .slice() no client.
  • current-profile.ts: MOCK_IS_MANAGER — mesmo padrão de Enquetes, alternar manualmente + recarregar pra trocar de perfil visualmente (Admin e Comunicação têm as mesmas permissões no módulo, uma única flag cobre os dois).

Mapeamento status → variante visual

resolveEventStatus(event, { isManager, isSubscribed }): EventStatus (função pura, sem I/O — mesmo espírito de shouldRevealResults):

function resolveEventStatus(event, { isManager, isSubscribed }) {
if (event.cancelledAt) return 'cancelado';
if (isPastDate(event.date)) return 'encerrado';
if (!isManager && isSubscribed) return 'inscrito';
if (event.registrationOpensAt && isFutureDate(event.registrationOpensAt)) return 'em_breve';
if (remainingSpotsOf(event) <= 0) return 'vagas_encerradas';
return 'vagas_abertas';
}
StatusBadge (variant + classe local)Botão "Inscrever-se" (associado)
vagas_abertassuccess + override bg-[#EBF9EB] text-[#12805C] (EVENT_STATUS_CLASSNAME, fix pós-review)Sim (via event.canSubscribe)
inscritosuccess (verde padrão)Não (substituído pelo badge — RUX003)
vagas_encerradassecondaryNão
em_brevedefault/infoNão
encerradosecondaryNão
canceladodestructive (tooltip "Cancelado por X em Y" na aba Encerrados)Não

AttendeeStatus (tabela de Inscritos): confirmadosuccess; pendenteoutline.

Cenários mockados

  1. Assembleia Geral Ordináriainscrito (associado mock já inscrito), 96 inscritos / 24 vagas restantes / 120 totais (80% ocupação)
  2. Webinar: Novidades do DMSvagas_abertas, várias vagas restantes
  3. Reunião — Comissão Jurídicavagas_abertas
  4. Encontro de Associadosem_breve (registrationOpensAt no futuro)
  5. evt-race — 1 vaga restante, testa a corrida de vagas do RF010-V
  6. evt-fullvagas_encerradas, sem botão de inscrição
  7. evt-pastencerrado, data passada
  8. evt-cancelledcancelado, com cancelledBy simulado

Empty states: MOCK_FORCE_EMPTY_ALL (current-profile.ts) zera a listagem inteira pra QA visual do estado vazio "tela inteira" (RF008-IV) — não reflete nenhum fluxo real de exclusão em massa.

Perfil mockado

MOCK_IS_MANAGER/MOCK_CURRENT_USER_ID em _lib/mock/current-profile.ts, sem UI de alternar perfil (troca manual + recarrega). Em produção, isManager viria de uma futura getEventContextAction (calculada a partir de content.manage, mesmo padrão de Enquetes/Blog).

MOCK_IS_MANAGER é uma constante de módulo, não uma leitura por conta. É o mesmo import direto lido em 3 pontos — page.tsx (vira a prop canManage), events-mock-service.ts (resolveEventStatus/canSubscribe/getEventContextMock) e [eventId]/inscritos/page.tsx (guard de redirect) — sempre o mesmo valor, nunca diverge entre si. Mas justamente por ser uma constante fixa no código (não uma função que recebe account/usuário autenticado), trocar de conta na aplicação não tem nenhum efeito sobre o perfil exibido no módulo Eventos — o mesmo perfil mockado (gestor ou associado) vale pra qualquer conta acessada, até o arquivo ser editado manualmente. Isso só passa a refletir a conta/role reais quando a Server Action de contexto (getEventContextAction) substituir o mock — mesmo ponto de virada mencionado acima.

Gap encontrado na auditoria de role (pós-review): a aba "Inscritos" (events-tabs.tsx) não recebe canManage — aparece e funciona igual pra qualquer perfil, inclusive gestor. Como o mock usa um MOCK_CURRENT_USER_ID único (não há "usuário logado" de verdade ainda), um gestor consegue abrir essa aba e, na tabela, clicar "Cancelar inscrição" (events-table.tsx, também sem checar canManage) — o que cancelaria a inscrição do associado mockado, não "dele mesmo" (não existe um "ele mesmo" separado nesse mock). Não é uma violação do RF009 (que trata só de quem pode se inscrever), mas é uma inconsistência de UX pendente de decisão: esconder a aba pra quem gerencia agora, ou aceitar que o problema se resolve sozinho quando o backend filtrar de verdade por usuário autenticado (a aba naturalmente viria vazia pra um gestor). Ainda não implementado — ver conversa/decisão no PR.

Extração de hooks (_lib/hooks/)

Refatoração pura de events-listing.tsx — nenhum comportamento visual, data-test, texto ou prop pública de componente filho mudou nessa extração.

Estado/lógicaDestinoObservação
Busca com debounceuse-debounced-search.tsGenérico, sem nada de Eventos — não existe um hook de debounce compartilhado no projeto (o mesmo padrão inline está duplicado em Blog/Mercado/Revista/Documentos); manter local ao módulo, sem tentar unificar os outros 4 (fora de escopo)
Filtros, fetch, paginação mobileuse-events-listing.tsRecebe search (já debounced) como parâmetro; migrado pra React Query (TanStack Query) — ver correções pós-review
Estado dos 3 modais (criação/edição, confirmação de inscrição, "ver todos")use-event-modals.tsEstado + handlers derivados
Decisão criar vs. atualizar eventoevents.actions.ts (submitEventForm)Só a chamada ao mock; toast e reload continuam no componente

handleFormSubmit permanece no componente (events-listing.tsx), não em nenhum hook — depende de editingEvent (use-event-modals) e de loadEvents (use-events-listing), duas fontes de estado de hooks diferentes; colocar essa função num dos dois acoplaria um ao outro. Menor acoplamento: os dois hooks continuam independentes.

Não há convenção prévia de pasta _lib/hooks/ em outro módulo da Intranet — Blog/Mercado/Documentos usam o mesmo padrão inline que Eventos tinha antes desta extração.

Pendências de design

  • AttendeeStatus (Confirmado/Pendente): não há RF/RUX que defina o que diferencia os dois, nem regra de transição (RF012 diz que não há lista de espera). Modelado como campo simples no mock, sem lógica de transição — se o backend precisar de uma regra real (ex.: pendente = aguardando confirmação de presença), fica pra quando o backend for definido.
  • registrationOpensAt: campo antecipado pra dar suporte ao status em_breve — não está em nenhum RF. Sinalizado como suposição a validar com Análise quando o backend for desenhado.
  • cancelledBy (adicionado no fix pós-review): sem Server Action real de cancelamento ainda, o valor é simulado (autor fixo) — quando o backend existir, vira o usuário autenticado que confirma o CancelEventDialog.

Correções pós-review (PR #181)

Fechamento de pendências de QA/code review antes do merge — detalhes completos e diagnóstico linha-a-linha ficaram em REVIEW_FIXES_SPEC.md durante a implementação (removido após aplicado; histórico no PR). Resumo do que mudou em relação ao que este documento descrevia originalmente:

  • Toast "Inscrição confirmada" ao confirmar inscrição (events-listing.tsx) — antes o fluxo de confirmação era o único dos 4 (criar/editar, cancelar inscrição, cancelar evento, confirmar inscrição) sem nenhum feedback.
  • <SelectValue> do campo Formato (event-form-modal.tsx) passou a formatar o valor exibido no trigger fechado (EVENT_FORMAT_LABELS, mesmo padrão de veiculo-search-form.tsx) — antes mostrava o valor cru do enum ("hibrido", sem acento/maiúscula) até o usuário abrir a lista.
  • Rótulo do link no EventCard renomeado de "Ver detalhes" pra "Ver inscritos" — já levava pra tela de Inscritos, só o texto estava errado.
  • EVENT_STATUS_CLASSNAME (events.utils.ts) — override de cor local só pra vagas_abertas, sem tocar packages/ui/src/shadcn/badge.tsx.
  • EventListItem.canSubscribe — nova fonte única de verdade pro botão "Inscrever-se" (RF009), substituindo o recálculo ad-hoc !canManage && status === 'vagas_abertas' em cada componente.
  • EventListItem.cancelledBy — novo campo, tooltip "Cancelado por X em Y" na tabela de Encerrados.
  • use-events-listing.ts migrado de useState/useEffect pra useQueries (React Query) — trata o erro da busca "todas as datas" (antes ignorado) e ganha cancelamento nativo de requisições obsoletas.
  • Exportação de Inscritos (export-attendees.ts) gera .xlsx real via downloadXlsx (mesma função de 19+ relatórios do emplacamento), não mais um CSV com extensão/MIME trocados.
  • Chips de filtro ativo + "Limpar filtros" (events-filter-chips.tsx) pro filtro de formato, layout confirmado no Figma (arquivo "ABRACAF (cópia)", node 1049:8793).
  • Avatares dos inscritos no modal de confirmação (AvatarGroup/AvatarGroupCount, já existentes no design system).