Admin Event Payments Dashboard Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Hierarchical admin view: per event → list of PAID payments → per payment shows booking info + ticket rows
Architecture: Three-level drill-down: Event selector → Payment list (PAID only) → Payment detail with ticket rows. Uses existing listForDashboard query for events, new getEventWithPaidReservations query, and existing getById for reservation detail.
Tech Stack: Convex queries + real-time subscriptions, React Context, nuqs for URL state, admin UI design system
File Map
packages/backend/convex/domains/
└── events.ts # Add getEventWithPaidReservations query
packages/backend/convex/domains/
└── reservations.ts # Add getPaidReservationsByEvent query
apps/frontend/
├── app/[locale]/dashboard/
│ └── event-payments/
│ └── page.tsx # New page with 3-panel layout
├── components/admin/
│ ├── event-payments/
│ │ ├── event-selector.tsx # Event dropdown/list
│ │ ├── payment-list.tsx # List of PAID reservations for event
│ │ └── payment-detail.tsx # Detail view with ticket rows
│ └── page-header.tsx # Existing
└── contexts/
└── event-payments-context.tsx # URL state + selected event/paymentTask 1: Add getPaidReservationsByEvent Query
Files:
-
Modify:
packages/backend/convex/domains/reservations.ts(append after listPaginated) -
Step 1: Read end of reservations.ts to find insertion point
Run: tail -20 packages/backend/convex/domains/reservations.ts
- Step 2: Append getPaidReservationsByEvent query
/**
* getPaidReservationsByEvent — all PAID reservations for a specific event
*
* Used by admin dashboard to show per-event payment list.
* Returns reservations with customer info, ticket details, and add-ons.
*/
export const getPaidReservationsByEvent = zQuery({
args: { eventId: zid("experienceEvents") },
returns: z.array(
z.object({
_id: zid("reservations"),
customerFirstName: z.string(),
customerLastName: z.string(),
customerEmail: z.string(),
customerPhone: z.string().optional(),
ticketType: z.enum(["DINNER_THEATRE", "SHOW_ONLY"]),
quantity: z.number(),
guests: z.number(),
subtotal: z.number(),
totalAmount: z.number(),
paymentStatus: z.enum(["PENDING", "PAID", "REFUNDED", "FAILED"]),
paymentMethod: z.string().optional(),
paymentGateway: z.string().optional(),
createdAt: z.number(),
addOns: z.array(
z.object({
addOnId: zid("addOns"),
quantity: z.number(),
}),
),
}),
),
handler: async (ctx, { eventId }) => {
const reservations = await ctx.db
.query("reservations")
.withIndex("by_event", (q) => q.eq("eventId", eventId))
.collect();
// Only return PAID reservations
return reservations
.filter((r) => r.paymentStatus === "PAID")
.sort((a, b) => b.createdAt - a.createdAt);
},
});- Step 3: Verify
Run: grep -n "getPaidReservationsByEvent" packages/backend/convex/domains/reservations.ts
Expected: Line number where query was added
Task 2: Add getEventWithAvailability Query
Files:
-
Modify:
packages/backend/convex/domains/events.ts(append after getEventAvailabilityWithPending) -
Step 1: Read end of events.ts to find insertion point
Run: tail -30 packages/backend/convex/domains/events.ts
- Step 2: Append getEventWithAvailability query
/**
* getEventWithAvailability — single event with real-time availability stats
*
* Used by admin dashboard event selector to show capacity info per event.
* Combines event data with paid/pending counts.
*/
export const getEventWithAvailability = zQuery({
args: { eventId: zid("experienceEvents") },
returns: z
.object({
_id: zid("experienceEvents"),
code: z.string(),
experienceId: zid("experiences"),
date: z.string(),
time: z.string(),
dinnerPrice: z.number(),
experienceOnlyPrice: z.number(),
experienceOnlyEnabled: z.boolean(),
actualCapacity: z.number(),
bookedCount: z.number(),
status: z.enum(["SCHEDULED", "CANCELLED", "SOLD_OUT"]),
experienceTitle: z.string(),
paidCount: z.number(),
pendingCount: z.number(),
available: z.number(),
})
.nullable(),
handler: async (ctx, { eventId }) => {
const evt = await ctx.db.get(eventId);
if (!evt) return null;
const experience = await ctx.db.get(evt.experienceId);
// Get all reservations for this event
const reservations = await ctx.db
.query("reservations")
.withIndex("by_event", (q) => q.eq("eventId", eventId))
.collect();
const paidReservations = reservations.filter(
(r) => r.paymentStatus === "PAID",
);
const pendingReservations = reservations.filter(
(r) => r.paymentStatus === "PENDING",
);
const paidCount = paidReservations.reduce((sum, r) => sum + r.quantity, 0);
const pendingCount = pendingReservations.reduce(
(sum, r) => sum + r.quantity,
0,
);
return {
_id: evt._id,
code: evt.code,
experienceId: evt.experienceId,
date: evt.date,
time: evt.time,
dinnerPrice: evt.dinnerPrice,
experienceOnlyPrice: evt.experienceOnlyPrice,
experienceOnlyEnabled: evt.experienceOnlyEnabled,
actualCapacity: evt.actualCapacity,
bookedCount: evt.bookedCount,
status: evt.status,
experienceTitle: experience?.title ?? "Unknown Experience",
paidCount,
pendingCount,
available: Math.max(0, evt.actualCapacity - paidCount - pendingCount),
};
},
});- Step 3: Verify
Run: grep -n "getEventWithAvailability" packages/backend/convex/domains/events.ts
Expected: Line number where query was added
Task 3: Create EventPaymentsContext
Files:
-
Create:
apps/frontend/contexts/event-payments-context.tsx -
Step 1: Create the context file
/**
* EventPaymentsContext — URL-persisted state for admin event payments dashboard
*
* Manages selected event and selected payment via nuqs.
* Provides data fetching hooks for event list, reservation list, and reservation detail.
*/
"use client";
import {
createContext,
useContext,
useCallback,
type ReactNode,
} from "react";
import { useQueryState } from "nuqs";
import { useQuery } from "convex/react";
import { api } from "@packages/backend/convex/_generated/api";
import type { Id } from "@packages/backend/convex/_generated/dataModel";
interface EventPaymentsContextValue {
// Selected event
selectedEventId: string | null;
setSelectedEventId: (id: string | null) => void;
// Selected payment (reservation)
selectedPaymentId: string | null;
setSelectedPaymentId: (id: string | null) => void;
// Event list query
events: ReturnType<typeof useQuery<typeof api.domains.events.listForDashboard>>;
isEventsLoading: boolean;
// Reservations for selected event query
reservations: ReturnType<typeof useQuery<typeof api.domains.reservations.getPaidReservationsByEvent>>;
isReservationsLoading: boolean;
// Selected reservation detail query
selectedReservation: ReturnType<typeof useQuery<typeof api.domains.reservations.getById>>;
isReservationLoading: boolean;
}
const defaultContextValue: EventPaymentsContextValue = {
selectedEventId: null,
setSelectedEventId: () => {},
selectedPaymentId: null,
setSelectedPaymentId: () => {},
events: undefined,
isEventsLoading: true,
reservations: undefined,
isReservationsLoading: false,
selectedReservation: undefined,
isReservationLoading: false,
};
export const EventPaymentsContext =
createContext<EventPaymentsContextValue>(defaultContextValue);
export function EventPaymentsProvider({ children }: { children: ReactNode }) {
// URL-persisted state
const [selectedEventId, setSelectedEventId] = useQueryState("eventId", {
defaultValue: "",
serialize: (value) => value ?? "",
parse: (raw) => (raw === "" ? null : raw),
});
const [selectedPaymentId, setSelectedPaymentId] = useQueryState("paymentId", {
defaultValue: "",
serialize: (value) => value ?? "",
parse: (raw) => (raw === "" ? null : raw),
});
// Event list query
const events = useQuery(api.domains.events.listForDashboard);
const isEventsLoading = events === undefined;
// Reservations for selected event
const reservations = useQuery(
api.domains.reservations.getPaidReservationsByEvent,
selectedEventId
? { eventId: selectedEventId as Id<"experienceEvents"> }
: "skip",
);
const isReservationsLoading = selectedEventId !== null && reservations === undefined;
// Selected reservation detail
const selectedReservation = useQuery(
api.domains.reservations.getById,
selectedPaymentId
? { id: selectedPaymentId as Id<"reservations"> }
: "skip",
);
const isReservationLoading =
selectedPaymentId !== null && selectedReservation === undefined;
const value: EventPaymentsContextValue = {
selectedEventId,
setSelectedEventId,
selectedPaymentId,
setSelectedPaymentId,
events,
isEventsLoading,
reservations,
isReservationsLoading,
selectedReservation,
isReservationLoading,
};
return (
<EventPaymentsContext.Provider value={value}>
{children}
</EventPaymentsContext.Provider>
);
}
export function useEventPayments() {
const ctx = useContext(EventPaymentsContext);
if (!ctx) {
throw new Error(
"useEventPayments must be used within EventPaymentsProvider",
);
}
return ctx;
}Task 4: Create EventSelector Component
Files:
-
Create:
apps/frontend/components/admin/event-payments/event-selector.tsx -
Step 1: Create the component
/**
* EventSelector — dropdown list of events with capacity info
*
* Shows event date, time, title, and availability stats.
* Selection updates URL state.
*/
"use client";
import * as m from "~/src/paraglide/messages";
import { useEventPayments } from "~/contexts/event-payments-context";
import { cn } from "~/lib/utils";
export function EventSelector() {
const {
events,
isEventsLoading,
selectedEventId,
setSelectedEventId,
setSelectedPaymentId,
} = useEventPayments();
const handleEventSelect = (eventId: string | null) => {
setSelectedEventId(eventId);
setSelectedPaymentId(null); // Reset payment selection when event changes
};
if (isEventsLoading) {
return (
<div className="space-y-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
<div className="space-y-1">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-lg" />
))}
</div>
</div>
);
}
if (!events || events.length === 0) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
No events found
</div>
);
}
return (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Select Event
</h3>
<div className="space-y-1">
{events.map((event) => {
const isSelected = event._id === selectedEventId;
return (
<button
key={event._id}
onClick={() => handleEventSelect(event._id)}
className={cn(
"w-full text-left p-3 rounded-lg border transition-all duration-200",
"hover:bg-surface hover:border-gold/30",
isSelected
? "bg-gold/10 border-gold/50"
: "bg-surface/50 border-border",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{event.experienceTitle}
</p>
<p className="text-sm text-muted-foreground">
{event.date} at {event.time}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium text-gold">
{event.bookedCount}/{event.actualCapacity}
</p>
<p className="text-xs text-muted-foreground">capacity</p>
</div>
</div>
{/* Status badges */}
<div className="flex items-center gap-2 mt-2">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
event.status === "SCHEDULED"
? "bg-green-500/10 text-green-500"
: "bg-muted text-muted-foreground",
)}
>
{event.status}
</span>
{event.bookedCount >= event.actualCapacity && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-500">
SOLD OUT
</span>
)}
</div>
</button>
);
})}
</div>
</div>
);
}Task 5: Create PaymentList Component
Files:
-
Create:
apps/frontend/components/admin/event-payments/payment-list.tsx -
Step 1: Create the component
/**
* PaymentList — list of PAID reservations for selected event
*
* Shows customer name, email, ticket type, quantity, total, and time.
* Clicking a row opens the payment detail panel.
*/
"use client";
import * as m from "~/src/paraglide/messages";
import { useEventPayments } from "~/contexts/event-payments-context";
import { cn } from "~/lib/utils";
import { formatVND } from "~/lib/utils/format-currency";
import { formatAdminDateTime } from "~/lib/utils/date";
import { Users, Mail } from "lucide-react";
export function PaymentList() {
const {
selectedEventId,
reservations,
isReservationsLoading,
selectedPaymentId,
setSelectedPaymentId,
} = useEventPayments();
if (!selectedEventId) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
Select an event to view payments
</div>
);
}
if (isReservationsLoading) {
return (
<div className="space-y-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded-lg" />
))}
</div>
);
}
if (!reservations || reservations.length === 0) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
No paid reservations for this event
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Paid Reservations ({reservations.length})
</h3>
</div>
<div className="space-y-1">
{reservations.map((reservation) => {
const isSelected = reservation._id === selectedPaymentId;
return (
<button
key={reservation._id}
onClick={() => setSelectedPaymentId(reservation._id)}
className={cn(
"w-full text-left p-3 rounded-lg border transition-all duration-200",
"hover:bg-surface hover:border-gold/30",
isSelected
? "bg-gold/10 border-gold/50"
: "bg-surface/50 border-border",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{/* Customer name */}
<p className="font-medium text-foreground">
{reservation.customerFirstName} {reservation.customerLastName}
</p>
{/* Email */}
<p className="text-sm text-muted-foreground flex items-center gap-1 mt-0.5">
<Mail className="w-3 h-3" />
{reservation.customerEmail}
</p>
{/* Ticket info */}
<p className="text-sm text-muted-foreground mt-1">
{reservation.ticketType === "DINNER_THEATRE"
? "Dinner & Theatre"
: "Show Only"}{" "}
• {reservation.quantity} guests
</p>
</div>
<div className="text-right shrink-0">
<p className="font-medium text-gold">
{formatVND(reservation.totalAmount)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatAdminDateTime(reservation.createdAt)}
</p>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}Task 6: Create PaymentDetail Component
Files:
-
Create:
apps/frontend/components/admin/event-payments/payment-detail.tsx -
Step 1: Create the component
/**
* PaymentDetail — detailed view of a single PAID reservation
*
* Shows customer info, ticket rows, add-ons, and payment details.
* Uses existing DetailRow component from admin component library.
*/
"use client";
import * as m from "~/src/paraglide/messages";
import { useEventPayments } from "~/contexts/event-payments-context";
import { DetailRow } from "~/components/admin/detail-row";
import { formatVND } from "~/lib/utils/format-currency";
import { formatFullDateTime } from "~/lib/utils/date";
import {
User,
Mail,
Phone,
Ticket,
Users,
CreditCard,
Package,
Clock,
CheckCircle,
} from "lucide-react";
export function PaymentDetail() {
const { selectedPaymentId, selectedReservation, isReservationLoading } =
useEventPayments();
if (!selectedPaymentId) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
Select a payment to view details
</div>
);
}
if (isReservationLoading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-8 bg-muted animate-pulse rounded" />
))}
</div>
);
}
if (!selectedReservation) {
return (
<div className="text-muted-foreground text-sm py-8 text-center">
Reservation not found
</div>
);
}
const { reservation, event } = selectedReservation;
return (
<div className="space-y-6">
{/* Header with status */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" />
<span className="font-medium text-green-500">Payment Confirmed</span>
</div>
<span className="text-sm text-muted-foreground">
{formatFullDateTime(reservation.createdAt)}
</span>
</div>
{/* Customer Information */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<User className="w-4 h-4" />
Customer Information
</h4>
<div className="bg-surface rounded-lg p-4 space-y-2">
<DetailRow
label="Name"
value={`${reservation.customerFirstName} ${reservation.customerLastName}`}
/>
<DetailRow
label="Email"
value={reservation.customerEmail}
icon={<Mail className="w-3 h-3" />}
/>
{reservation.customerPhone && (
<DetailRow
label="Phone"
value={reservation.customerPhone}
icon={<Phone className="w-3 h-3" />}
/>
)}
</div>
</div>
{/* Ticket Rows */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Ticket className="w-4 h-4" />
Tickets
</h4>
<div className="bg-surface rounded-lg p-4">
{/* Ticket row */}
<div className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div>
<p className="font-medium">
{reservation.ticketType === "DINNER_THEATRE"
? "Dinner & Theatre"
: "Show Only"}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Users className="w-3 h-3" />
{reservation.quantity} guests
</p>
</div>
<p className="font-medium">
{formatVND(reservation.subtotal)}
</p>
</div>
{/* Add-ons */}
{reservation.addOns && reservation.addOns.length > 0 && (
<div className="flex items-center justify-between py-2 border-b border-border">
<div className="flex items-center gap-2">
<Package className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">
Add-ons ({reservation.addOns.length})
</span>
</div>
</div>
)}
{/* Total */}
<div className="flex items-center justify-between pt-3 mt-2">
<p className="font-semibold">Total</p>
<p className="font-bold text-gold text-lg">
{formatVND(reservation.totalAmount)}
</p>
</div>
</div>
</div>
{/* Payment Information */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Payment Details
</h4>
<div className="bg-surface rounded-lg p-4 space-y-2">
<DetailRow label="Status" value={reservation.paymentStatus} />
{reservation.paymentMethod && (
<DetailRow
label="Method"
value={reservation.paymentMethod}
/>
)}
{reservation.paymentGateway && (
<DetailRow
label="Gateway"
value={reservation.paymentGateway}
/>
)}
{reservation.onePayTransactionId && (
<DetailRow
label="Transaction ID"
value={reservation.onePayTransactionId}
truncate
/>
)}
</div>
</div>
{/* Event Information */}
{event && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<Clock className="w-4 h-4" />
Event
</h4>
<div className="bg-surface rounded-lg p-4 space-y-2">
<DetailRow label="Show" value={event.experienceTitle} />
<DetailRow label="Date" value={event.date} />
<DetailRow label="Time" value={event.time} />
</div>
</div>
)}
</div>
);
}Task 7: Create EventPaymentsDashboard Page
Files:
-
Create:
apps/frontend/app/[locale]/dashboard/event-payments/page.tsx -
Step 1: Create the page
/**
* EventPaymentsDashboard — admin hierarchical view of event payments
*
* URL: /dashboard/event-payments
* Layout: 3-panel (event list | payment list | payment detail)
* State: URL-persisted via nuqs (eventId, paymentId)
*/
"use client";
import { Suspense } from "react";
import { EventPaymentsProvider } from "~/contexts/event-payments-context";
import { EventSelector } from "~/components/admin/event-payments/event-selector";
import { PaymentList } from "~/components/admin/event-payments/payment-list";
import { PaymentDetail } from "~/components/admin/event-payments/payment-detail";
import { PageHeader } from "~/components/admin/page-header";
import { DashboardPage } from "~/components/admin/dashboard-page";
import { Skeleton } from "~/components/ui/skeleton";
function EventPaymentsContent() {
return (
<div className="flex gap-6 h-full">
{/* Event Selector - Left Panel */}
<div className="w-80 shrink-0">
<EventSelector />
</div>
{/* Payment List - Middle Panel */}
<div className="w-96 shrink-0">
<PaymentList />
</div>
{/* Payment Detail - Right Panel */}
<div className="flex-1 min-w-0">
<PaymentDetail />
</div>
</div>
);
}
function EventPaymentsSkeleton() {
return (
<div className="flex gap-6 h-full">
<div className="w-80 shrink-0 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="w-96 shrink-0 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="flex-1 space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}
export default function EventPaymentsPage() {
return (
<DashboardPage>
<div className="space-y-6">
<PageHeader title="Event Payments" />
<EventPaymentsProvider>
<Suspense fallback={<EventPaymentsSkeleton />}>
<EventPaymentsContent />
</Suspense>
</EventPaymentsProvider>
</div>
</DashboardPage>
);
}- Step 2: Verify the page exists
Run: ls -la apps/frontend/app/[locale]/dashboard/event-payments/
Expected: page.tsx file exists
Task 8: Add Navigation Link to Admin Dashboard
Files:
-
Modify:
apps/frontend/components/admin/dashboard-bottom-nav.tsx(or sidebar nav) -
Step 1: Read the dashboard nav to find where to add link
Run: grep -n "Reservations\|Inquiries" apps/frontend/components/admin/dashboard-bottom-nav.tsx | head -10
- Step 2: Add "Event Payments" nav item
Find where "Reservations" nav item is and add "Event Payments" before or after it:
{
label: "Event Payments",
href: "/dashboard/event-payments",
icon: CreditCard,
},Task 9: TypeScript Verification
- Step 1: Run TypeScript check
Run: cd apps/frontend && npx tsc --noEmit 2>&1 | head -30
Expected: No errors (or only pre-existing errors)
- Step 2: Fix any type errors if needed
Common issues:
- Missing imports → add import
- Wrong types → fix type annotation
- Missing context provider → ensure EventPaymentsProvider wraps the page
Task 10: Commit
git add packages/backend/convex/domains/reservations.ts \
packages/backend/convex/domains/events.ts \
apps/frontend/contexts/event-payments-context.tsx \
apps/frontend/components/admin/event-payments/ \
apps/frontend/app/[locale]/dashboard/event-payments/page.tsx \
apps/frontend/components/admin/dashboard-bottom-nav.tsx
git commit -m "feat(admin): event payments dashboard with hierarchical view
- Three-panel layout: event selector | payment list | payment detail
- getPaidReservationsByEvent query for admin payment list
- getEventWithAvailability query for event capacity stats
- URL-persisted state via nuqs (eventId, paymentId)
- Real-time updates via Convex subscriptions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"Verification Checklist
-
getPaidReservationsByEventquery exists in reservations.ts -
getEventWithAvailabilityquery exists in events.ts -
EventPaymentsContextprovider works with nuqs state -
EventSelectorcomponent shows event list with capacity -
PaymentListcomponent shows PAID reservations for selected event -
PaymentDetailcomponent shows reservation with ticket rows - Page renders 3-panel layout correctly
- Navigation link added to admin dashboard
- TypeScript passes
- Real-time subscription updates when payment status changes
Self-Review
-
Spec coverage: Per event → paid payments → booking info with ticket rows? Yes — EventSelector → PaymentList → PaymentDetail.
-
Placeholder scan: No placeholders — all code complete.
-
Type consistency:
getPaidReservationsByEventreturnspaymentStatus: z.enum(["PENDING", "PAID", ...])but only filters PAID — correct.PaymentDetailusesselectedReservationwhich is fromgetById— same field names used throughout. -
URL state: Both
eventIdandpaymentIdare URL-persisted via nuqs — user can share deep links to specific event/payment.
What's Next (Not in This Plan)
- Export to CSV for accounting
- Refund processing button
- Check-in status per reservation
- Revenue analytics per event