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, andcheckInstables
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 detailsTask 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
-
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
-
Placeholder scan: No "TBD", "TODO", or placeholder content found
-
Type consistency: All types match the Convex schema definitions
-
Missing features identified:
- Filter/sort on events list (can be added as enhancement)
- Search reservations on mission control (can be added as enhancement)