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. subscribeToEventMocktem o valor mágico de erro (force-errorno 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 testeevt-race, a primeira confirmação encontra as vagas esgotadas por "outro associado".- "Ver todos" (RF021) não chama uma função nova —
EventDetail.attendeesjá vem completo degetEventDetailMock; 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';
}
| Status | Badge (variant + classe local) | Botão "Inscrever-se" (associado) |
|---|---|---|
vagas_abertas | success + override bg-[#EBF9EB] text-[#12805C] (EVENT_STATUS_CLASSNAME, fix pós-review) | Sim (via event.canSubscribe) |
inscrito | success (verde padrão) | Não (substituído pelo badge — RUX003) |
vagas_encerradas | secondary | Não |
em_breve | default/info | Não |
encerrado | secondary | Não |
cancelado | destructive (tooltip "Cancelado por X em Y" na aba Encerrados) | Não |
AttendeeStatus (tabela de Inscritos): confirmado → success; pendente → outline.
Cenários mockados
- Assembleia Geral Ordinária —
inscrito(associado mock já inscrito), 96 inscritos / 24 vagas restantes / 120 totais (80% ocupação) - Webinar: Novidades do DMS —
vagas_abertas, várias vagas restantes - Reunião — Comissão Jurídica —
vagas_abertas - Encontro de Associados —
em_breve(registrationOpensAtno futuro) evt-race— 1 vaga restante, testa a corrida de vagas do RF010-Vevt-full—vagas_encerradas, sem botão de inscriçãoevt-past—encerrado, data passadaevt-cancelled—cancelado, comcancelledBysimulado
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ógica | Destino | Observação |
|---|---|---|
| Busca com debounce | use-debounced-search.ts | Gené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 mobile | use-events-listing.ts | Recebe 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.ts | Estado + handlers derivados |
| Decisão criar vs. atualizar evento | events.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 statusem_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 oCancelEventDialog.
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 deveiculo-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
EventCardrenomeado 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ó pravagas_abertas, sem tocarpackages/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.tsmigrado deuseState/useEffectprauseQueries(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.xlsxreal viadownloadXlsx(mesma função de 19+ relatórios doemplacamento), 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)", node1049:8793). - Avatares dos inscritos no modal de confirmação (
AvatarGroup/AvatarGroupCount, já existentes no design system).