plans
2026-05-03
2026 05 03 Admin Backoffice Plan

Admin Backoffice Implementation Plan

Spec file: 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: Implement the admin dashboard for House of Legends staff and admins. Real-time KPI widgets, show/occurrence CRUD, batch generation, reservation management, and analytics.

Architecture: Admin routes are server-client hybrid. Dashboard widgets use Convex real-time subscriptions. Calendar uses nuqs for date state. Batch generation is a multi-step form with a confirmation step.

Tech Stack: Next.js 16 App Router, Convex real-time queries + mutations, nuqs for URL state, Tailwind CSS v4, 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-dashboard.md. The file maps, sidebar nav, and dashboard widgets are duplicated. Implement admin-dashboard first, then extend it with the backoffice-specific features in this plan rather than building two separate admin surfaces. Consolidate to a single /admin/* route group.


Business Summary

What this does: Provides the comprehensive admin backoffice for House of Legends staff and admins to manage shows, generate occurrences in batch, handle reservations, and manage add-ons. Includes real-time KPI widgets so staff can see today's shows, open orders, pending reviews, and daily revenue at a glance.

Why it matters: This replaces the legacy WordPress admin and all manual operations. Staff can now manage the entire show lifecycle from a single interface without switching between systems. Real-time data means Hamza and staff always see current business状态 rather than relying on stale reports.

Time to implement: 5-8 days | Complexity: High

Dependencies: foundation-plan (for staffMutation/adminMutation auth helpers), admin-dashboard (consolidate sidebar and layout from there)


File Map

apps/frontend/app/admin/
├── layout.tsx                    # CREATE — auth guard + sidebar (CONSOLIDATE: already exists in admin-dashboard)
├── page.tsx                     # MODIFY — add dashboard widgets
├── shows/
│   └── page.tsx                 # CREATE — show template list (nuqs, not [id] segment)
├── occurrences/
│   ├── page.tsx                # CREATE — calendar view
│   └── batch/
│       └── page.tsx           # CREATE — batch generation form
├── reservations/
│   └── page.tsx                # CREATE — reservation list + filters
├── addons/
│   └── page.tsx                # CREATE — add-on list + form
└── reports/
    └── page.tsx                # CREATE — analytics charts

apps/frontend/components/admin/
├── sidebar-nav.tsx              # CREATE — nav links per role (CONSOLIDATE: use from admin-dashboard)
├── dashboard-widgets.tsx       # CREATE — stat cards with real-time data
├── occurrence-calendar.tsx      # CREATE — monthly calendar
├── batch-generation-form.tsx    # CREATE — occurrence batch creator
└── reservation-table.tsx        # CREATE — filterable reservation list

Phase 1: Admin Layout + Auth Guard

Task 1: Create Admin Layout with Auth Guard

Files:

  • Create: apps/frontend/app/admin/layout.tsx (CONSOLIDATE: extends existing from admin-dashboard)

  • Step 1: Read Clerk auth middleware

cat apps/frontend/middleware.ts
  • Step 2: Create admin layout with role-based sidebar

Auth: Admin layout is a server component. Use auth() from @clerk/nextjs/server to get user and role. Redirect to sign-in if not authenticated.

[P1 Fix]: The role must be fetched from Clerk's publicMetadata, not hardcoded. The Clerk user must have publicMetadata.role set during user provisioning.

// apps/frontend/app/admin/layout.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { SidebarNav } from "~/components/admin/sidebar-nav";
import { currentUser } from "@clerk/nextjs/server";
import { getTranslations } from "next-intl/server";
 
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in?redirect_url=/admin");
 
  // [P1 Fix]: Fetch role from Clerk publicMetadata — do NOT hardcode
  const user = await currentUser();
  const role = (user?.publicMetadata?.role as "ADMIN" | "STAFF") ?? "STAFF";
  const t = await getTranslations("admin.nav");
 
  return (
    <div className="flex min-h-screen bg-[#1a1a1a]">
      <SidebarNav role={role} />
      <main className="flex-1 p-8 overflow-auto">{children}</main>
    </div>
  );
}
  • Step 3: Create sidebar navigation

[P1 Fix]: Use IconSymbol component, not emoji characters.

// apps/frontend/components/admin/sidebar-nav.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", "STAFF"] as const },
  { href: "/admin/shows", labelKey: "shows", icon: "theatermasks.fill", roles: ["ADMIN"] as const },
  { href: "/admin/occurrences", labelKey: "occurrences", icon: "calendar", roles: ["ADMIN"] as const },
  { href: "/admin/reservations", labelKey: "reservations", icon: "ticket.fill", roles: ["ADMIN", "STAFF"] as const },
  { href: "/admin/tables", labelKey: "tables", icon: "square.grid.2x2.fill", roles: ["ADMIN"] as const },
  { href: "/admin/menu", labelKey: "menu", icon: "fork.knife", roles: ["ADMIN"] as const },
  { href: "/admin/addons", labelKey: "addons", icon: "plus.circle.fill", roles: ["ADMIN"] as const },
  { href: "/admin/challenges", labelKey: "challenges", icon: "star.fill", roles: ["ADMIN"] as const },
  { href: "/admin/reports", labelKey: "reports", icon: "chart.line.uptrend.xyaxis", roles: ["ADMIN"] as const },
];
 
export function SidebarNav({ 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] min-h-screen p-4">
      <h1 className="font-serif text-xl text-[#C5A059] mb-8 px-2">
        {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 text-sm transition-colors ${
              pathname === item.href
                ? "bg-[#C5A059]/10 text-[#C5A059] font-medium"
                : "text-[#808080] hover:text-[#e6e6e6] hover:bg-[#1a1a1a]"
            }`}
          >
            <IconSymbol name={item.icon} size={18} className="shrink-0" />
            {t(item.labelKey)}
          </Link>
        ))}
      </nav>
    </aside>
  );
}
  • Step 4: Commit
git add apps/frontend/app/admin/layout.tsx apps/frontend/components/admin/sidebar-nav.tsx
git commit -m "feat(admin): add admin layout with auth guard and sidebar"

Phase 2: Dashboard Widgets

Task 2: Create Dashboard Page with Real-time Widgets

Files:

  • Modify: apps/frontend/app/admin/page.tsx

  • Create: apps/frontend/components/admin/dashboard-widgets.tsx

  • Step 1: Create dashboard widgets component

// apps/frontend/components/admin/dashboard-widgets.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
 
export function DashboardWidgets() {
  const t = useTranslations("admin.dashboard");
 
  const todayOccurrences = useQuery(api.occurrences.getToday, {});
  const pendingOrders = useQuery(api.orders.countPending, {});
  const pendingChallenges = useQuery(api.challenges.countPending, {});
  const revenueToday = useQuery(api.orders.getRevenueToday, {});
 
  return (
    <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
      <StatCard
        label={t("todaysShows")}
        value={todayOccurrences?.length ?? 0}
        sublabel={t("scheduled")}
        icon="calendar"
        color="accent"
      />
      <StatCard
        label={t("openOrders")}
        value={pendingOrders ?? 0}
        sublabel={t("inKitchen")}
        icon="fork.knife"
        color="orange"
      />
      <StatCard
        label={t("pendingReviews")}
        value={pendingChallenges ?? 0}
        sublabel={t("awaitingApproval")}
        icon="star.fill"
        color="blue"
      />
      <StatCard
        label={t("revenueToday")}
        value={`${((revenueToday ?? 0) / 1_000_000).toFixed(1)}M`}
        sublabel="VND"
        icon="dollarsign.circle.fill"
        color="green"
      />
    </div>
  );
}
 
function StatCard({
  label,
  value,
  sublabel,
  icon,
  color,
}: {
  label: string;
  value: string | number;
  sublabel: string;
  icon: string;
  color: "accent" | "orange" | "blue" | "green";
}) {
  const colorMap = {
    accent: "border-[#C5A059]",
    orange: "border-orange-500",
    blue: "border-blue-500",
    green: "border-green-500",
  };
  const iconColorMap = {
    accent: "text-[#C5A059]",
    orange: "text-orange-500",
    blue: "text-blue-500",
    green: "text-green-500",
  };
  return (
    <div className={`bg-[#1a1a1a] border-l-4 ${colorMap[color]} p-4 rounded-r-lg`}>
      <div className="flex items-center justify-between mb-2">
        <p className="text-sm text-[#808080]">{label}</p>
        <IconSymbol name={icon} size={20} className={iconColorMap[color]} />
      </div>
      <p className="text-3xl font-serif text-[#e6e6e6]">{value}</p>
      <p className="text-xs text-[#808080] mt-1">{sublabel}</p>
    </div>
  );
}
  • Step 2: Add Convex queries for dashboard data

In convex/functions/occurrences.ts:

export const getToday = query({
  args: {},
  handler: async (ctx) => {
    const today = new Date().toISOString().split("T")[0];
    return await ctx.db.query("showOccurrences").withIndex("by_date").collect();
  },
});
  • Step 3: Commit
git add apps/frontend/app/admin/page.tsx apps/frontend/components/admin/dashboard-widgets.tsx
git add convex/functions/occurrences.ts
git commit -m "feat(admin): add dashboard widgets with real-time data"

Phase 3: Shows + Occurrences Management

Task 3: Create Show CRUD Pages

Files:

  • Create: apps/frontend/app/admin/shows/page.tsx — list + nuqs-based edit modal
  • Create: apps/frontend/components/admin/show-form.tsx

[P0 CRITICAL]: Do NOT use useParams() or dynamic URL segments [id] for admin routes. Use nuqs useQueryState instead. URL pattern: /admin/shows?selectedId={showId}

[Spec Violation Fix]: The spec file 03-admin-backoffice.md line 157 uses shows/[id]/page.tsx with a [id] segment. This is a spec error — use nuqs instead as specified in this plan.

// apps/frontend/app/admin/shows/page.tsx
"use client";
import { useQueryState } from "nuqs"; // [P0 Fix]: Use nuqs, NOT [id] segment
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import Link from "next/link";
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.listActive);
  const [selectedId, setSelectedId] = useQueryState("selectedId", { defaultValue: "" });
 
  return (
    <div>
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-serif text-[#e6e6e6]">{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("addNew")}
        </button>
      </div>
      <table className="w-full text-sm">
        <thead>
          <tr className="text-left text-[#808080] border-b border-[#333333]">
            <th className="pb-2">{t("columns.title")}</th>
            <th className="pb-2">{t("columns.slug")}</th>
            <th className="pb-2">{t("columns.status")}</th>
            <th className="pb-2">{t("columns.price")}</th>
            <th className="pb-2">{t("columns.actions")}</th>
          </tr>
        </thead>
        <tbody>
          {shows?.map((show) => (
            <tr key={show._id} className="border-b border-[#333333] text-[#e6e6e6]">
              <td className="py-3">{show.title}</td>
              <td className="text-[#808080]">{show.slug}</td>
              <td>
                <span className="px-2 py-1 rounded text-xs bg-green-500/20 text-green-400">
                  {show.status}
                </span>
              </td>
              <td className="text-[#808080]">{show.defaultDinnerPrice.toLocaleString()} VND</td>
              <td>
                <button
                  onClick={() => setSelectedId(show._id)}
                  className="text-[#C5A059] hover:underline flex items-center gap-1"
                >
                  <IconSymbol name="pencil" size={14} />
                  {t("edit")}
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {selectedId && (
        <ShowForm
          showId={selectedId === "new" ? undefined : selectedId}
          onClose={() => setSelectedId("")}
        />
      )}
    </div>
  );
}
  • Step 1: Create show form component (new/edit) with nuqs

[P0 Fix]: Use Zod safeParse() instead of as any type assertion.

// apps/frontend/components/admin/show-form.tsx
"use client";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { showTemplateSchema } from "~/lib/schemas/admin-show"; // [P0 Fix]: Zod validation
import { IconSymbol } from "~/components/ui/iconSymbol";
import { z } from "zod";
 
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 = (data: unknown) => {
    startTransition(async () => {
      // [P0 Fix]: Zod parse instead of as any
      const parsed = showTemplateSchema.safeParse(data);
      if (!parsed.success) {
        throw new Error("Validation failed");
      }
      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 go here */}
      </div>
    </div>
  );
}
  • Step 2: Commit
