plans
2026-05-03
2026 05 03 Admin Dashboard

Admin Dashboard Implementation Plan

Spec file: No dedicated spec — overlaps with docs/superpowers/specs/03-admin-backoffice.md

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: 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 staffMutation and adminMutation helpers do NOT currently exist in convex/auth.ts — they must be implemented in foundation-plan first. This plan correctly uses mutation() 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 / actualCapacity per 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:

FeatureAdminStaff
Dashboard + AnalyticsYesNo
Show template CRUDYesNo
Batch occurrence generationYesNo
Occurrence overrideYesYes (limited)
Reservation view/cancel/refundYesYes (view)
Add-ons CRUDYesNo
CSV exportYesNo

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.ts exists 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 IconSymbol component, 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 listAll query (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 nuqs useQueryState for 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 of as any type 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 — add generateBatch mutation

This is the most operationally critical feature for Hamza.

  • Step 1: Create generateBatch mutation in Convex

[P1 Fix]: v.id() validators must be used for ID fields. Do NOT use v.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 useQueryState from nuqs for URL state — NOT useState + separate URL params.

  • Step 1: Add paginated listPaginated query 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 nuqs useQueryState for 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 staffMutation which does NOT currently exist in convex/auth.ts. Blocked until foundation-plan implements staffMutation. Do not implement this phase until foundation-plan is complete. Use mutation() 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

  1. Admin auth — only authenticated users can access /admin/*
  2. Dashboard metrics — shows occupancy rate, revenue, avg cart value, upsell rate, at-risk shows
  3. Show templates — full CRUD with all fields (title, video, gallery, pricing, capacity)
  4. Batch generation — select days of week + date range + time → creates all occurrences in one click
  5. Calendar view — monthly grid with color-coded fill rates, click to edit occurrence
  6. Occurrence overrides — change capacity, price, show-only toggle, cancel/sold-out per date
  7. Reservations — paginated list with filters, detail view with cancel/refund/resend actions
  8. Add-ons library — CRUD for add-ons with enable/disable
  9. D-1 automation — runs daily, auto-enables show-only on low-occupancy dates, notifies Hamza
  10. Real-time — all admin data updates without refresh via Convex subscriptions

User Stories

IDAs a...I want to...So that...Priority
ADM-US01AdminView dashboard with key metrics (occupancy rate, revenue, avg cart value)I can see business health at a glanceMust
ADM-US02AdminSee at-risk occurrences (<30% filled, next 7 days)I can take action before shows are undersoldMust
ADM-US03AdminCreate a new show templateI can add new shows to the systemMust
ADM-US04AdminBatch-generate 30 occurrences (Fri+Sat, 19:30, next quarter)I can set up recurring shows efficientlyMust
ADM-US05AdminOverride price for a specific dateI can adjust pricing for special eventsShould
ADM-US06AdminToggle SHOW_ONLY on/off for a dateI can control show-only ticket availabilityMust
ADM-US07AdminCancel an occurrenceI can handle unforeseen circumstancesMust
ADM-US08StaffView reservations without edit rightsI can help customers without making changesMust
ADM-US09AdminExport reservations to CSVI can share data with external systemsShould
ADM-US10AdminEnable/disable add-onsI can manage add-on availabilityMust
ADM-US11SystemAuto-enable SHOW_ONLY on D-1 for low-occupancy shows (<50%)I can maximize ticket sales without manual interventionShould

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 + isOpen for 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.cloud

8. 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

PlanDepends OnShares
03-admin-dashboard01-foundationAll tables
03-admin-backoffice01-foundation, 03-admin-dashboardOverlapping — consolidate implementations
10-cancellation-refund01-foundation, 03-admin-dashboardreservations table, cancel mutations
12-d1-auto-rule01-foundation, 03-admin-dashboardshowOccurrences, showOnlyEnabled

10. Performance Considerations

  • Analytics aggregation: topShowsByRevenue iterates over all paid reservations. Add a by_show_revenue materialized index or use Convex query results caching.
  • Paginated lists: Always use cursor-based pagination for reservations.listPaginated. Setting limit: 20 is required.
  • Calendar data: Loading all occurrences for a month via useQuery can be expensive. Consider adding a getByMonth query with server-side filtering.
  • Real-time overhead: Each useQuery call opens a WebSocket subscription. Bundle dashboard metrics into a single dashboardSummary query 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 automation
    • reservations.by_paymentStatus — required for all analytics queries

Consistency Audit: admin-dashboard

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
P0-1Phase 7 (D-1 automation)staffMutation/adminMutation not yet implemented[P0 GAP] Use mutation() with inline auth check until foundation-plan implements staffMutation
P0-2Phase 3 (Show CRUD), Phase 5 (Reservations)Dynamic URL segments [id][FIXED] Changed all to nuqs useQueryState — URL pattern: /admin/shows?selectedId={id}
P0-3ShowForm handleSubmitas any type assertion[FIXED] Changed to Zod safeParse() with showTemplateSchema

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
P1-1All Convex functionsconsole.log usage[FIXED] Changed to consola from consola package
P1-2UI componentsHardcoded strings[FIXED] All use useTranslations/getTranslations
P1-3AdminReservationsPage filter state updatesMissing useTransition[FIXED] Added to all state update flows
P1-4AdminDashboard with async dataMissing Suspense boundary[FIXED] Added <Suspense> wrappers around AtRiskList and RecentReservations
P1-5generateBatchv.string() for templateId[FIXED] Changed to v.id("showTemplates")
P1-6Admin layoutRole hardcoded[FIXED] Fetch role from Clerk publicMetadata
P1-7Status badges, buttonsEmoji in UI[FIXED] Replaced with IconSymbol component
P1-8Analytics queriesFull 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)

#IssueAction Required
GAP-1staffMutation not yet implemented in convex/auth.tsfoundation-plan implements staffMutation — Phase 7 D-1 automation blocked until foundation-plan completes
GAP-2adminMutation not yet implemented in convex/auth.tsfoundation-plan implements adminMutation — privileged mutations blocked until foundation-plan completes
GAP-3WhatsApp Business API not integratednotifications-crm plan implements actual WhatsApp delivery
GAP-4notifications table not defined in schemafoundation-plan must add notifications table (referenced by d1-auto-rule plan)

Cross-Plan Naming Consistency

IssueDetails
generateBatch vs createBatchadmin-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) or useTranslations (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 as type assertions used anywhere in plan code
  • v.id() validators used for all Convex ID fields

Security

  • All privileged mutations use staffMutation or adminMutation wrappers (after foundation-plan implements them)
  • Clerk middleware protects /admin/* routes
  • Role fetched from Clerk publicMetadata, not hardcoded

Design Tokens

TokenHexTailwind ClassUsage
background#1a1a1abg-[#1a1a1a]Page background
accent#C5A059text-[#C5A059]Gold primary, links
text#e6e6e6text-[#e6e6e6]Body text
muted#808080text-[#808080]Secondary text
border#333333border-[#333333]Borders