plans
2026-05-11
2026 05 11 Admin Event Dashboard

Admin Events Dashboard & Mission Control Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Create an Events Dashboard as mission control center for managing individual events — showing participants, payments, check-ins, and actionable insights.

Architecture:

  • Events List Page (/dashboard/events/page.tsx) shows all events in a table view with filtering
  • Event Mission Control Page (/dashboard/events/[eventId]/page.tsx) shows deep-dive into a single event with all its bookings, payments, and check-ins
  • Convex queries provide real-time data from experienceEvents, reservations, payments, and checkIns tables

Tech Stack: Next.js 16 App Router | Convex (real-time queries) | Tailwind CSS v4 | Radix UI


File Structure

apps/frontend/
├── app/[locale]/dashboard/events/
│   ├── page.tsx                              # Events list
│   └── [eventId]/
│       └── page.tsx                          # Event mission control
└── lib/utils/
    └── date.ts                              # Already exists, ensure formatDateShort available

packages/backend/convex/
├── functions/
│   ├── events.ts                            # Event queries (list all, get by ID)
│   └── eventReservations.ts                 # Reservations for an event + payment details

Task 1: Create Convex queries for Events

Files:

  • Create: packages/backend/convex/functions/events.ts

  • Step 1: Create Convex events functions file

// packages/backend/convex/functions/events.ts
import { query } from "../_generated/server";
import { authenticatedQuery } from "../lib/auth";
 
export const listAll = authenticatedQuery({
  args: {},
  handler: async (ctx) => {
    const events = await ctx.db
      .query("experienceEvents")
      .order("date", { kind: "desc" })
      .collect();
 
    // Join with experience data
    const eventsWithExperience = await Promise.all(
      events.map(async (event) => {
        const experience = await ctx.db.get(event.experienceId);
        return {
          ...event,
          experienceTitle: experience?.title ?? "Unknown",
          experienceCode: experience?.code ?? "",
        };
      }),
    );
 
    return eventsWithExperience;
  },
});
 
export const getById = authenticatedQuery({
  args: { eventId: v.id("experienceEvents") },
  handler: async (ctx, { eventId }) => {
    const event = await ctx.db.get(eventId);
    if (!event) return null;
 
    const experience = await ctx.db.get(event.experienceId);
    return {
      ...event,
      experienceTitle: experience?.title ?? "Unknown",
      experienceCode: experience?.code ?? "",
    };
  },
});
  • Step 2: Create Convex eventReservations functions file
// packages/backend/convex/functions/eventReservations.ts
import { query } from "../_generated/server";
import { authenticatedQuery } from "../lib/auth";
 
export const listByEvent = authenticatedQuery({
  args: { eventId: v.id("experienceEvents") },
  handler: async (ctx, { eventId }) => {
    const reservations = await ctx.db
      .query("reservations")
      .withIndex("by_event", { event: eventId })
      .collect();
 
    // Get payment details for each reservation
    const withPayments = await Promise.all(
      reservations.map(async (res) => {
        const payments = await ctx.db
          .query("payments")
          .withIndex("by_reservation", { reservationId: res._id })
          .collect();
 
        // Get check-in status
        const checkIns = await ctx.db
          .query("checkIns")
          .withIndex("by_ticket", { ticketId: res.token ?? "" })
          .first();
 
        return {
          ...res,
          payments,
          checkIn: checkIns,
        };
      }),
    );
 
    return withPayments;
  },
});
 
export const getStats = authenticatedQuery({
  args: { eventId: v.id("experienceEvents") },
  handler: async (ctx, { eventId }) => {
    const reservations = await ctx.db
      .query("reservations")
      .withIndex("by_event", { event: eventId })
      .collect();
 
    const paidReservations = reservations.filter(
      (r) => r.paymentStatus === "PAID",
    );
    const checkedInCount = await ctx.db
      .query("checkIns")
      .withIndex("by_event", { eventId })
      .collect();
 
    const totalRevenue = paidReservations.reduce(
      (sum, r) => sum + r.totalAmount,
      0,
    );
    const dinnerTickets = reservations.filter(
      (r) => r.ticketType === "DINNER_THEATRE",
    ).length;
    const showOnlyTickets = reservations.filter(
      (r) => r.ticketType === "SHOW_ONLY",
    ).length;
 
    return {
      totalReservations: reservations.length,
      paidCount: paidReservations.length,
      pendingCount: reservations.filter((r) => r.paymentStatus === "PENDING")
        .length,
      refundedCount: reservations.filter((r) => r.paymentStatus === "REFUNDED")
        .length,
      checkedInCount: checkedInCount.length,
      totalGuests: reservations.reduce((sum, r) => sum + r.guests, 0),
      totalRevenue,
      dinnerTickets,
      showOnlyTickets,
    };
  },
});
  • Step 3: Commit