git add apps/frontend/app/admin/shows/
git commit -m "feat(admin): add show CRUD pages"

Phase 4: Occurrence Calendar + Batch Generation

Task 4: Create Occurrence Calendar + Batch Generation

Files:

  • Create: apps/frontend/app/admin/occurrences/page.tsx

  • Create: apps/frontend/app/admin/occurrences/batch/page.tsx

  • Create: apps/frontend/components/admin/occurrence-calendar.tsx

  • Create: apps/frontend/components/admin/batch-generation-form.tsx

  • Step 1: Create occurrence calendar

Monthly calendar showing dots per day (green/orange/red based on occupancy). Click day to expand occurrence list for that day.

  • Step 2: Create batch generation form

[P1 Fix]: Use Zod safeParse() for validation. Use v.id() for ID fields.

// apps/frontend/components/admin/batch-generation-form.tsx
"use client";
import { useState, useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
import { batchOccurrenceSchema } from "~/lib/schemas/admin-show"; // [P1 Fix]: Zod validation
 
export function BatchGenerationForm() {
  const t = useTranslations("admin.occurrences.batch");
  const [templateId, setTemplateId] = useState("");
  const [startDate, setStartDate] = useState("");
  const [endDate, setEndDate] = useState("");
  const [timeSlots, setTimeSlots] = useState<string[]>(["19:00"]);
  const [daysOfWeek, setDaysOfWeek] = useState<number[]>([4, 5, 6]); // Thu,Fri,Sat
  const [preview, setPreview] = useState<{ date: string; time: string }[]>([]);
  const [isPending, startTransition] = useTransition();
 
  const createBatch = useMutation(api.occurrences.createBatch);
 
  const handleSubmit = async () => {
    startTransition(async () => {
      // [P0 Fix]: Zod parse instead of as any
      const parsed = batchOccurrenceSchema.safeParse({ templateId, occurrences: preview });
      if (!parsed.success) {
        throw new Error("Validation failed");
      }
      await createBatch(parsed.data);
    });
  };
 
  return (
    <div className="space-y-6 max-w-2xl">
      {/* Template select, date range, time slots, days of week */}
      {/* Preview section */}
      {preview.length > 0 && (
        <>
          <p className="text-sm text-[#808080]">{preview.length} {t("occurrencesCreated")}</p>
          <button
            onClick={handleSubmit}
            disabled={isPending}
            className="px-6 py-3 bg-[#C5A059] text-black font-bold rounded-lg disabled:opacity-50 flex items-center gap-2"
          >
            <IconSymbol name="checkmark" size={16} />
            {isPending ? t("creating") : t("confirmCreate")}
          </button>
        </>
      )}
    </div>
  );
}
  • Step 3: Add createBatch mutation to Convex

[P1 Fix]: v.id() validators must be used for ID fields to ensure type safety. Do NOT use v.string() for ID args.

[Naming Consistency Note]: The admin-dashboard plan defines generateBatch in its enrichment section. This plan defines createBatch. Choose one as the canonical implementation and use it consistently. The function signature differs slightly — createBatch takes a separate templateId + occurrences[] while generateBatch takes daysOfWeek[] + time + startDate + endDate. Both are valid approaches; ensure the chosen mutation is used consistently in the frontend form.

// convex/functions/occurrences.ts — ADD createBatch mutation
export const createBatch = mutation({
  args: {
    templateId: v.id("showTemplates"), // [P1 Fix]: Use v.id(), not v.string()
    occurrences: v.array(
      v.object({
        date: v.string(),
        time: v.string(),
      }),
    ),
  },
  handler: async (ctx, { templateId, occurrences }) => {
    const template = await ctx.db.get(templateId);
    if (!template) {
      throw new AppError("OCC_001", `Show template ${templateId} not found`);
    }
    if (occurrences.length === 0) {
      throw new AppError("OCC_002", "No occurrences provided");
    }
    if (occurrences.length > 100) {
      throw new AppError(
        "OCC_003",
        "Cannot create more than 100 occurrences at once",
      );
    }
    const ids = [];
    for (const occ of occurrences) {
      const id = await ctx.db.insert("showOccurrences", {
        templateId,
        date: occ.date,
        time: occ.time,
        status: "SCHEDULED",
        bookedCount: 0,
        actualCapacity: template.defaultCapacity,
      });
      ids.push(id);
    }
    return ids;
  },
});
  • Step 4: Commit
git add apps/frontend/app/admin/occurrences/ apps/frontend/components/admin/occurrence-calendar.tsx apps/frontend/components/admin/batch-generation-form.tsx
git add convex/functions/occurrences.ts
git commit -m "feat(admin): add occurrence calendar and batch generation"

Phase 5: Reservation Management

Task 5: Create Reservation List with Filters

Files:

  • Create: apps/frontend/app/admin/reservations/page.tsx
  • Create: apps/frontend/components/admin/reservation-table.tsx

[P0 CRITICAL]: Reservation filters MUST use useQueryState from nuqs for URL state — NOT useState + separate URL params. This ensures filter state is shareable via URL and survives page refresh.

  • Step 1: Create reservation table with nuqs filters
// apps/frontend/app/admin/reservations/page.tsx
"use client";
import { Suspense } from "react";
import { useQueryState } from "nuqs"; // [P0 Fix]: Use nuqs, not useState
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 = useQuery(api.reservations.listPaginated, {
    occurrenceId: undefined,
    paymentStatus: status || undefined,
    emailSearch: search || undefined,
    cursor: undefined,
    limit: 20,
  });
 
  return (
    <Suspense fallback={<div className="h-64 bg-[#1a1a1a] animate-pulse rounded-lg" />}>
      <div>
        {/* Filters bar */}
        <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("filterAllStatuses")}</option>
            <option value="PENDING">{t("statusPending")}</option>
            <option value="PAID">{t("statusPaid")}</option>
            <option value="CANCELLED">{t("statusCancelled")}</option>
            <option value="REFUNDED">{t("statusRefunded")}</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?.reservations ?? []} />
      </div>
    </Suspense>
  );
}
  • Step 2: Commit
