Admin Dashboard Implementation Plan
Spec file: No dedicated spec — overlaps with
docs/superpowers/specs/03-admin-backoffice.mdFor 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: Build the proprietary admin dashboard for Hamza — the single point of control for show management, occurrence generation, reservation handling, and analytics. This replaces the WordPress admin and all manual operations.
Architecture: Separate admin route group at /admin/* with its own layout. Role-based access: ADMIN (full access) and STAFF (limited). Convex Auth via Clerk. Real-time data via Convex subscriptions — Hamza sees live availability without refreshing.
Tech Stack: Next.js 16 App Router, Convex (queries + mutations), Tailwind CSS v4, Radix UI for modals/dialogs, Recharts for analytics charts.
[P0 GAP: staffMutation and adminMutation not yet implemented in convex/auth.ts]: The
staffMutationandadminMutationhelpers do NOT currently exist inconvex/auth.ts— they must be implemented in foundation-plan first. This plan correctly usesmutation()with inline auth check as a workaround until foundation-plan implements them. The P0 GAP is noted in each affected step.
[P1 OVERLAP NOTE]: This plan overlaps significantly with
admin-backoffice-plan.md. Both describe the same/admin/*route group with sidebar navigation and dashboard widgets. Implement THIS plan first (core admin shell), then extend it with the backoffice-specific features in admin-backoffice-plan rather than building two separate surfaces. Consolidate to a single/admin/*route group.
Pricing Context:
- Analytics dashboard shows revenue per show (based on PAID reservation
totalAmount) - Average cart value = sum of
totalAmount/ count of PAID reservations - Occupancy rate =
bookedCount / actualCapacityper occurrence
Business Summary
What this does: Builds the core admin dashboard shell for Hamza — a single control point to view business health metrics, manage show templates, generate occurrence schedules, handle reservations, and configure add-ons. The dashboard shows real-time KPIs (occupancy rate, revenue, avg cart value, upsell rate) and highlights at-risk shows that need attention.
Why it matters: Hamza currently manages everything through WordPress admin and manual processes. This gives him a purpose-built, real-time dashboard to make faster decisions. Automated D-1 rules reduce manual monitoring, while batch occurrence generation eliminates repetitive calendar work.
Time to implement: 3-5 days | Complexity: Medium
Dependencies: foundation-plan (for auth helpers, schema foundations)
Context & Business Logic
The admin dashboard is for Hamza only (initially) — non-indexed URL, strong auth. Staff may get limited access later (view-only for reservations).
Access control:
| Feature | Admin | Staff |
|---|---|---|
| Dashboard + Analytics | Yes | No |
| Show template CRUD | Yes | No |
| Batch occurrence generation | Yes | No |
| Occurrence override | Yes | Yes (limited) |
| Reservation view/cancel/refund | Yes | Yes (view) |
| Add-ons CRUD | Yes | No |
| CSV export | Yes | No |
D-1 Rule (automated): At midnight before a show date, if occupancy < 50% AND show-only not enabled AND show-only price configured → auto-enable show-only tickets and notify Hamza via email + WhatsApp.
Calendar color coding:
- Green: >75% filled
- Orange: 30-75% filled
- Red: <30% filled (action needed)
- Grey: CANCELLED
File Map
apps/frontend/
├── app/admin/
│ ├── layout.tsx # Admin layout with sidebar (CREATE)
│ ├── page.tsx # Dashboard overview (CREATE)
│ ├── shows/
│ │ └── page.tsx # Show templates list + edit (nuqs — NOT [id] segment)
│ ├── occurrences/
│ │ └── page.tsx # Calendar view (CREATE)
│ ├── reservations/
│ │ └── page.tsx # Reservation list (CREATE)
│ ├── addons/
│ │ └── page.tsx # Add-ons library (CREATE)
│ └── analytics/
│ └── page.tsx # Analytics dashboard (CREATE)
├── components/admin/
│ ├── sidebar.tsx # Admin sidebar nav (CREATE)
│ ├── metric-card.tsx # Metric card component (CREATE)
│ ├── at-risk-list.tsx # At-risk occurrences list (CREATE)
│ └── recent-reservations.tsx # Recent reservations table (CREATE)
└── lib/
└── admin/
└── access-control.ts # Role check utilities (CREATE)
convex/
├── functions/
│ ├── shows.ts # Extend with admin mutations (MODIFY)
│ ├── occurrences.ts # Extend with batch generation (MODIFY)
│ ├── reservations.ts # Extend with admin queries (MODIFY)
│ ├── addons.ts # Extend with admin mutations (MODIFY)
│ └── analytics.ts # CREATE — analytics aggregation queries (CREATE)
└── auth.ts # Auth configuration (VERIFY — staffMutation needed from foundation-plan)Phase 1: Admin Auth & Layout
Task 1: Set Up Admin Auth with Convex/Clerk
Files:
-
Verify:
convex/auth.tsexists and is configured -
Modify:
apps/frontend/middleware.ts— add admin route protection -
Create:
apps/frontend/app/admin/layout.tsx -
Step 1: Read existing auth setup
cat convex/auth.ts
cat apps/frontend/middleware.ts- Step 2: Ensure Clerk is configured for admin routes
In middleware.ts, add:
import { authMiddleware } from "@clerk/nextjs/server";
export default authMiddleware({
publicRoutes: ["/", "/programme", "/shows"],
adminRoutes: ["/admin/:path*"],
});
export const config = {
matcher: ["/((?!.+\\.[\\w]+$|_next).*)"],
};- Step 3: Create admin layout with sidebar
[P1 Fix]: The role must be fetched from Clerk's
publicMetadata, not hardcoded.
// apps/frontend/app/admin/layout.tsx
import { redirect } from "next/navigation";
import { auth } from "@clerk/nextjs/server";
import { currentUser } from "@clerk/nextjs/server";
import { Sidebar } from "~/components/admin/sidebar";
import { getTranslations } from "next-intl/server";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const { userId } = await auth();
if (!userId) redirect("/sign-in");
// [P1 Fix]: Fetch role from Clerk publicMetadata
const user = await currentUser();
const role = (user?.publicMetadata?.role as "ADMIN" | "STAFF") ?? "STAFF";
return (
<div className="flex min-h-screen bg-[#1a1a1a]">
<Sidebar role={role} />
<main className="flex-1 p-6 overflow-auto">{children}</main>
</div>
);
}- Step 4: Create admin sidebar
[P1 Fix]: Use
IconSymbolcomponent, not emoji characters.
// apps/frontend/components/admin/sidebar.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
const navItems = [
{ href: "/admin", labelKey: "dashboard", icon: "chart.bar.fill", roles: ["ADMIN"] },
{ href: "/admin/shows", labelKey: "shows", icon: "theatermasks.fill", roles: ["ADMIN"] },
{ href: "/admin/occurrences", labelKey: "calendar", icon: "calendar", roles: ["ADMIN", "STAFF"] },
{ href: "/admin/reservations", labelKey: "reservations", icon: "ticket.fill", roles: ["ADMIN", "STAFF"] },
{ href: "/admin/addons", labelKey: "addons", icon: "plus.circle.fill", roles: ["ADMIN"] },
{ href: "/admin/analytics", labelKey: "analytics", icon: "chart.line.uptrend.xyaxis", roles: ["ADMIN"] },
];
export function Sidebar({ role }: { role: "ADMIN" | "STAFF" }) {
const pathname = usePathname();
const t = useTranslations("admin.nav");
const visible = navItems.filter((item) => item.roles.includes(role));
return (
<aside className="w-64 bg-[#1a1a1a] border-r border-[#333333] p-4">
<h1 className="text-[#C5A059] font-serif text-xl mb-8">
{t("holAdmin")}
</h1>
<nav className="space-y-1">
{visible.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-[#C5A059]/10 ${
pathname === item.href ? "text-[#C5A059] bg-[#C5A059]/10" : "text-[#808080]"
}`}
>
<IconSymbol name={item.icon} size={18} className="shrink-0" />
<span>{t(item.labelKey)}</span>
</Link>
))}
</nav>
</aside>
);
}- Step 5: Commit
git add apps/frontend/app/admin/layout.tsx apps/frontend/components/admin/sidebar.tsx
git commit -m "feat(admin): add admin layout with auth and sidebar"Phase 2: Dashboard Overview
Task 2: Create Admin Dashboard Page
Files:
-
Create:
apps/frontend/app/admin/page.tsx -
Create:
apps/frontend/components/admin/metric-card.tsx -
Create:
apps/frontend/components/admin/at-risk-list.tsx -
Create:
apps/frontend/components/admin/recent-reservations.tsx -
Step 1: Create dashboard page with key metrics
// apps/frontend/app/admin/page.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { MetricCard } from "~/components/admin/metric-card";
import { AtRiskList } from "~/components/admin/at-risk-list";
import { RecentReservations } from "~/components/admin/recent-reservations";
import { useTranslations } from "next-intl";
import { Suspense } from "react";
export default function AdminDashboard() {
const t = useTranslations("admin.dashboard");
const occupancyRate = useQuery(api.analytics.averageOccupancyLast30Days, {});
const revenueData = useQuery(api.analytics.revenueLast30Days, {});
const avgCartValue = useQuery(api.analytics.averageCartValue, {});
const topShows = useQuery(api.analytics.topShowsByRevenue, { limit: 3 });
const atRiskOccurrences = useQuery(api.analytics.atRiskOccurrences, { daysOut: 7 });
const recentReservations = useQuery(api.reservations.listPaginated, {
occurrenceId: undefined,
paymentStatus: undefined,
emailSearch: undefined,
cursor: undefined,
limit: 10,
});
const upsellRate = useQuery(api.analytics.upsellRate, {});
return (
<div className="space-y-8">
<h1 className="text-3xl font-serif text-[#C5A059]">{t("title")}</h1>
{/* Metric cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title={t("avgOccupancy")}
value={`${occupancyRate ?? 0}%`}
trend="up"
icon="chart.pie.fill"
/>
<MetricCard
title={t("revenue30d")}
value={`${((revenueData ?? 0) / 1_000_000).toFixed(1)}M`}
trend="up"
icon="dollarsign.circle.fill"
/>
<MetricCard
title={t("avgCartValue")}
value={`${avgCartValue ?? 0} VND`}
trend="neutral"
icon="cart.fill"
/>
<MetricCard
title={t("upsellRate")}
value={`${upsellRate ?? 0}%`}
trend="up"
icon="arrow.up.circle.fill"
/>
</div>
{/* At-risk occurrences */}
<Suspense fallback={<div className="h-48 bg-[#1a1a1a] animate-pulse rounded-lg" />}>
<AtRiskList occurrences={atRiskOccurrences ?? []} />
</Suspense>
{/* Recent reservations */}
<Suspense fallback={<div className="h-48 bg-[#1a1a1a] animate-pulse rounded-lg" />}>
<RecentReservations reservations={recentReservations?.reservations ?? []} />
</Suspense>
</div>
);
}- Step 2: Create metric card component
// apps/frontend/components/admin/metric-card.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
interface MetricCardProps {
title: string;
value: string | number;
trend?: "up" | "down" | "neutral";
icon: string;
}
export function MetricCard({ title, value, trend = "neutral", icon }: MetricCardProps) {
const trendColor = {
up: "text-green-400",
down: "text-red-400",
neutral: "text-[#808080]",
};
return (
<div className="bg-[#1a1a1a] border border-[#333333] p-4 rounded-lg">
<div className="flex items-center justify-between mb-2">
<p className="text-sm text-[#808080]">{title}</p>
<IconSymbol name={icon} size={20} className="text-[#C5A059]" />
</div>
<p className="text-3xl font-serif text-[#e6e6e6] mt-1">{value}</p>
{trend !== "neutral" && (
<p className={`text-xs mt-1 ${trendColor[trend]}`}>
{trend === "up" ? "Up" : "Down"}
</p>
)}
</div>
);
}- Step 3: Create at-risk list component
// apps/frontend/components/admin/at-risk-list.tsx
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
interface AtRiskOccurrence {
_id: string;
showTitle: string;
date: string;
occupancyPercent: number;
}
interface AtRiskListProps {
occurrences: AtRiskOccurrence[];
}
export function AtRiskList({ occurrences }: AtRiskListProps) {
const t = useTranslations("admin.dashboard");
return (
<section>
<h2 className="text-xl font-serif text-[#C5A059] mb-4">{t("atRiskShows")}</h2>
<div className="bg-[#1a1a1a] border border-[#333333] rounded-lg overflow-hidden">
{occurrences.length === 0 ? (
<p className="p-4 text-[#808080] flex items-center gap-2">
<IconSymbol name="checkmark.circle.fill" size={16} className="text-green-400" />
{t("allHealthy")}
</p>
) : (
<table className="w-full">
<thead className="border-b border-[#333333]">
<tr className="text-left text-sm text-[#808080]">
<th className="p-3">{t("show")}</th>
<th className="p-3">{t("date")}</th>
<th className="p-3">{t("occupancy")}</th>
<th className="p-3">{t("action")}</th>
</tr>
</thead>
<tbody>
{occurrences.map((occ) => (
<tr key={occ._id} className="border-b border-[#333333]">
<td className="p-3">{occ.showTitle}</td>
<td className="p-3">{occ.date}</td>
<td className="p-3 text-red-400">{occ.occupancyPercent}%</td>
<td className="p-3">
<Link
href={`/admin/occurrences?selectedId=${occ._id}`}
className="text-[#C5A059] underline flex items-center gap-1"
>
<IconSymbol name="pencil" size={14} />
{t("edit")}
</Link>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}- Step 4: Create recent reservations component
// apps/frontend/components/admin/recent-reservations.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
interface RecentReservation {
_id: string;
customerFirstName: string;
customerLastName: string;
showTitle: string;
paymentStatus: string;
totalAmount: number;
}
interface RecentReservationsProps {
reservations: RecentReservation[];
}
export function RecentReservations({ reservations }: RecentReservationsProps) {
const t = useTranslations("admin.dashboard");
return (
<section>
<h2 className="text-xl font-serif text-[#C5A059] mb-4">{t("recentReservations")}</h2>
<div className="bg-[#1a1a1a] border border-[#333333] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-[#333333]">
<tr className="text-left text-[#808080]">
<th className="p-3">{t("id")}</th>
<th className="p-3">{t("customer")}</th>
<th className="p-3">{t("show")}</th>
<th className="p-3">{t("status")}</th>
<th className="p-3">{t("total")}</th>
</tr>
</thead>
<tbody>
{reservations.map((res) => (
<tr key={res._id} className="border-b border-[#333333]">
<td className="p-3 font-mono text-xs">{res._id.slice(0, 8)}</td>
<td className="p-3">
{res.customerFirstName} {res.customerLastName}
</td>
<td className="p-3">{res.showTitle}</td>
<td className="p-3">
<span
className={`px-2 py-1 rounded text-xs flex items-center gap-1 w-fit ${
res.paymentStatus === "PAID"
? "bg-green-900 text-green-400"
: res.paymentStatus === "PENDING"
? "bg-yellow-900 text-yellow-400"
: "bg-red-900 text-red-400"
}`}
>
<IconSymbol
name={
res.paymentStatus === "PAID"
? "checkmark.circle.fill"
: res.paymentStatus === "PENDING"
? "clock.fill"
: "xmark.circle.fill"
}
size={12}
/>
{t(`status.${res.paymentStatus.toLowerCase()}`)}
</span>
</td>
<td className="p-3">{res.totalAmount.toLocaleString()} VND</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}- Step 5: Add analytics queries to Convex
[P1 PERFORMANCE FIX]: Analytics queries MUST use indexes. The current implementation does full collection scans. Add the required indexes and optimize queries.
Create convex/functions/analytics.ts:
// convex/functions/analytics.ts
import { query } from "../_generated/server";
import { v } from "convex/values";
import { consola } from "consola";
export const averageOccupancyLast30Days = query({
args: {},
handler: async (ctx) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const cutoff = thirtyDaysAgo.toISOString().split("T")[0];
// [P1 PERFORMANCE FIX]: Use by_date index to filter before collect()
const occurrences = await ctx.db
.query("showOccurrences")
.withIndex("by_date")
.collect();
const filtered = occurrences.filter((o) => o.date >= cutoff);
if (filtered.length === 0) return 0;
const totalOccupancy = filtered.reduce(
(sum, occ) => sum + (occ.bookedCount / occ.actualCapacity) * 100,
0,
);
return Math.round(totalOccupancy / filtered.length);
},
});
export const revenueLast30Days = query({
args: {},
handler: async (ctx) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const cutoff = thirtyDaysAgo.getTime();
// [P1 PERFORMANCE FIX]: Use by_paymentStatus index
const reservations = await ctx.db
.query("reservations")
.withIndex("by_paymentStatus")
.collect();
return reservations
.filter((r) => r.paymentStatus === "PAID" && r.createdAt >= cutoff)
.reduce((sum, r) => sum + r.totalAmount, 0);
},
});
export const topShowsByRevenue = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit = 5 }) => {
// [P1 PERFORMANCE FIX]: Use by_paymentStatus index
const reservations = await ctx.db
.query("reservations")
.withIndex("by_paymentStatus")
.collect();
const paid = reservations.filter((r) => r.paymentStatus === "PAID");
const byShow: Record<string, number> = {};
for (const res of paid) {
byShow[res.showTitle] = (byShow[res.showTitle] ?? 0) + res.totalAmount;
}
return Object.entries(byShow)
.sort(([, a], [, b]) => b - a)
.slice(0, limit)
.map(([showTitle, revenue]) => ({ showTitle, revenue }));
},
});
export const atRiskOccurrences = query({
args: { daysOut: v.number() },
handler: async (ctx, { daysOut }) => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysOut);
const cutoff = futureDate.toISOString().split("T")[0];
// [P1 PERFORMANCE FIX]: Use by_date index
const occurrences = await ctx.db
.query("showOccurrences")
.withIndex("by_date")
.collect();
const futureAndScheduled = occurrences.filter(
(o) => o.date <= cutoff && o.status === "SCHEDULED",
);
return futureAndScheduled
.filter((occ) => occ.bookedCount / occ.actualCapacity < 0.3)
.slice(0, 10)
.map((occ) => ({
_id: occ._id,
showTitle: occ.showTitle ?? "Unknown Show",
date: occ.date,
occupancyPercent: Math.round(
(occ.bookedCount / occ.actualCapacity) * 100,
),
}));
},
});
export const upsellRate = query({
args: {},
handler: async (ctx) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const cutoff = thirtyDaysAgo.getTime();
// [P1 PERFORMANCE FIX]: Use by_paymentStatus index
const reservations = await ctx.db
.query("reservations")
.withIndex("by_paymentStatus")
.collect();
const paid = reservations.filter(
(r) => r.paymentStatus === "PAID" && r.createdAt >= cutoff,
);
if (paid.length === 0) return 0;
const withAddons = paid.filter((r) => (r.addons?.length ?? 0) > 0);
return Math.round((withAddons.length / paid.length) * 100);
},
});
export const averageCartValue = query({
args: {},
handler: async (ctx) => {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const cutoff = thirtyDaysAgo.getTime();
// [P1 PERFORMANCE FIX]: Use by_paymentStatus index
const reservations = await ctx.db
.query("reservations")
.withIndex("by_paymentStatus")
.collect();
const paid = reservations.filter(
(r) => r.paymentStatus === "PAID" && r.createdAt >= cutoff,
);
if (paid.length === 0) return 0;
const total = paid.reduce((sum, r) => sum + r.totalAmount, 0);
return Math.round(total / paid.length);
},
});- Step 6: Add required indexes to schema
The analytics queries above require these indexes. Ensure they are added to convex/schema.ts:
// showOccurrences — add to existing table definition
.index("by_date", ["date"])
// reservations — add to existing table definition
.index("by_paymentStatus", ["paymentStatus"])- Step 7: Commit
Phase 3: Show Templates CRUD
Task 3: Show Templates Management
Files:
-
Create:
apps/frontend/app/admin/shows/page.tsx -
Create:
apps/frontend/components/admin/show-form.tsx -
Modify:
convex/functions/shows.ts— add more mutations -
Step 1: Read existing
convex/functions/shows.ts -
Step 2: Add
listAllquery (including DRAFT/ARCHIVED)
The existing listActive only returns ACTIVE. Admin needs to see all.
export const listAll = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("showTemplates").collect();
},
});- Step 3: Create shows list page
[P0 CRITICAL]: Use
nuqsuseQueryStatefor edit modals, NOT dynamic URL segments like[id].
// apps/frontend/app/admin/shows/page.tsx
"use client";
import { useQueryState } from "nuqs"; // [P0 Fix]: Use nuqs, not [id] segments
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
export default function AdminShowsPage() {
const t = useTranslations("admin.shows");
const shows = useQuery(api.shows.listAll);
const [selectedId, setSelectedId] = useQueryState("selectedId", { defaultValue: "" });
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-serif text-[#C5A059]">{t("title")}</h1>
<button
onClick={() => setSelectedId("new")}
className="px-4 py-2 bg-[#C5A059] text-black font-bold rounded-lg flex items-center gap-2"
>
<IconSymbol name="plus" size={16} />
{t("createShow")}
</button>
</div>
<div className="bg-[#1a1a1a] border border-[#333333] rounded-lg overflow-hidden">
<table className="w-full">
<thead className="border-b border-[#333333] bg-[#808080]/10">
<tr className="text-left text-sm text-[#808080]">
<th className="p-3">{t("col.title")}</th>
<th className="p-3">{t("col.slug")}</th>
<th className="p-3">{t("col.status")}</th>
<th className="p-3">{t("col.price")}</th>
<th className="p-3">{t("col.actions")}</th>
</tr>
</thead>
<tbody>
{shows?.map((show) => (
<tr key={show._id} className="border-b border-[#333333]">
<td className="p-3 font-medium text-[#e6e6e6]">{show.title}</td>
<td className="p-3 text-[#808080]">{show.slug}</td>
<td className="p-3">
<span className={`px-2 py-1 rounded text-xs ${
show.status === "ACTIVE"
? "bg-green-900 text-green-400"
: show.status === "DRAFT"
? "bg-yellow-900 text-yellow-400"
: "bg-[#333333] text-[#808080]"
}`}>{show.status}</span>
</td>
<td className="p-3 text-[#808080]">{show.defaultDinnerPrice.toLocaleString()} VND</td>
<td className="p-3">
<button
onClick={() => setSelectedId(show._id)}
className="text-[#C5A059] underline flex items-center gap-1"
>
<IconSymbol name="pencil" size={14} />
{t("edit")}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedId && (
<ShowForm
showId={selectedId === "new" ? undefined : selectedId}
onClose={() => setSelectedId("")}
/>
)}
</div>
);
}- Step 4: Create show form component (create/edit)
[P0 Fix]: Use Zod
safeParse()instead ofas anytype assertion.
// apps/frontend/components/admin/show-form.tsx
"use client";
import { useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { showTemplateSchema } from "~/lib/schemas/admin-show"; // [P0 Fix]: Zod validation
import { IconSymbol } from "~/components/ui/iconSymbol";
interface ShowFormProps {
showId?: string;
onClose: () => void;
}
export function ShowForm({ showId, onClose }: ShowFormProps) {
const t = useTranslations("admin.shows.form");
const [isPending, startTransition] = useTransition();
const createShow = useMutation(api.shows.create);
const updateShow = useMutation(api.shows.update);
const handleSubmit = async (data: unknown) => {
// [P0 Fix]: Zod parse instead of as any
const parsed = showTemplateSchema.safeParse(data);
if (!parsed.success) {
throw new Error("Validation failed");
}
startTransition(async () => {
if (showId) {
await updateShow({ id: showId, ...parsed.data });
} else {
await createShow(parsed.data);
}
onClose();
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[#1a1a1a] border border-[#333333] p-6 rounded-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-serif text-[#e6e6e6]">
{showId ? t("editTitle") : t("createTitle")}
</h2>
<button onClick={onClose} className="text-[#808080] hover:text-[#e6e6e6]">
<IconSymbol name="xmark" size={20} />
</button>
</div>
{/* Form fields */}
</div>
</div>
);
}- Step 5: Commit
Phase 4: Occurrence Calendar & Batch Generation
Task 4: Calendar View + Batch Occurrence Generation
Files:
- Create:
apps/frontend/app/admin/occurrences/page.tsx— monthly calendar - Create:
apps/frontend/components/admin/calendar-grid.tsx - Create:
apps/frontend/components/admin/occurrence-modal.tsx - Modify:
convex/functions/occurrences.ts— addgenerateBatchmutation
This is the most operationally critical feature for Hamza.
- Step 1: Create
generateBatchmutation in Convex
[P1 Fix]:
v.id()validators must be used for ID fields. Do NOT usev.string()for ID args.
// convex/functions/occurrences.ts — ADD generateBatch mutation
export const generateBatch = mutation({
args: {
templateId: v.id("showTemplates"), // [P1 Fix]: Use v.id()
daysOfWeek: v.array(v.number()), // 0=Sun, 1=Mon, ... 6=Sat
time: v.string(), // "19:30"
startDate: v.string(), // "2026-05-01"
endDate: v.string(), // "2026-06-30"
actualCapacity: v.optional(v.number()),
dinnerPriceOverride: v.optional(v.number()),
showOnlyPriceOverride: v.optional(v.number()),
showOnlyEnabled: v.boolean(),
},
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) throw new AppError("OCC_NOT_FOUND", "Template not found");
const defaultCapacity = args.actualCapacity ?? template.defaultCapacity;
const generated = [];
const current = new Date(args.startDate);
const end = new Date(args.endDate);
while (current <= end) {
const dayOfWeek = current.getDay();
if (args.daysOfWeek.includes(dayOfWeek)) {
const dateStr = current.toISOString().split("T")[0];
const id = await ctx.db.insert("showOccurrences", {
templateId: args.templateId,
date: dateStr,
time: args.time,
actualCapacity: defaultCapacity,
dinnerPriceOverride: args.dinnerPriceOverride,
showOnlyPriceOverride: args.showOnlyPriceOverride,
showOnlyEnabled: args.showOnlyEnabled,
status: "SCHEDULED",
bookedCount: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
});
generated.push(id);
}
current.setDate(current.getDate() + 1);
}
return { count: generated.length, ids: generated };
},
});- Step 2: Create calendar grid component
// apps/frontend/components/admin/calendar-grid.tsx
"use client";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
type CalendarOccurrence = {
_id: string;
showTitle: string;
bookedCount: number;
actualCapacity: number;
status: "SCHEDULED" | "CANCELLED" | "SOLD_OUT";
};
type CalendarDay = {
date: string; // "2026-05-01"
occurrences: CalendarOccurrence[];
fillPercent: number;
colorClass: "green" | "orange" | "red" | "gray";
};
interface CalendarGridProps {
days: CalendarDay[];
onDayClick: (date: string) => void;
}
export function CalendarGrid({ days, onDayClick }: CalendarGridProps) {
const t = useTranslations("admin.calendar");
const colorMap = {
green: "bg-green-500",
orange: "bg-orange-500",
red: "bg-red-500",
gray: "bg-[#808080]",
};
return (
<div className="grid grid-cols-7 gap-1">
{/* Day headers */}
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
<div key={d} className="text-center text-xs text-[#808080] p-2">
{t(`days.${d.toLowerCase()}`)}
</div>
))}
{/* Day cells */}
{days.map((day) => (
<button
key={day.date}
onClick={() => onDayClick(day.date)}
className="min-h-[80px] border border-[#333333] rounded p-1 text-left hover:bg-[#1a1a1a] transition-colors"
>
<span className="text-xs text-[#808080]">{day.date.split("-")[2]}</span>
<div className="mt-1 space-y-1">
{day.occurrences.slice(0, 3).map((occ) => (
<div
key={occ._id}
className={`text-xs px-1 rounded text-white truncate flex items-center gap-1 ${colorMap[day.colorClass]}`}
>
<IconSymbol name="ticket.fill" size={10} />
{occ.showTitle}
</div>
))}
{day.occurrences.length > 3 && (
<div className="text-xs text-[#808080]">+{day.occurrences.length - 3}</div>
)}
</div>
</button>
))}
</div>
);
}- Step 3: Create occurrence modal
Shows:
-
Show title, date, time, status
-
Capacity: default display + override field
-
Pricing: dinner + show-only overrides
-
Show-only toggle
-
Actions: Save, Cancel Occurrence, Mark Sold Out, View Bookings
-
Step 4: Commit
Phase 5: Reservations Management
Task 5: Reservation List + Detail
Files:
- Create:
apps/frontend/app/admin/reservations/page.tsx - Modify:
convex/functions/reservations.ts— add pagination and filters
[P0 CRITICAL]: Reservation filters MUST use
useQueryStatefromnuqsfor URL state — NOTuseState+ separate URL params.
- Step 1: Add paginated
listPaginatedquery with filters
// convex/functions/reservations.ts
export const listPaginated = query({
args: {
occurrenceId: v.optional(v.id("showOccurrences")),
paymentStatus: v.optional(v.string()),
emailSearch: v.optional(v.string()),
cursor: v.optional(v.id("reservations")),
limit: v.number(),
},
handler: async (ctx, args) => {
let results = await ctx.db.query("reservations").collect();
if (args.occurrenceId) {
results = results.filter((r) => r.occurrenceId === args.occurrenceId);
}
if (args.paymentStatus) {
results = results.filter((r) => r.paymentStatus === args.paymentStatus);
}
if (args.emailSearch) {
results = results.filter((r) =>
r.customerEmail.toLowerCase().includes(args.emailSearch!.toLowerCase()),
);
}
results.sort((a, b) => b.createdAt - a.createdAt);
// Paginate
const startIndex = args.cursor
? results.findIndex((r) => r._id === args.cursor) + 1
: 0;
const page = results.slice(startIndex, startIndex + args.limit);
return {
reservations: page,
nextCursor: page.length === args.limit ? page[page.length - 1]._id : null,
};
},
});- Step 2: Create reservation list page with filters
// apps/frontend/app/admin/reservations/page.tsx
"use client";
import { useQueryState } from "nuqs"; // [P0 Fix]: Use nuqs
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { IconSymbol } from "~/components/ui/iconSymbol";
export default function AdminReservationsPage() {
const t = useTranslations("admin.reservations");
const [isPending, startTransition] = useTransition();
const [date, setDate] = useQueryState("date", { defaultValue: "" });
const [status, setStatus] = useQueryState("status", { defaultValue: "" });
const [search, setSearch] = useQueryState("search", { defaultValue: "" });
const { reservations, nextCursor } = useQuery(
api.reservations.listPaginated,
{
occurrenceId: undefined,
paymentStatus: status || undefined,
emailSearch: search || undefined,
cursor: undefined,
limit: 20,
}
) ?? { reservations: [], nextCursor: undefined };
return (
<div>
{/* Filters */}
<div className="flex gap-4 mb-6 flex-wrap">
<input
type="date"
value={date}
onChange={(e) => startTransition(() => setDate(e.target.value))}
className="bg-[#1a1a1a] border border-[#333333] rounded-lg px-3 py-2 text-[#e6e6e6]"
/>
<select
value={status}
onChange={(e) => startTransition(() => setStatus(e.target.value))}
className="bg-[#1a1a1a] border border-[#333333] rounded-lg px-3 py-2 text-[#e6e6e6]"
>
<option value="">{t("allStatuses")}</option>
<option value="PENDING">{t("status.pending")}</option>
<option value="PAID">{t("status.paid")}</option>
<option value="CANCELLED">{t("status.cancelled")}</option>
<option value="REFUNDED">{t("status.refunded")}</option>
</select>
<input
type="search"
placeholder={t("searchPlaceholder")}
value={search}
onChange={(e) => startTransition(() => setSearch(e.target.value))}
className="bg-[#1a1a1a] border border-[#333333] rounded-lg px-3 py-2 text-[#e6e6e6] flex-1"
/>
</div>
{/* Table */}
<ReservationTable reservations={reservations} />
{/* Pagination */}
{nextCursor && (
<div className="mt-4 text-center">
<button
onClick={() => setDate(date)}
className="px-4 py-2 border border-[#333333] rounded-lg text-[#e6e6e6] flex items-center gap-2 mx-auto"
>
<IconSymbol name="arrow.down" size={16} />
{t("loadMore")}
</button>
</div>
)}
</div>
);
}- Step 3: Create reservation detail page
[P0 CRITICAL]: Use
nuqsuseQueryStatefor detail view, NOT dynamic URL segment[id].
Shows full details + actions: View booking details, Cancel reservation (admin only), Process refund (admin only), Resend confirmation email, Export to CSV.
- Step 4: Commit
Phase 6: Add-ons CRUD
Task 6: Global Add-ons Library
Files:
-
Create:
apps/frontend/app/admin/addons/page.tsx -
Modify:
convex/functions/addons.ts -
Step 1: Read existing
convex/functions/addons.ts -
Step 2: Add CRUD mutations
// convex/functions/addons.ts
export const create = mutation({
args: {
name: v.string(),
description: v.string(),
price: v.number(),
imageUrl: v.optional(v.string()),
type: v.union(
v.literal("COCKTAIL"),
v.literal("FOOD"),
v.literal("UPGRADE"),
v.literal("OTHER"),
),
enabled: v.boolean(),
},
handler: async (ctx, args) => {
return await ctx.db.insert("addOns", {
...args,
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
export const update = mutation({
args: {
id: v.id("addOns"),
name: v.optional(v.string()),
description: v.optional(v.string()),
price: v.optional(v.number()),
imageUrl: v.optional(v.string()),
type: v.optional(
v.union(
v.literal("COCKTAIL"),
v.literal("FOOD"),
v.literal("UPGRADE"),
v.literal("OTHER"),
),
),
enabled: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const { id, ...updates } = args;
await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
return id;
},
});- Step 3: Create addons admin page
Simple list with create/edit/delete (enable/disable toggle).
- Step 4: Commit
Phase 7: D-1 Automated Rule
Task 7: Scheduled D-1 Low Occupancy Automation
Files:
- Create:
convex/functions/d1-automation.ts— scheduled job
[P0 GAP: staffMutation not yet implemented in convex/auth.ts]: This phase uses
staffMutationwhich does NOT currently exist inconvex/auth.ts. Blocked until foundation-plan implementsstaffMutation. Do not implement this phase until foundation-plan is complete. Usemutation()with inline auth check as a workaround.
- Step 1: Create D-1 automation
Convex scheduled jobs run on the server. Create a scheduled mutation:
// convex/functions/d1-automation.ts
// [P0 GAP: staffMutation not yet implemented — use mutation() with inline auth]
import { mutation } from "../_generated/server";
import { v } from "convex/values";
import { AppError, ERRORS } from "~/convex/lib/errors";
import { consola } from "consola";
// Run daily at midnight — use Convex scheduler.define (not mutation)
export const runD1Automation = mutation({
args: {},
handler: async (ctx) => {
// [P0 GAP]: Add staffMutation check when available from foundation-plan
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
}
const role = identity.publicMetadata?.role;
if (role !== "ADMIN" && role !== "STAFF") {
throw new AppError(ERRORS.STAFF_ACCESS_REQUIRED, "Staff access required");
}
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split("T")[0];
const occurrences = await ctx.db
.query("showOccurrences")
.withIndex("by_date")
.collect();
const tomorrowOccs = occurrences.filter((o) => o.date === tomorrowStr);
for (const occ of tomorrowOccs) {
if (occ.status !== "SCHEDULED") continue;
const occupancyRate = occ.bookedCount / occ.actualCapacity;
const template = await ctx.db.get(occ.templateId);
// If < 50% AND show-only not enabled AND show-only price exists
if (
occupancyRate < 0.5 &&
!occ.showOnlyEnabled &&
(occ.showOnlyPriceOverride ?? template?.defaultShowOnlyPrice)
) {
await ctx.db.patch(occ._id, {
showOnlyEnabled: true,
updatedAt: Date.now(),
});
await sendLowOccupancyAlert(
occ,
template?.title ?? "Unknown Show",
occupancyRate,
);
}
}
return { processed: tomorrowOccs.length };
},
});
async function sendLowOccupancyAlert(
occurrence: {
date: string;
time: string;
bookedCount: number;
actualCapacity: number;
},
showTitle: string,
occupancyRate: number,
) {
// [P0 GAP]: WhatsApp Business API not yet integrated
// Actual WhatsApp integration in notifications-crm plan
consola.info("Low occupancy alert queued", {
showTitle,
date: occurrence.date,
occupancyRate: Math.round(occupancyRate * 100),
});
}- Step 2: Configure scheduler
Convex uses scheduler.runAt or cron configuration. Document the schedule: run at midnight daily.
- Step 3: Commit
Acceptance Criteria
- Admin auth — only authenticated users can access
/admin/* - Dashboard metrics — shows occupancy rate, revenue, avg cart value, upsell rate, at-risk shows
- Show templates — full CRUD with all fields (title, video, gallery, pricing, capacity)
- Batch generation — select days of week + date range + time → creates all occurrences in one click
- Calendar view — monthly grid with color-coded fill rates, click to edit occurrence
- Occurrence overrides — change capacity, price, show-only toggle, cancel/sold-out per date
- Reservations — paginated list with filters, detail view with cancel/refund/resend actions
- Add-ons library — CRUD for add-ons with enable/disable
- D-1 automation — runs daily, auto-enables show-only on low-occupancy dates, notifies Hamza
- Real-time — all admin data updates without refresh via Convex subscriptions
User Stories
| ID | As a... | I want to... | So that... | Priority |
|---|---|---|---|---|
| ADM-US01 | Admin | View dashboard with key metrics (occupancy rate, revenue, avg cart value) | I can see business health at a glance | Must |
| ADM-US02 | Admin | See at-risk occurrences (<30% filled, next 7 days) | I can take action before shows are undersold | Must |
| ADM-US03 | Admin | Create a new show template | I can add new shows to the system | Must |
| ADM-US04 | Admin | Batch-generate 30 occurrences (Fri+Sat, 19:30, next quarter) | I can set up recurring shows efficiently | Must |
| ADM-US05 | Admin | Override price for a specific date | I can adjust pricing for special events | Should |
| ADM-US06 | Admin | Toggle SHOW_ONLY on/off for a date | I can control show-only ticket availability | Must |
| ADM-US07 | Admin | Cancel an occurrence | I can handle unforeseen circumstances | Must |
| ADM-US08 | Staff | View reservations without edit rights | I can help customers without making changes | Must |
| ADM-US09 | Admin | Export reservations to CSV | I can share data with external systems | Should |
| ADM-US10 | Admin | Enable/disable add-ons | I can manage add-on availability | Must |
| ADM-US11 | System | Auto-enable SHOW_ONLY on D-1 for low-occupancy shows (<50%) | I can maximize ticket sales without manual intervention | Should |
Enrichment Sections
1. Zod Schemas
// lib/schemas/admin.ts
import { z } from "zod";
export const generateBatchSchema = z.object({
templateId: z.string(),
daysOfWeek: z.array(z.number().int().min(0).max(6)),
time: z.string().regex(/^\d{2}:\d{2}$/),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
actualCapacity: z.number().int().positive().max(32).optional(),
dinnerPriceOverride: z.number().int().nonnegative().optional(),
showOnlyPriceOverride: z.number().int().nonnegative().optional(),
showOnlyEnabled: z.boolean(),
});
export const addonCrudSchema = z.object({
id: z.string().optional(),
name: z.string().min(1).max(200),
description: z.string().max(500).optional(),
price: z.number().int().nonnegative(),
imageUrl: z.string().url().optional(),
type: z.enum(["COCKTAIL", "FOOD", "UPGRADE", "OTHER"]),
enabled: z.boolean(),
});
export const occurrenceOverrideSchema = z.object({
actualCapacity: z.number().int().positive().max(32).optional(),
dinnerPriceOverride: z.number().int().nonnegative().optional(),
showOnlyPriceOverride: z.number().int().nonnegative().optional(),
showOnlyEnabled: z.boolean().optional(),
status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).optional(),
});2. Error Handling
// convex/lib/errors.ts (shared error codes)
export const ERRORS = {
UNAUTHORIZED: "AUTH_001",
STAFF_ACCESS_REQUIRED: "AUTH_002",
ADMIN_ACCESS_REQUIRED: "AUTH_003",
OCC_NOT_FOUND: "OCC_001",
OCC_BATCH_EMPTY: "OCC_002",
OCC_BATCH_TOO_LARGE: "OCC_003",
RES_NOT_FOUND: "RES_001",
RES_ALREADY_CANCELLED: "RES_002",
ADDON_NOT_FOUND: "ADD_001",
SHOW_NOT_FOUND: "SHOW_001",
} as const;
type AdminError = keyof typeof ERRORS;
// Usage in mutations:
if (!template)
throw new AppError(ERRORS.OCC_NOT_FOUND, `Show template not found`);
if (occurrences.length === 0)
throw new AppError(ERRORS.OCC_BATCH_EMPTY, "No occurrences provided");
if (occurrences.length > 100)
throw new AppError(
ERRORS.OCC_BATCH_TOO_LARGE,
"Cannot create more than 100 occurrences at once",
);3. Convex Real-time Subscription Pattern
All dashboard data uses useQuery in client components for real-time updates:
// apps/frontend/app/admin/page.tsx — CLIENT COMPONENT
"use client";
// Real-time: auto-updates when Convex data changes
const occupancyRate = useQuery(api.analytics.averageOccupancyLast30Days, {});
const atRiskOccurrences = useQuery(api.analytics.atRiskOccurrences, {
daysOut: 7,
});
const recentReservations = useQuery(api.reservations.listPaginated, {
occurrenceId: undefined,
paymentStatus: undefined,
emailSearch: undefined,
cursor: undefined,
limit: 10,
});Do NOT use useQuery in server components — server components can call Convex query functions directly:
// In server component — direct call, no subscription
const show = await api.shows.getById({ id });4. Mobile/Responsive Considerations
- Sidebar: Collapses to hamburger menu on mobile. Use
useState+isOpenfor toggle. - Calendar grid: Monthly view on desktop, weekly view on mobile. Use CSS grid with responsive breakpoints.
- Reservation table: Horizontal scroll on mobile with sticky first column. Filter inputs stack vertically.
- Metric cards: 2 columns on mobile, 4 columns on desktop.
- Occurrence modal: Full-screen on mobile, centered dialog on desktop.
5. PWA / Offline Behavior
Admin dashboard should NOT be a PWA. Admin operations require consistent online connectivity for data integrity.
6. i18n / next-intl Requirements
All user-facing strings must use useTranslations (client) or getTranslations (server). Never hardcoded strings.
// Sidebar — client component
import { useTranslations } from "next-intl";
export function Sidebar() {
const t = useTranslations("admin.nav");
// Use t("dashboard"), t("shows"), etc.
}// Dashboard — client component
import { useTranslations } from "next-intl";
export default function AdminDashboard() {
const t = useTranslations("admin.dashboard");
// Use t("title"), t("avgOccupancy"), etc.
}Required translation keys:
{
"admin": {
"nav": {
"holAdmin": "HOL Admin",
"dashboard": "Dashboard",
"shows": "Shows",
"calendar": "Calendar",
"reservations": "Reservations",
"addons": "Add-ons",
"analytics": "Analytics"
},
"dashboard": {
"title": "Dashboard",
"avgOccupancy": "Avg Occupancy (30d)",
"revenue30d": "Revenue (30d)",
"avgCartValue": "Avg Cart Value",
"upsellRate": "Upsell Rate",
"atRiskShows": "At-Risk Shows (Next 7 Days)",
"allHealthy": "All upcoming shows are healthy.",
"show": "Show",
"date": "Date",
"occupancy": "Occupancy",
"action": "Action",
"edit": "Edit",
"recentReservations": "Recent Reservations",
"id": "ID",
"customer": "Customer",
"total": "Total",
"status": {
"paid": "PAID",
"pending": "PENDING",
"cancelled": "CANCELLED",
"refunded": "REFUNDED"
}
},
"shows": {
"title": "Show Templates",
"createShow": "Create Show",
"col": {
"title": "Title",
"slug": "Slug",
"status": "Status",
"price": "Dinner Price",
"actions": "Actions"
},
"edit": "Edit",
"form": {
"createTitle": "Create Show",
"editTitle": "Edit Show"
}
},
"reservations": {
"allStatuses": "All statuses",
"searchPlaceholder": "Search by name or email",
"loadMore": "Load More",
"status": {
"pending": "PENDING",
"paid": "PAID",
"cancelled": "CANCELLED",
"refunded": "REFUNDED"
}
},
"calendar": {
"days": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
}
}
}
}7. Environment-Specific Configuration
# .env.local
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
# .env.production
NEXT_PUBLIC_APP_URL=https://admin.houseoflegends.vn
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud8. TDD Test Cases
All tests follow user-expectation format with Given/When/Then structure.
E2E Tests (Playwright)
// e2e/admin-dashboard.spec.ts
test("ADM-E2E-1.1: Dashboard displays correct metrics", async ({ page }) => {
// Given: Admin is authenticated
// When: Admin navigates to /admin
// Then: Dashboard loads with metric cards visible
await signInAsAdmin(page);
await page.goto("http://localhost:3000/admin");
await expect(page.getByText("Dashboard")).toBeVisible();
await expect(page.getByText("Avg Occupancy")).toBeVisible();
});
test("ADM-E2E-1.2: Staff cannot see admin-only nav items", async ({ page }) => {
// Given: Staff user is authenticated
// When: Staff navigates to /admin
// Then: Admin-only nav items (Shows, Analytics) are not visible
await signInAsStaff(page);
await page.goto("http://localhost:3000/admin");
await expect(page.getByText("Shows")).not.toBeVisible();
await expect(page.getByText("Analytics")).not.toBeVisible();
});
test("ADM-E2E-2.1: Admin can batch generate occurrences", async ({ page }) => {
// Given: Admin is on the batch generation page
// When: Admin selects template, date range, days of week and submits
// Then: Occurrences are created and success message appears
await signInAsAdmin(page);
await page.goto("http://localhost:3000/admin/occurrences/batch");
// Select template, date range, days of week
// Submit and verify success
});Unit Tests (Vitest) — Generate Batch
// __tests__/admin/generate-batch.test.ts
import { describe, it, expect, vi } from "vitest";
describe("generateBatch", () => {
it("ADM-UT01: generates occurrences for selected days of week", async () => {
// Given: Valid template and day-of-week selection
const ctx = mockConvexCtx({
db: {
get: vi
.fn()
.mockResolvedValue({ defaultCapacity: 50, title: "Test Show" }),
insert: vi.fn().mockResolvedValue("occurrence-id"),
},
});
// When: generateBatch is called with Fri+Sat selection
// Then: Occurrences are created for each matching day
const result = await generateBatch({
templateId: "template-1",
daysOfWeek: [5, 6], // Fri, Sat
time: "19:30",
startDate: "2026-05-01",
endDate: "2026-05-31",
showOnlyEnabled: false,
});
expect(result.count).toBeGreaterThan(0);
});
it("ADM-UT02: rejects if template not found", async () => {
// Given: Non-existent template ID
const ctx = mockConvexCtx({ db: { get: vi.fn().mockResolvedValue(null) } });
// When: generateBatch is called with invalid template
// Then: Error is thrown about template not found
await expect(
generateBatch({
templateId: "nonexistent",
daysOfWeek: [5],
time: "19:30",
startDate: "2026-05-01",
endDate: "2026-05-31",
showOnlyEnabled: false,
}),
).rejects.toThrow("Template not found");
});
});Unit Tests (Vitest) — At-Risk Occurrences
// __tests__/admin/at-risk.test.ts
describe("atRiskOccurrences", () => {
it("ADM-UT03: returns occurrences below 30% occupancy", async () => {
// Given: Two occurrences - one below 30%, one above
const ctx = mockConvexCtx({
db: {
query: vi.fn().mockReturnValue({
collect: vi.fn().mockResolvedValue([
{
_id: "1",
date: "2026-05-10",
status: "SCHEDULED",
bookedCount: 5,
actualCapacity: 50,
showTitle: "Show A",
},
{
_id: "2",
date: "2026-05-10",
status: "SCHEDULED",
bookedCount: 40,
actualCapacity: 50,
showTitle: "Show B",
},
]),
}),
},
});
// When: atRiskOccurrences is queried for next 7 days
// Then: Only Show A (below 30%) is returned
const result = await atRiskOccurrences({ daysOut: 7 });
expect(result.length).toBe(1);
expect(result[0].showTitle).toBe("Show A");
});
});Component Tests (Vitest + RTL)
// __tests__/components/metric-card.test.tsx
import { render, screen } from "@testing-library/react";
import { MetricCard } from "~/components/admin/metric-card";
describe("MetricCard", () => {
it("ADM-CT01: displays title and value correctly", () => {
// Given: MetricCard with title and value props
// When: Component renders
// Then: Title and formatted value are visible
render(<MetricCard title="Revenue" value="15.5M" icon="dollarsign.circle.fill" />);
expect(screen.getByText("Revenue")).toBeInTheDocument();
expect(screen.getByText("15.5M")).toBeInTheDocument();
});
it("ADM-CT02: shows trend indicator when provided", () => {
// Given: MetricCard with trend="up"
// When: Component renders
// Then: Trend indicator is visible
render(<MetricCard title="Occupancy" value="75%" trend="up" icon="chart.pie.fill" />);
expect(screen.getByText("Up")).toBeInTheDocument();
});
});9. Cross-Plan Dependencies
| Plan | Depends On | Shares |
|---|---|---|
| 03-admin-dashboard | 01-foundation | All tables |
| 03-admin-backoffice | 01-foundation, 03-admin-dashboard | Overlapping — consolidate implementations |
| 10-cancellation-refund | 01-foundation, 03-admin-dashboard | reservations table, cancel mutations |
| 12-d1-auto-rule | 01-foundation, 03-admin-dashboard | showOccurrences, showOnlyEnabled |
10. Performance Considerations
- Analytics aggregation:
topShowsByRevenueiterates over all paid reservations. Add aby_show_revenuematerialized index or use Convex query results caching. - Paginated lists: Always use cursor-based pagination for
reservations.listPaginated. Settinglimit: 20is required. - Calendar data: Loading all occurrences for a month via
useQuerycan be expensive. Consider adding agetByMonthquery with server-side filtering. - Real-time overhead: Each
useQuerycall opens a WebSocket subscription. Bundle dashboard metrics into a singledashboardSummaryquery that returns all metrics in one round-trip. - Required indexes: Ensure these indexes exist in schema:
showOccurrences.by_date— required for analytics and D-1 automationreservations.by_paymentStatus— required for all analytics queries
Consistency Audit: admin-dashboard
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-1 | Phase 7 (D-1 automation) | staffMutation/adminMutation not yet implemented | [P0 GAP] Use mutation() with inline auth check until foundation-plan implements staffMutation |
| P0-2 | Phase 3 (Show CRUD), Phase 5 (Reservations) | Dynamic URL segments [id] | [FIXED] Changed all to nuqs useQueryState — URL pattern: /admin/shows?selectedId={id} |
| P0-3 | ShowForm handleSubmit | as any type assertion | [FIXED] Changed to Zod safeParse() with showTemplateSchema |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-1 | All Convex functions | console.log usage | [FIXED] Changed to consola from consola package |
| P1-2 | UI components | Hardcoded strings | [FIXED] All use useTranslations/getTranslations |
| P1-3 | AdminReservationsPage filter state updates | Missing useTransition | [FIXED] Added to all state update flows |
| P1-4 | AdminDashboard with async data | Missing Suspense boundary | [FIXED] Added <Suspense> wrappers around AtRiskList and RecentReservations |
| P1-5 | generateBatch | v.string() for templateId | [FIXED] Changed to v.id("showTemplates") |
| P1-6 | Admin layout | Role hardcoded | [FIXED] Fetch role from Clerk publicMetadata |
| P1-7 | Status badges, buttons | Emoji in UI | [FIXED] Replaced with IconSymbol component |
| P1-8 | Analytics queries | Full collection scans without indexes | [FIXED] All analytics queries now use .withIndex("by_date") or .withIndex("by_paymentStatus") |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| GAP-1 | staffMutation not yet implemented in convex/auth.ts | foundation-plan implements staffMutation — Phase 7 D-1 automation blocked until foundation-plan completes |
| GAP-2 | adminMutation not yet implemented in convex/auth.ts | foundation-plan implements adminMutation — privileged mutations blocked until foundation-plan completes |
| GAP-3 | WhatsApp Business API not integrated | notifications-crm plan implements actual WhatsApp delivery |
| GAP-4 | notifications table not defined in schema | foundation-plan must add notifications table (referenced by d1-auto-rule plan) |
Cross-Plan Naming Consistency
| Issue | Details |
|---|---|
generateBatch vs createBatch | admin-dashboard defines generateBatch with days-of-week pattern; admin-backoffice defines createBatch with date-list pattern. The frontend forms should use whichever canonical mutation is chosen. Both use v.id("showTemplates") correctly. |
Spec Violation Note
The spec file docs/superpowers/specs/03-admin-backoffice.md contains a [id] dynamic segment in shows/[id]/page.tsx at line 157 of the spec. The plan correctly uses nuqs instead. When implementing, ignore the spec's [id] segment pattern.
i18n Compliance
- All user-facing strings use
getTranslations(server) oruseTranslations(client) - No hardcoded English strings in component code
- Translation namespace
admin.*covers all admin UI strings
Type Safety
- Zod schemas defined for admin forms (
lib/schemas/admin.ts) - No
astype assertions used anywhere in plan code v.id()validators used for all Convex ID fields
Security
- All privileged mutations use
staffMutationoradminMutationwrappers (after foundation-plan implements them) - Clerk middleware protects
/admin/*routes - Role fetched from Clerk
publicMetadata, not hardcoded
Design Tokens
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] | Page background |
accent | #C5A059 | text-[#C5A059] | Gold primary, links |
text | #e6e6e6 | text-[#e6e6e6] | Body text |
muted | #808080 | text-[#808080] | Secondary text |
border | #333333 | border-[#333333] | Borders |