git add packages/backend/convex/functions/events.ts packages/backend/convex/functions/eventReservations.ts
git commit -m "feat(events): add Convex queries for events list and mission control"

Task 2: Create Events List Page

Files:

  • Create: apps/frontend/app/[locale]/dashboard/events/page.tsx

  • Modify: apps/frontend/components/admin/dashboard-bottom-nav.tsx (add Events nav item)

  • Step 1: Create Events List page component

"use client";
 
import { useQuery } from "convex/react";
import Link from "next/link";
import { api } from "@packages/backend/convex/_generated/api";
import { formatDateShort } from "~/lib/utils/date";
import { formatVND } from "~/lib/utils/format-currency";
import { DashboardPage } from "~/components/admin/dashboard-page";
import { PageHeader } from "~/components/admin/page-header";
import { Card } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { ArrowRight, Calendar, Users, DollarSign } from "lucide-react";
import * as m from "~/src/paraglide/messages";
import { cn } from "~/lib/utils";
 
type EventWithExperience = {
  _id: string;
  code: string;
  date: string;
  time: string;
  actualCapacity: number;
  bookedCount: number;
  status: string;
  experienceTitle: string;
  dinnerPrice: number;
  experienceOnlyPrice: number;
};
 
export default function EventsPage() {
  const events = useQuery(api.domains.events.listAll) as
    | EventWithExperience[]
    | undefined;
 
  if (events === undefined) {
    return (
      <DashboardPage>
        <div className="space-y-4">
          <Skeleton className="h-9 w-48" />
          <div className="grid gap-4">
            <Skeleton className="h-24 w-full" />
            <Skeleton className="h-24 w-full" />
          </div>
        </div>
      </DashboardPage>
    );
  }
 
  return (
    <DashboardPage>
      <div className="animate-slide-in-from-bottom-4 animate-fade-in animate-duration-300">
        <PageHeader
          title={m.admin_events_title?.() ?? "Events Dashboard"}
          subtitle={
            m.admin_events_subtitle?.() ?? "Mission control for all events"
          }
        />
      </div>
 
      <div className="mt-6 space-y-4">
        {events.length === 0 ? (
          <Card variant="glass" className="p-12 text-center">
            <p className="text-[var(--color-muted-foreground)]">
              {m.admin_events_noEvents?.() ?? "No events found"}
            </p>
          </Card>
        ) : (
          <div className="grid gap-4">
            {events.map((event) => {
              const percentage =
                event.actualCapacity > 0
                  ? Math.round((event.bookedCount / event.actualCapacity) * 100)
                  : 0;
 
              return (
                <Link key={event._id} href={`/dashboard/events/${event._id}`}>
                  <Card
                    variant="glass"
                    hover="lift"
                    className="p-6 transition-all hover:border-[var(--color-gold)]/30"
                  >
                    <div className="flex items-center justify-between">
                      <div className="flex-1">
                        <div className="flex items-center gap-3 mb-2">
                          <h3 className="font-medium text-lg">
                            {event.experienceTitle}
                          </h3>
                          <Badge
                            variant={
                              event.status === "SCHEDULED"
                                ? "default"
                                : event.status === "SOLD_OUT"
                                  ? "secondary"
                                  : "outline"
                            }
                          >
                            {event.status}
                          </Badge>
                        </div>
 
                        <div className="flex items-center gap-6 text-sm text-[var(--color-muted-foreground)]">
                          <span className="flex items-center gap-1.5">
                            <Calendar className="w-4 h-4" />
                            {formatDateShort(event.date)}
                          </span>
                          <span>{event.time}</span>
                          <span className="flex items-center gap-1.5">
                            <Users className="w-4 h-4" />
                            {event.bookedCount}/{event.actualCapacity} (
                            {percentage}%)
                          </span>
                          <span className="flex items-center gap-1.5">
                            <DollarSign className="w-4 h-4" />
                            {formatVND(event.dinnerPrice)}
                          </span>
                        </div>
                      </div>
 
                      <Button variant="ghost" size="sm" className="ml-4">
                        <ArrowRight className="w-4 h-4" />
                      </Button>
                    </div>
 
                    {/* Mini progress bar */}
                    <div className="mt-4 h-2 rounded-full bg-[var(--color-muted)]/50 overflow-hidden">
                      <div
                        className={cn(
                          "h-full rounded-full transition-all",
                          percentage < 20
                            ? "bg-red-500"
                            : percentage < 40
                              ? "bg-orange-500"
                              : percentage < 70
                                ? "bg-yellow-500"
                                : "bg-green-500",
                        )}
                        style={{ width: `${Math.min(percentage, 100)}%` }}
                      />
                    </div>
                  </Card>
                </Link>
              );
            })}
          </div>
        )}
      </div>
    </DashboardPage>
  );
}
  • Step 2: Add Events nav item to bottom nav