git add apps/frontend/app/admin/reservations/ apps/frontend/components/admin/reservation-table.tsx
git commit -m "feat(admin): add reservation management with filters"

Phase 6: Add-Ons Management

Task 6: Create Add-Ons CRUD

Files:

  • Create: apps/frontend/app/admin/addons/page.tsx

  • Step 1: Create add-ons page with drag-to-reorder

Uses React DnD or simple up/down buttons for sort order. Toggle availability inline.

  • Step 2: Commit

Acceptance Criteria

  1. Admin layout requires auth, shows role-appropriate nav
  2. Dashboard shows real-time: today's shows count, open orders, pending challenges, revenue
  3. At-risk occurrences (< 30% capacity) displayed on dashboard
  4. Show list page: CRUD operations work, "Add New Show" button navigates to form via nuqs
  5. Occurrence calendar: monthly view, color-coded dots, click to expand day
  6. Batch generation: select template, date range, time slots, days → preview → create
  7. Reservation list: filters by date, status, search by name/email work via nuqs
  8. Add-ons: list with inline availability toggle and sort order

Enrichment Sections

1. Zod Schemas

// lib/schemas/admin-show.ts
import { z } from "zod";
 
export const showTemplateSchema = z.object({
  title: z.string().min(1).max(200),
  slug: z
    .string()
    .min(1)
    .regex(/^[a-z0-9-]+$/),
  tagline: z.string().max(300).optional(),
  description: z.string().optional(),
  videoUrl: z.string().url().optional(),
  gallery: z.array(z.string().url()).optional(),
  supportedTypes: z.array(z.enum(["DINNER_THEATRE", "SHOW_ONLY"])),
  defaultDinnerPrice: z.number().int().nonnegative(),
  defaultShowOnlyPrice: z.number().int().nonnegative().optional(),
  defaultCapacity: z.number().int().positive().max(32),
  status: z.enum(["ACTIVE", "DRAFT", "ARCHIVED"]).default("DRAFT"),
});
 
