plans
2026-05-11
2026 05 11 Admin Event Payments Dashboard

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/payment

Task 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

  • getPaidReservationsByEvent query exists in reservations.ts
  • getEventWithAvailability query exists in events.ts
  • EventPaymentsContext provider works with nuqs state
  • EventSelector component shows event list with capacity
  • PaymentList component shows PAID reservations for selected event
  • PaymentDetail component 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

  1. Spec coverage: Per event → paid payments → booking info with ticket rows? Yes — EventSelector → PaymentList → PaymentDetail.

  2. Placeholder scan: No placeholders — all code complete.

  3. Type consistency: getPaidReservationsByEvent returns paymentStatus: z.enum(["PENDING", "PAID", ...]) but only filters PAID — correct. PaymentDetail uses selectedReservation which is from getById — same field names used throughout.

  4. URL state: Both eventId and paymentId are 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