In apps/frontend/components/admin/dashboard-bottom-nav.tsx, add to navItems array:

{
  href: "/dashboard/events",
  navKey: "events",
  icon: Clapperboard,
  roles: ["ADMIN"] as Role[],
},

And add case:

case "events":
  return m.admin_nav_events?.() ?? "Events";
  • Step 3: Commit
git add apps/frontend/app/\[locale\]/dashboard/events/page.tsx apps/frontend/components/admin/dashboard-bottom-nav.tsx
git commit -m "feat(events): add events list dashboard page with navigation"

Task 3: Create Event Mission Control Page

Files:

  • Create: apps/frontend/app/[locale]/dashboard/events/[eventId]/page.tsx

  • Step 1: Create Mission Control page component

"use client";
 
import { useQuery } from "convex/react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@packages/backend/convex/_generated/api";
import { formatDateShort, formatShowTime } from "~/lib/utils/date";
import { formatVND } from "~/lib/utils/format-currency";
import { DashboardPage } from "~/components/admin/dashboard-page";
import { Card } from "~/components/ui/card";
import { Skeleton } from "~/components/ui/skeleton";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
  ArrowLeft,
  Users,
  DollarSign,
  CheckCircle,
  Clock,
  CreditCard,
  User,
  Phone,
  Mail,
  Ticket,
} from "lucide-react";
import * as m from "~/src/paraglide/messages";
import { cn } from "~/lib/utils";
 
type ReservationWithPayments = {
  _id: string;
  customerFirstName: string;
  customerLastName: string;
  customerEmail: string;
  customerPhone?: string;
  ticketType: "DINNER_THEATRE" | "SHOW_ONLY";
  guests: number;
  totalAmount: number;
  paymentStatus:
    | "PENDING"
    | "PAID"
    | "REFUNDED"
    | "FAILED"
    | "CANCELLED"
    | "REFUND_PENDING";
  status: string;
  token?: string;
  checkIn?: { checkedInAt: number };
  payments: Array<{
    amount: number;
    status: string;
    card?: string;
    vpcTransactionNo?: string;
  }>;
};
 
type EventStats = {
  totalReservations: number;
  paidCount: number;
  pendingCount: number;
  checkedInCount: number;
  totalGuests: number;
  totalRevenue: number;
  dinnerTickets: number;
  showOnlyTickets: number;
};
 