export const occurrenceSchema = z.object({
  templateId: z.string(),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  time: z.string().regex(/^\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().default(false),
  status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]).default("SCHEDULED"),
});
 
export const batchOccurrenceSchema = z.object({
  templateId: z.string().min(1),
  occurrences: z.array(
    z.object({
      date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
      time: z.string().regex(/^\d{2}:\d{2}$/),
    }),
  ),
});
 
export const reservationFilterSchema = z.object({
  date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .optional(),
  status: z.enum(["PENDING", "PAID", "CANCELLED", "REFUNDED"]).optional(),
  search: z.string().optional(),
  occurrenceId: z.string().optional(),
  cursor: z.string().optional(),
  limit: z.number().int().positive().max(100).default(20),
});
 
export const addonSchema = z.object({
  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(),
  sortOrder: z.number().int().default(0),
});

2. Error Handling

// convex/functions/occurrences.ts — batch generation errors
export const createBatch = mutation({
  args: {
    templateId: v.id("showTemplates"),
    occurrences: v.array(v.object({ date: v.string(), time: v.string() })),
  },
  handler: async (ctx, { templateId, occurrences }) => {
    const template = await ctx.db.get(templateId);
    if (!template) {
      throw new AppError("OCC_001", `Show template ${templateId} not found`);
    }
    if (occurrences.length === 0) {
      throw new AppError("OCC_002", "No occurrences provided");
    }
    if (occurrences.length > 100) {
      throw new AppError(
        "OCC_003",
        "Cannot create more than 100 occurrences at once",
      );
    }
    // ... rest of handler
  },
});
 
