Admin Backoffice Implementation Plan
Spec file:
docs/superpowers/specs/03-admin-backoffice.mdFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 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
staffMutationandadminMutationhelpers do NOT currently exist inconvex/auth.ts— they must be implemented in foundation-plan first. This plan correctly usesmutation()with inline auth check as a workaround until foundation-plan implements them. The P0 GAP is noted in each affected step.
[P1 OVERLAP NOTE]: This plan overlaps significantly with
admin-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 listPhase 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 havepublicMetadata.roleset 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
IconSymbolcomponent, 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. UsenuqsuseQueryStateinstead. URL pattern:/admin/shows?selectedId={showId}
[Spec Violation Fix]: The spec file
03-admin-backoffice.mdline 157 usesshows/[id]/page.tsxwith 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 ofas anytype 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. Usev.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
createBatchmutation to Convex
[P1 Fix]:
v.id()validators must be used for ID fields to ensure type safety. Do NOT usev.string()for ID args.
[Naming Consistency Note]: The admin-dashboard plan defines
generateBatchin its enrichment section. This plan definescreateBatch. Choose one as the canonical implementation and use it consistently. The function signature differs slightly —createBatchtakes a separatetemplateId+occurrences[]whilegenerateBatchtakesdaysOfWeek[]+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
useQueryStatefromnuqsfor URL state — NOTuseState+ 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
- Admin layout requires auth, shows role-appropriate nav
- Dashboard shows real-time: today's shows count, open orders, pending challenges, revenue
- At-risk occurrences (< 30% capacity) displayed on dashboard
- Show list page: CRUD operations work, "Add New Show" button navigates to form via nuqs
- Occurrence calendar: monthly view, color-coded dots, click to expand day
- Batch generation: select template, date range, time slots, days → preview → create
- Reservation list: filters by date, status, search by name/email work via nuqs
- 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
useStatefor 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.cloud8. 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 On | Shared Schema |
|---|---|
| 01-foundation | All tables, auth helpers (staffMutation, adminMutation) |
| 10-cancellation-refund | Uses reservations table, adds cancel mutations |
| 12-d1-auto-rule | Reads showOccurrences, writes showOnlyEnabled |
| 03-admin-dashboard | Overlapping — 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:
createBatchinserts occurrences one-by-one in a loop. For 100+ occurrences, consider async mutation execution. - Analytics queries:
averageOccupancyLast30Days,revenueLast30Daysdo full collection scans. Add dedicated indexes and consider caching via Convexquerycaching. - nuqs state: Each
useQueryStatecall 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 automationreservations.by_paymentStatus— required for analytics and cancellationreservations.by_occurrence— required for reservation list filtering
Consistency Audit: admin-backoffice-plan
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-1 | Phase 3 (Show CRUD) | as any type assertion in ShowForm handleSubmit | [FIXED] Changed to Zod safeParse() with showTemplateSchema |
| P0-2 | Phase 5 (Reservation) | Dynamic URL segment [id] for detail view | [FIXED] Changed to useQueryState from nuqs — URL pattern: /admin/reservations?selectedId={id} |
| P0-3 | All mutations | Missing v.id() for ID fields | [FIXED] All ID args use v.id("tableName") |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-1 | All Convex functions | console.log usage | [FIXED] Changed to consola from consola package |
| P1-2 | UI components | Hardcoded strings | [FIXED] All use useTranslations/getTranslations |
| P1-3 | AdminReservationsPage, BatchGenerationForm | Missing useTransition | [FIXED] Added to filter state updates |
| P1-4 | Pages with async data | Missing Suspense boundary | [FIXED] Added <Suspense> wrappers |
| P1-5 | Sidebar nav | Emoji in UI | [FIXED] Replaced with IconSymbol component |
| P1-6 | Admin layout | Role hardcoded | [FIXED] Fetch role from Clerk publicMetadata |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| GAP-1 | staffMutation not yet implemented in convex/auth.ts | foundation-plan implements staffMutation — privileged mutations blocked until foundation-plan completes |
| GAP-2 | adminMutation not yet implemented in convex/auth.ts | foundation-plan implements adminMutation — privileged mutations blocked until foundation-plan completes |
| GAP-3 | notifications table not defined in schema | foundation-plan must add notifications table (referenced by d1-auto-rule plan) |
Cross-Plan Naming Consistency
| Issue | Details |
|---|---|
generateBatch vs createBatch | admin-dashboard 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) oruseTranslations(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
astype assertions used anywhere in plan code v.id()validators used for all Convex ID fields
Security
- All privileged mutations use
staffMutationoradminMutationwrappers (after foundation-plan implements them) - Clerk middleware protects
/admin/*routes - Role fetched from Clerk
publicMetadata, not hardcoded
Design Tokens
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] | Page background |
accent | #C5A059 | text-[#C5A059] | Gold primary, links |
text | #e6e6e6 | text-[#e6e6e6] | Body text |
muted | #808080 | text-[#808080] | Secondary text |
border | #333333 | border-[#333333] | Borders |