export default function EventMissionControlPage() {
  const params = useParams();
  const eventId = params.eventId as string;
 
  const event = useQuery(api.domains.events.getById, { eventId });
  const reservations = useQuery(api.domains.eventReservations.listByEvent, {
    eventId,
  }) as ReservationWithPayments[] | undefined;
  const stats = useQuery(api.domains.eventReservations.getStats, {
    eventId,
  }) as EventStats | undefined;
 
  if (!event) {
    return (
      <DashboardPage>
        <div className="space-y-4">
          <Skeleton className="h-9 w-48" />
          <Skeleton className="h-64 w-full" />
        </div>
      </DashboardPage>
    );
  }
 
  const percentage =
    event.actualCapacity > 0
      ? Math.round((event.bookedCount / event.actualCapacity) * 100)
      : 0;
 
  return (
    <DashboardPage>
      {/* Header */}
      <div className="flex items-center gap-4 mb-6">
        <Link href="/dashboard/events">
          <Button variant="ghost" size="sm">
            <ArrowLeft className="w-4 h-4 mr-2" />
            {m.admin_back?.() ?? "Back"}
          </Button>
        </Link>
        <div className="flex-1">
          <h1 className="text-2xl font-serif font-medium">
            {event.experienceTitle}
          </h1>
          <p className="text-sm text-[var(--color-muted-foreground)]">
            {formatDateShort(event.date)} • {formatShowTime(event.time)} •{" "}
            {event.code}
          </p>
        </div>
        <Badge
          variant={
            event.status === "SCHEDULED"
              ? "default"
              : event.status === "SOLD_OUT"
                ? "secondary"
                : "outline"
          }
        >
          {event.status}
        </Badge>
      </div>
 
      {/* Stats Grid */}
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
        <Card variant="glass" className="p-4">
          <div className="flex items-center gap-3">
            <div className="p-2 rounded-lg bg-[var(--color-gold)]/10">
              <Users className="w-5 h-5 text-[var(--color-gold)]" />
            </div>
            <div>
              <p className="text-xs text-[var(--color-muted-foreground)]">
                {m.admin_reservations_guests?.() ?? "Guests"}
              </p>
              <p className="text-xl font-medium">{stats?.totalGuests ?? 0}</p>
            </div>
          </div>
        </Card>
 
        <Card variant="glass" className="p-4">
          <div className="flex items-center gap-3">
            <div className="p-2 rounded-lg bg-green-500/10">
              <DollarSign className="w-5 h-5 text-green-500" />
            </div>
            <div>
              <p className="text-xs text-[var(--color-muted-foreground)]">
                {m.admin_revenue?.() ?? "Revenue"}
              </p>
              <p className="text-xl font-medium">
                {formatVND(stats?.totalRevenue ?? 0)}
              </p>
            </div>
          </div>
        </Card>
 
        <Card variant="glass" className="p-4">
          <div className="flex items-center gap-3">
            <div className="p-2 rounded-lg bg-blue-500/10">
              <CheckCircle className="w-5 h-5 text-blue-500" />
            </div>
            <div>
              <p className="text-xs text-[var(--color-muted-foreground)]">
                {m.admin_checkedIn?.() ?? "Checked In"}
              </p>
              <p className="text-xl font-medium">
                {stats?.checkedInCount ?? 0}/{stats?.totalGuests ?? 0}
              </p>
            </div>
          </div>
        </Card>
 
        <Card variant="glass" className="p-4">
          <div className="flex items-center gap-3">
            <div className="p-2 rounded-lg bg-orange-500/10">
              <Clock className="w-5 h-5 text-orange-500" />
            </div>
            <div>
              <p className="text-xs text-[var(--color-muted-foreground)]">
                {m.admin_pending?.() ?? "Pending"}
              </p>
              <p className="text-xl font-medium">{stats?.pendingCount ?? 0}</p>
            </div>
          </div>
        </Card>
      </div>
 
      {/* Capacity Bar */}
      <Card variant="glass" className="p-4 mb-6">
        <div className="flex items-center justify-between mb-2">
          <span className="text-sm font-medium">
            {m.admin_capacity?.() ?? "Booking Capacity"}
          </span>
          <span className="text-sm text-[var(--color-muted-foreground)]">
            {event.bookedCount}/{event.actualCapacity} ({percentage}%)
          </span>
        </div>
        <div className="h-3 rounded-full bg-[var(--color-muted)]/50 overflow-hidden">
          <div
            className={cn(
              "h-full rounded-full transition-all",
              percentage < 20
                ? "bg-red-500"
                : percentage < 40
                  ? "bg-orange-500"
                  : percentage < 70
                    ? "bg-yellow-500"
                    : "bg-green-500",
            )}
            style={{ width: `${Math.min(percentage, 100)}%` }}
          />
        </div>
      </Card>
 
      {/* Reservations List */}
      <div className="mb-4 flex items-center justify-between">
        <h2 className="text-lg font-medium">
          {m.admin_reservations?.() ?? "Reservations"}
        </h2>
        <span className="text-sm text-[var(--color-muted-foreground)]">
          {reservations?.length ?? 0} {m.admin_total?.() ?? "total"}
        </span>
      </div>
 
      {reservations === undefined ? (
        <div className="space-y-3">
          <Skeleton className="h-24 w-full" />
          <Skeleton className="h-24 w-full" />
        </div>
      ) : reservations.length === 0 ? (
        <Card variant="glass" className="p-8 text-center">
          <p className="text-[var(--color-muted-foreground)]">
            {m.admin_noReservations?.() ?? "No reservations yet"}
          </p>
        </Card>
      ) : (
        <div className="space-y-3">
          {reservations.map((res) => (
            <Card key={res._id} variant="glass" className="p-4">
              <div className="flex items-start justify-between">
                <div className="flex-1">
                  <div className="flex items-center gap-3 mb-2">
                    <div className="flex items-center gap-2">
                      <User className="w-4 h-4 text-[var(--color-muted-foreground)]" />
                      <span className="font-medium">
                        {res.customerFirstName} {res.customerLastName}
                      </span>
                    </div>
                    <Badge
                      variant={
                        res.paymentStatus === "PAID"
                          ? "default"
                          : res.paymentStatus === "PENDING"
                            ? "secondary"
                            : res.paymentStatus === "REFUNDED"
                              ? "outline"
                              : "destructive"
                      }
                    >
                      {res.paymentStatus}
                    </Badge>
                    {res.checkIn && (
                      <Badge variant="default" className="bg-green-500">
                        <CheckCircle className="w-3 h-3 mr-1" />
                        Checked In
                      </Badge>
                    )}
                  </div>
 
                  <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
                    <div className="flex items-center gap-1.5 text-[var(--color-muted-foreground)]">
                      <Mail className="w-3.5 h-3.5" />
                      <span className="truncate">{res.customerEmail}</span>
                    </div>
                    {res.customerPhone && (
                      <div className="flex items-center gap-1.5 text-[var(--color-muted-foreground)]">
                        <Phone className="w-3.5 h-3.5" />
                        <span>{res.customerPhone}</span>
                      </div>
                    )}
                    <div className="flex items-center gap-1.5 text-[var(--color-muted-foreground)]">
                      <Ticket className="w-3.5 h-3.5" />
                      <span>
                        {res.ticketType === "DINNER_THEATRE"
                          ? "Dinner + Show"
                          : "Show Only"}
                      </span>
                    </div>
                    <div className="flex items-center gap-1.5">
                      <Users className="w-3.5 h-3.5 text-[var(--color-muted-foreground)]" />
                      <span>
                        {res.guests} {m.admin_guests?.() ?? "guests"}
                      </span>
                    </div>
                  </div>
                </div>
 
                <div className="text-right ml-4">
                  <p className="text-lg font-medium">
                    {formatVND(res.totalAmount)}
                  </p>
                  {res.payments[0] && (
                    <p className="text-xs text-[var(--color-muted-foreground)]">
                      {res.payments[0].card ?? "N/A"}
                    </p>
                  )}
                </div>
              </div>
            </Card>
          ))}
        </div>
      )}
    </DashboardPage>
  );
}
  • Step 2: Commit