// Error codes:
const OCCURRENCE_ERRORS = {
  TEMPLATE_NOT_FOUND: "OCC_001",
  EMPTY_OCCURRENCE_LIST: "OCC_002",
  BATCH_SIZE_EXCEEDED: "OCC_003",
  SHOW_NOT_FOUND: "SHOW_001",
} as const;
type OccurrenceError = keyof typeof OCCURRENCE_ERRORS;
 
// Reservation error codes:
const RESERVATION_ERRORS = {
  NOT_FOUND: "RES_001",
  ALREADY_CANCELLED: "RES_002",
  ADDON_NOT_FOUND: "ADD_001",
} as const;
type ReservationError = keyof typeof RESERVATION_ERRORS;

3. Convex Real-time Subscription Pattern

Dashboard widgets must subscribe to real-time data using useQuery:

// Dashboard — real-time widget pattern
"use client";
// Each useQuery call auto-subscribes to Convex real-time updates
const todayOccurrences = useQuery(api.occurrences.getToday, {});
const pendingOrders = useQuery(api.orders.countPending, {});
const pendingChallenges = useQuery(api.challenges.countPending, {});
const revenueToday = useQuery(api.orders.getRevenueToday, {});
 
// For individual occurrence detail
const occurrence = useQuery(api.occurrences.getById, { id: occurrenceId });
 