git add apps/frontend/app/\[locale\]/dashboard/events/\[eventId\]/page.tsx
git commit -m "feat(events): add event mission control page with full participant and payment details"

Task 4: Add i18n keys (if missing)

Files:

  • Modify: apps/frontend/messages/*.json (or paraglide messages file)

Add these keys if they don't exist:

{
  "admin_events_title": "Events Dashboard",
  "admin_events_subtitle": "Mission control for all events",
  "admin_events_noEvents": "No events found",
  "admin_events_title": "Events",
  "admin_back": "Back",
  "admin_reservations_guests": "Guests",
  "admin_revenue": "Revenue",
  "admin_checkedIn": "Checked In",
  "admin_pending": "Pending",
  "admin_capacity": "Booking Capacity",
  "admin_reservations": "Reservations",
  "admin_total": "total",
  "admin_noReservations": "No reservations yet",
  "admin_guests": "guests"
}
  • Step 1: Commit
git add apps/frontend/messages/*.json
git commit -m "i18n: add events dashboard translations"

Self-Review Checklist

  1. Spec coverage:

    • Events list page shows all events with key stats
    • Clicking event navigates to mission control
    • Mission control shows event details, stats, bookings
    • Reservations show participant info, payment status, check-in status
    • Color-coded capacity indicator
  2. Placeholder scan: No "TBD", "TODO", or placeholder content found

  3. Type consistency: All types match the Convex schema definitions

  4. Missing features identified:

    • Filter/sort on events list (can be added as enhancement)
    • Search reservations on mission control (can be added as enhancement)