// For reservation list — paginated
const { reservations, nextCursor } = useQuery(api.reservations.listPaginated, {
  limit: 20,
  occurrenceId: undefined,
  paymentStatus: filterStatus || undefined,
  emailSearch: search || undefined,
  cursor: undefined,
}) ?? { reservations: [], nextCursor: undefined };

Do NOT call useQuery in server components — only in client components ("use client").


4. Mobile/Responsive Considerations

  • Sidebar: Collapses to hamburger menu on mobile. Use useState for open/close toggle.
  • Occurrence calendar: Monthly grid becomes weekly view on mobile. Touch-friendly occurrence chips.
  • Reservation table: Horizontal scroll with sticky columns on mobile. Filter bar stacks vertically.
  • Batch generation form: Full-width inputs on mobile. Preview list scrollable.
  • Dashboard widgets: 2-column grid on tablet, 4-column on desktop. Stacked on mobile.

5. PWA / Offline Behavior

Admin dashboard should NOT be a PWA — admin operations require authentication and should always be online to ensure data consistency.


6. i18n / next-intl Requirements

All UI strings must use getTranslations/useTranslations. Never hardcoded user-facing strings.

{
  "admin": {
    "nav": {
      "holAdmin": "HOL Admin",
      "dashboard": "Dashboard",
      "shows": "Shows",
      "occurrences": "Calendar",
      "reservations": "Reservations",
      "tables": "Tables",
      "menu": "Menu",
      "addons": "Add-ons",
      "challenges": "Challenges",
      "reports": "Reports"
    },
    "dashboard": {
      "todaysShows": "Today's Shows",
      "scheduled": "scheduled",
      "openOrders": "Open Orders",
      "inKitchen": "in kitchen",
      "pendingReviews": "Pending Reviews",
      "awaitingApproval": "awaiting approval",
      "revenueToday": "Revenue Today"
    },
    "shows": {
      "title": "Shows",
      "addNew": "Add New Show",
      "columns": {
        "title": "Title",
        "slug": "Slug",
        "status": "Status",
        "price": "Price",
        "actions": "Actions"
      },
      "edit": "Edit"
    },
    "reservations": {
      "filterAllStatuses": "All statuses",
      "statusPending": "PENDING",
      "statusPaid": "PAID",
      "statusCancelled": "CANCELLED",
      "statusRefunded": "REFUNDED",
      "searchPlaceholder": "Search by name or email"
    },
    "occurrences": {
      "batch": {
        "preview": "Preview",
        "occurrencesCreated": "occurrences will be created",
        "creating": "Creating...",
        "confirmCreate": "Confirm & Create"
      }
    }
  }
}

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-backoffice.spec.ts
test("AB-E2E-1.1: Admin auth redirect", async ({ page }) => {
  // Given: User is not authenticated
  // When: User navigates to /admin
  // Then: User is redirected to sign-in page
  await page.goto("http://localhost:3000/admin");
  await expect(page).toHaveURL(/sign-in/);
});
 
test("AB-E2E-1.2: Staff can view reservations but not edit", async ({
  page,
}) => {
  // Given: Staff user is authenticated
  // When: Staff navigates to /admin/reservations
  // Then: Reservation table is visible but no cancel buttons appear
  await signInAsStaff(page);
  await page.goto("http://localhost:3000/admin/reservations");
  await expect(page.locator("table")).toBeVisible();
  await expect(page.getByRole("button", { name: /cancel/i })).not.toBeVisible();
});
 
test("AB-E2E-2.1: Admin can create new show", async ({ page }) => {
  // Given: Admin is authenticated and on shows list page
  // When: Admin clicks "Add New Show" button
  // Then: Create show modal opens
  await signInAsAdmin(page);
  await page.goto("http://localhost:3000/admin/shows");
  await page.getByRole("button", { name: /add new show/i }).click();
  await expect(page.getByText(/create show/i)).toBeVisible();
});
 
test("AB-E2E-3.1: Admin can batch generate occurrences", async ({ page }) => {
  // Given: Admin is authenticated and on 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 occurrences created
});

Unit Tests (Vitest) — Batch Generation

// __tests__/admin/occurrences.test.ts
import { describe, it, expect, vi } from "vitest";
 
describe("createBatch", () => {
  it("AB-UT01: rejects empty occurrence list", async () => {
    // Given: Empty occurrence array
    const ctx = mockCtx();
    // When: createBatch is called with empty array
    // Then: Error is thrown about no occurrences provided
    await expect(
      createBatch({ templateId: "test", occurrences: [] }),
    ).rejects.toThrow("No occurrences provided");
  });
 
  it("AB-UT02: rejects invalid date format", async () => {
    // Given: Occurrence with invalid date format
    const ctx = mockCtx();
    // When: createBatch is called with invalid date
    // Then: Validation error is thrown
    await expect(
      createBatch({
        templateId: "test",
        occurrences: [{ date: "invalid", time: "19:00" }],
      }),
    ).rejects.toThrow();
  });
 
  it("AB-UT03: rejects batch larger than 100", async () => {
    // Given: Batch with more than 100 occurrences
    const ctx = mockCtx();
    const manyOccurrences = Array.from({ length: 101 }, (_, i) => ({
      date: `2026-05-${String(i + 1).padStart(2, "0")}`,
      time: "19:00",
    }));
    // When: createBatch is called with oversized batch
    // Then: Error is thrown about batch size limit
    await expect(
      createBatch({ templateId: "test", occurrences: manyOccurrences }),
    ).rejects.toThrow("Cannot create more than 100 occurrences at once");
  });
 
  it("AB-UT04: creates occurrences for valid input", async () => {
    // Given: Valid template and occurrence list
    const mockTemplate = { defaultCapacity: 50 };
    const ctx = mockCtx({
      db: {
        get: vi.fn().mockResolvedValue(mockTemplate),
        insert: vi.fn().mockResolvedValue("occ-new-id"),
      },
    });
    // When: createBatch is called with valid data
    // Then: Occurrences are created and returned
    const result = await createBatch({
      templateId: "template-1",
      occurrences: [
        { date: "2026-05-15", time: "19:30" },
        { date: "2026-05-16", time: "19:30" },
      ],
    });
    expect(result).toHaveLength(2);
    expect(ctx.db.insert).toHaveBeenCalledTimes(2);
  });
});

Unit Tests (Vitest) — Reservation Filtering

// __tests__/admin/reservations.test.ts
describe("reservation filtering", () => {
  it("AB-UT05: filters by PENDING status", async () => {
    // Given: Reservations with mixed statuses
    const ctx = mockCtx();
    // When: listPaginated is called with paymentStatus=PENDING
    // Then: Only PENDING reservations are returned
    const result = await listPaginated({ paymentStatus: "PENDING", limit: 20 });
    expect(
      result.reservations.every((r) => r.paymentStatus === "PENDING"),
    ).toBe(true);
  });
 
  it("AB-UT06: returns empty for no matches", async () => {
    // Given: No reservations match the filter
    const ctx = mockCtx();
    // When: listPaginated is called with non-existent status
    // Then: Empty array is returned
    const result = await listPaginated({
      paymentStatus: "NONEXISTENT",
      limit: 20,
    });
    expect(result.reservations).toHaveLength(0);
  });
 
  it("AB-UT07: filters by date range", async () => {
    // Given: Reservations across multiple dates
    const ctx = mockCtx();
    // When: listPaginated is called with specific date
    // Then: Only reservations on that date are returned
    const result = await listPaginated({ date: "2026-05-15", limit: 20 });
    expect(result.reservations.every((r) => r.date === "2026-05-15")).toBe(
      true,
    );
  });
});

Component Tests (Vitest + RTL)

// __tests__/components/show-form.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { ShowForm } from "~/components/admin/show-form";
 
describe("ShowForm", () => {
  it("AB-CT01: renders create title when no showId provided", () => {
    // Given: ShowForm component with no showId
    // When: Component renders
    // Then: "Create Show" title is visible
    render(<ShowForm onClose={() => {}} />);
    expect(screen.getByText("Create Show")).toBeInTheDocument();
  });
 
  it("AB-CT02: renders edit title when showId provided", () => {
    // Given: ShowForm component with showId
    // When: Component renders
    // Then: "Edit Show" title is visible
    render(<ShowForm showId="show-123" onClose={() => {}} />);
    expect(screen.getByText("Edit Show")).toBeInTheDocument();
  });
 
  it("AB-CT03: shows validation error for empty title", async () => {
    // Given: ShowForm with empty submission
    render(<ShowForm onClose={() => {}} />);
    // When: User submits without entering title
    // Then: Validation error is displayed
    fireEvent.click(screen.getByRole("button", { name: /submit/i }));
    expect(screen.getByText(/title is required/i)).toBeInTheDocument();
  });
});

9. Cross-Plan Dependencies

Depends OnShared Schema
01-foundationAll tables, auth helpers (staffMutation, adminMutation)
10-cancellation-refundUses reservations table, adds cancel mutations
12-d1-auto-ruleReads showOccurrences, writes showOnlyEnabled
03-admin-dashboardOverlapping — consolidate implementations

10. Performance Considerations

  • Paginate all list queries: Never call useQuery(api.reservations.list) without pagination — use cursor-based pagination for large datasets.
  • Batch mutation efficiency: createBatch inserts occurrences one-by-one in a loop. For 100+ occurrences, consider async mutation execution.
  • Analytics queries: averageOccupancyLast30Days, revenueLast30Days do full collection scans. Add dedicated indexes and consider caching via Convex query caching.
  • nuqs state: Each useQueryState call adds a URL param. Keep filter count reasonable (max 5 filters per page).
  • Required indexes: Ensure these indexes exist in schema:
    • showOccurrences.by_date — required for calendar and D-1 automation
    • reservations.by_paymentStatus — required for analytics and cancellation
    • reservations.by_occurrence — required for reservation list filtering

Consistency Audit: admin-backoffice-plan

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
P0-1Phase 3 (Show CRUD)as any type assertion in ShowForm handleSubmit[FIXED] Changed to Zod safeParse() with showTemplateSchema
P0-2Phase 5 (Reservation)Dynamic URL segment [id] for detail view[FIXED] Changed to useQueryState from nuqs — URL pattern: /admin/reservations?selectedId={id}
P0-3All mutationsMissing v.id() for ID fields[FIXED] All ID args use v.id("tableName")

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, BatchGenerationFormMissing useTransition[FIXED] Added to filter state updates
P1-4Pages with async dataMissing Suspense boundary[FIXED] Added <Suspense> wrappers
P1-5Sidebar navEmoji in UI[FIXED] Replaced with IconSymbol component
P1-6Admin layoutRole hardcoded[FIXED] Fetch role from Clerk publicMetadata

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
GAP-1staffMutation not yet implemented in convex/auth.tsfoundation-plan implements staffMutation — privileged mutations 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-3notifications 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 enrichment defines generateBatch with days-of-week pattern; admin-backoffice defines createBatch with date-list pattern. Choose one canonical implementation. 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 all admin forms (lib/schemas/admin-show.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