plans
2026-05-10
2026 05 10 Inquiry Management Admin

Inquiry Management Admin — 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: Build admin dashboard pages for managing website form submissions (inquiries). Each form type gets its own page. Admins can view, filter, and manage submission status.

Architecture:

  • New inquirySessions Convex table tracks admin-specific fields (status, notes) separate from formSessions which handles booking flows
  • Each form type (CONTACT, PRIVATE_EVENTS, VENUE_RENTAL, WORKSHOPS, ARTIST_PROPOSAL, HOST_AN_EVENT) gets its own admin page at /dashboard/inquiries/[formType]
  • Shared InquiryTable component renders form-specific columns via a field config map
  • Slide-over detail panel shows full submission data + status/notes management

Tech Stack: Next.js App Router, Convex, Tailwind CSS v4, shadcn/ui, React Hook Form, Zod, Lucide icons, paraglide-js i18n


File Structure

packages/backend/convex/
├── schema.ts                           # Add inquirySessions table
├── domains/inquiries.ts               # NEW: Convex queries/mutations for inquiry management

apps/frontend/
├── app/[locale]/dashboard/inquiries/
│   ├── page.tsx                       # NEW: Redirect to /dashboard/inquiries/contact
│   └── [formType]/
│       └── page.tsx                   # NEW: Shared page component for all inquiry types
├── components/admin/
│   ├── inquiry-table.tsx               # NEW: Filterable table with form-specific columns
│   ├── inquiry-filters.tsx            # NEW: Status filter tabs + search
│   ├── inquiry-detail-panel.tsx        # NEW: Slide-over panel with full data + actions
│   └── inquiry-nav-item.tsx            # NEW: Nav item component
├── lib/
│   └── inquiry-config.ts               # NEW: Field config per form type (columns, labels, i18n keys)
└── messages/en.json                    # Add inquiry-related i18n keys

Navigation sidebar update:
  sidebar.tsx                           # Add inquiries nav item with sub-menu for each form type

Task 1: Add inquirySessions Table to Convex Schema

Files:

  • Modify: packages/backend/convex/schema.ts

  • Step 1: Add inquirySessions table to schema

Find the closing ); at the end of the formSessions table definition (line ~388), and add after it:

  // Inquiry management (admin overlay on formSessions)
  inquirySessions: defineTable({
    formSessionId: v.id("formSessions"), // Reference to the original form submission
    formType: v.union(
      v.literal("CONTACT"),
      v.literal("VENUE_RENTAL"),
      v.literal("PRIVATE_EVENTS"),
      v.literal("WORKSHOPS"),
      v.literal("ARTIST_PROPOSAL"),
      v.literal("HOST_AN_EVENT"),
    ),
    status: v.union(
      v.literal("NEW"),
      v.literal("READ"),
      v.literal("REPLIED"),
      v.literal("ARCHIVED"),
    ).default("NEW"),
    adminNotes: v.optional(v.string()),
    reviewedBy: v.optional(v.string()), // Admin user ID from Clerk
    reviewedAt: v.optional(v.number()), // Unix timestamp
    createdAt: v.number(),
    updatedAt: v.number(),
  })
    .index("by_formSession", ["formSessionId"])
    .index("by_formType", ["formType"])
    .index("by_status", ["status"])
    .index("by_formType_status", ["formType", "status"]),
  • Step 2: Run Convex schema validation

Run: cd packages/backend/convex && npx convex schema Expected: Schema validated successfully with no errors

  • Step 3: Commit
git add packages/backend/convex/schema.ts
git commit -m "feat(admin): add inquirySessions table for admin inquiry management"

Task 2: Create Inquiry Convex Domain (queries + mutations)

Files:

  • Create: packages/backend/convex/domains/inquiries.ts

  • Step 1: Write the Convex inquiries domain

// packages/backend/convex/domains/inquiries.ts
// SoC: Admin inquiry management — queries and mutations for inquirySessions
 
import { zQuery, zMutation, z } from "../lib/zod";
import { zid } from "convex-helpers/server/zod3";
 
const FORM_TYPES = [
  "CONTACT",
  "VENUE_RENTAL",
  "PRIVATE_EVENTS",
  "WORKSHOPS",
  "ARTIST_PROPOSAL",
  "HOST_AN_EVENT",
] as const;
 
const INQUIRY_STATUSES = ["NEW", "READ", "REPLIED", "ARCHIVED"] as const;
 
// List inquiries by form type with optional status filter
export const listByFormType = zQuery({
  args: {
    formType: z.enum(FORM_TYPES),
    status: z.enum(INQUIRY_STATUSES).optional(),
    limit: z.number().optional().default(50),
    cursor: z.number().optional(),
  },
  handler: async (ctx, { formType, status, limit = 50, cursor }) => {
    let query = ctx.db
      .query("inquirySessions")
      .withIndex("by_formType", (q) => q.eq("formType", formType));
 
    if (status) {
      query = ctx.db
        .query("inquirySessions")
        .withIndex("by_formType_status", (q) =>
          q.eq("formType", formType).eq("status", status),
        );
    }
 
    const results = await query.collect();
 
    // Sort by createdAt descending (newest first)
    results.sort((a, b) => b.createdAt - a.createdAt);
 
    // Join with formSessions to get submitted data
    const enriched = await Promise.all(
      results.slice(cursor ?? 0, (cursor ?? 0) + limit).map(async (inquiry) => {
        const formSession = await ctx.db.get(inquiry.formSessionId);
        return {
          ...inquiry,
          formData: formSession ? JSON.parse(formSession.data) : null,
          submittedAt: formSession?.createdAt ?? inquiry.createdAt,
        };
      }),
    );
 
    return enriched;
  },
});
 
// Get counts by status for a form type (for filter badges)
export const countByStatus = zQuery({
  args: { formType: z.enum(FORM_TYPES) },
  handler: async (ctx, { formType }) => {
    const all = await ctx.db
      .query("inquirySessions")
      .withIndex("by_formType", (q) => q.eq("formType", formType))
      .collect();
 
    const counts = { NEW: 0, READ: 0, REPLIED: 0, ARCHIVED: 0 };
    for (const inquiry of all) {
      counts[inquiry.status]++;
    }
    return counts;
  },
});
 
// Get single inquiry with full form data
export const getById = zQuery({
  args: { inquiryId: zid("inquirySessions") },
  handler: async (ctx, { inquiryId }) => {
    const inquiry = await ctx.db.get(inquiryId);
    if (!inquiry) return null;
 
    const formSession = await ctx.db.get(inquiry.formSessionId);
    return {
      ...inquiry,
      formData: formSession ? JSON.parse(formSession.data) : null,
      submittedAt: formSession?.createdAt ?? inquiry.createdAt,
    };
  },
});
 
// Update inquiry status
export const updateStatus = zMutation({
  args: {
    inquiryId: zid("inquirySessions"),
    status: z.enum(INQUIRY_STATUSES),
  },
  handler: async (ctx, { inquiryId, status }) => {
    const identity = ctx.identity;
    const adminId = identity?.subject;
 
    await ctx.db.patch(inquiryId, {
      status,
      reviewedBy: adminId,
      reviewedAt: Date.now(),
      updatedAt: Date.now(),
    });
    return inquiryId;
  },
});
 
// Update admin notes
export const updateNotes = zMutation({
  args: {
    inquiryId: zid("inquirySessions"),
    notes: z.string(),
  },
  handler: async (ctx, { inquiryId, notes }) => {
    await ctx.db.patch(inquiryId, {
      adminNotes: notes,
      updatedAt: Date.now(),
    });
    return inquiryId;
  },
});
 
// Create or get inquiry session when a form is submitted
// Called by a wrapper around form submission or via a trigger
export const ensureInquirySession = zMutation({
  args: {
    formSessionId: zid("formSessions"),
    formType: z.enum(FORM_TYPES),
  },
  handler: async (ctx, { formSessionId, formType }) => {
    // Check if already exists
    const existing = await ctx.db
      .query("inquirySessions")
      .withIndex("by_formSession", (q) => q.eq("formSessionId", formSessionId))
      .first();
 
    if (existing) return existing._id;
 
    const now = Date.now();
    return await ctx.db.insert("inquirySessions", {
      formSessionId,
      formType,
      status: "NEW",
      createdAt: now,
      updatedAt: now,
    });
  },
});
  • Step 2: Regenerate Convex types

Run: cd packages/backend && npx convex codegen Expected: _generated/api.d.ts updated with new inquirySessions types and api.domains.inquiries.* functions

  • Step 3: Commit
git add packages/backend/convex/domains/inquiries.ts packages/backend/convex/_generated/
git commit -m "feat(admin): add inquiries Convex domain for admin management"

Task 3: Wire Inquiry Creation into Form Submission

Files:

  • Modify: packages/backend/convex/domains/forms.ts

  • Step 1: Auto-create inquirySession when a public form is submitted

Find the submitFormData mutation (line 197). After the formSessions insert (line 214), add a call to ensureInquirySession:

// After: await ctx.db.insert("formSessions", {...});
// Add:
await ctx.db.insert("inquirySessions", {
  formSessionId: id as Id<"formSessions">,
  formType: formType as InquiryFormType,
  status: "NEW",
  createdAt: now,
  updatedAt: now,
});

Also find submitContactForm (line 59) and add the same after its insert.

Also find submitProposalForm (line 455) and add after its insert.

  • Step 2: Commit
git add packages/backend/convex/domains/forms.ts
git commit -m "feat(admin): auto-create inquirySession on form submission"

Task 4: Create Inquiry Config (field definitions per form type)

Files:

  • Create: apps/frontend/lib/inquiry-config.ts

  • Step 1: Write the inquiry config

// apps/frontend/lib/inquiry-config.ts
// SoC: Pure config — field definitions for each inquiry form type
// Used by InquiryTable to render form-specific columns
 
export type InquiryFormType =
  | "CONTACT"
  | "PRIVATE_EVENTS"
  | "VENUE_RENTAL"
  | "WORKSHOPS"
  | "ARTIST_PROPOSAL"
  | "HOST_AN_EVENT";
 
export type InquiryStatus = "NEW" | "READ" | "REPLIED" | "ARCHIVED";
 
export type FieldConfig = {
  key: string;
  labelKey: string; // paraglide message key
  type: "text" | "email" | "phone" | "date" | "select" | "textarea" | "url";
};
 
export type FormTypeConfig = {
  titleKey: string; // paraglide message key for page title
  subtitleKey: string;
  icon: string; // lucide icon name
  columns: FieldConfig[]; // columns to show in table
  detailFields: FieldConfig[]; // all fields to show in detail panel
};
 
export const INQUIRY_FORM_CONFIGS: Record<InquiryFormType, FormTypeConfig> = {
  CONTACT: {
    titleKey: "admin_inquiry_contact_title",
    subtitleKey: "admin_inquiry_contact_subtitle",
    icon: "Mail",
    columns: [
      { key: "name", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
    detailFields: [
      { key: "name", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
 
  PRIVATE_EVENTS: {
    titleKey: "admin_inquiry_privateEvents_title",
    subtitleKey: "admin_inquiry_privateEvents_subtitle",
    icon: "Calendar",
    columns: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "eventType", labelKey: "admin_inquiry_eventType", type: "text" },
      {
        key: "preferredDate",
        labelKey: "admin_inquiry_preferredDate",
        type: "date",
      },
      { key: "guestCount", labelKey: "admin_inquiry_guestCount", type: "text" },
    ],
    detailFields: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "subject", labelKey: "admin_inquiry_subject", type: "text" },
      { key: "eventType", labelKey: "admin_inquiry_eventType", type: "select" },
      {
        key: "preferredDate",
        labelKey: "admin_inquiry_preferredDate",
        type: "date",
      },
      { key: "guestCount", labelKey: "admin_inquiry_guestCount", type: "text" },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
 
  VENUE_RENTAL: {
    titleKey: "admin_inquiry_venueRental_title",
    subtitleKey: "admin_inquiry_venueRental_subtitle",
    icon: "Building",
    columns: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      {
        key: "projectType",
        labelKey: "admin_inquiry_projectType",
        type: "text",
      },
      { key: "eventDate", labelKey: "admin_inquiry_eventDate", type: "date" },
      {
        key: "estimatedParticipants",
        labelKey: "admin_inquiry_participants",
        type: "text",
      },
    ],
    detailFields: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      {
        key: "projectType",
        labelKey: "admin_inquiry_projectType",
        type: "select",
      },
      { key: "eventDate", labelKey: "admin_inquiry_eventDate", type: "date" },
      {
        key: "estimatedParticipants",
        labelKey: "admin_inquiry_participants",
        type: "text",
      },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
 
  WORKSHOPS: {
    titleKey: "admin_inquiry_workshops_title",
    subtitleKey: "admin_inquiry_workshops_subtitle",
    icon: "GraduationCap",
    columns: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      {
        key: "workshopType",
        labelKey: "admin_inquiry_workshopType",
        type: "text",
      },
      {
        key: "preferredDate",
        labelKey: "admin_inquiry_preferredDate",
        type: "date",
      },
      {
        key: "estimatedParticipants",
        labelKey: "admin_inquiry_participants",
        type: "text",
      },
    ],
    detailFields: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "subject", labelKey: "admin_inquiry_subject", type: "text" },
      {
        key: "workshopType",
        labelKey: "admin_inquiry_workshopType",
        type: "select",
      },
      {
        key: "preferredDate",
        labelKey: "admin_inquiry_preferredDate",
        type: "date",
      },
      {
        key: "estimatedParticipants",
        labelKey: "admin_inquiry_participants",
        type: "text",
      },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
 
  ARTIST_PROPOSAL: {
    titleKey: "admin_inquiry_artistProposal_title",
    subtitleKey: "admin_inquiry_artistProposal_subtitle",
    icon: "Mic",
    columns: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "artistName", labelKey: "admin_inquiry_artistName", type: "text" },
      {
        key: "performanceType",
        labelKey: "admin_inquiry_performanceType",
        type: "text",
      },
    ],
    detailFields: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "subject", labelKey: "admin_inquiry_subject", type: "text" },
      { key: "artistName", labelKey: "admin_inquiry_artistName", type: "text" },
      {
        key: "performanceType",
        labelKey: "admin_inquiry_performanceType",
        type: "select",
      },
      {
        key: "performanceLength",
        labelKey: "admin_inquiry_performanceLength",
        type: "text",
      },
      {
        key: "stageRequirements",
        labelKey: "admin_inquiry_stageRequirements",
        type: "textarea",
      },
      { key: "videoLink", labelKey: "admin_inquiry_videoLink", type: "url" },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
 
  HOST_AN_EVENT: {
    titleKey: "admin_inquiry_hostAnEvent_title",
    subtitleKey: "admin_inquiry_hostAnEvent_subtitle",
    icon: "PartyPopper",
    columns: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "eventType", labelKey: "admin_inquiry_eventType", type: "text" },
      { key: "eventDate", labelKey: "admin_inquiry_eventDate", type: "date" },
      { key: "guestCount", labelKey: "admin_inquiry_guestCount", type: "text" },
    ],
    detailFields: [
      { key: "fullName", labelKey: "common_forms_name", type: "text" },
      { key: "email", labelKey: "common_forms_email", type: "email" },
      { key: "phone", labelKey: "common_forms_phone", type: "phone" },
      { key: "subject", labelKey: "admin_inquiry_subject", type: "text" },
      { key: "eventType", labelKey: "admin_inquiry_eventType", type: "select" },
      { key: "eventDate", labelKey: "admin_inquiry_eventDate", type: "date" },
      { key: "guestCount", labelKey: "admin_inquiry_guestCount", type: "text" },
      { key: "message", labelKey: "common_forms_message", type: "textarea" },
    ],
  },
};
 
export const INQUIRY_NAV_ITEMS: Array<{
  formType: InquiryFormType;
  titleKey: string;
  icon: string;
}> = [
  { formType: "CONTACT", titleKey: "admin_inquiry_nav_contact", icon: "Mail" },
  {
    formType: "PRIVATE_EVENTS",
    titleKey: "admin_inquiry_nav_privateEvents",
    icon: "Calendar",
  },
  {
    formType: "VENUE_RENTAL",
    titleKey: "admin_inquiry_nav_venueRental",
    icon: "Building",
  },
  {
    formType: "WORKSHOPS",
    titleKey: "admin_inquiry_nav_workshops",
    icon: "GraduationCap",
  },
  {
    formType: "ARTIST_PROPOSAL",
    titleKey: "admin_inquiry_nav_artistProposal",
    icon: "Mic",
  },
  {
    formType: "HOST_AN_EVENT",
    titleKey: "admin_inquiry_nav_hostAnEvent",
    icon: "PartyPopper",
  },
];
  • Step 2: Commit
git add apps/frontend/lib/inquiry-config.ts
git commit -m "feat(admin): add inquiry config with field definitions per form type"

Task 5: Add i18n Keys for Inquiry Admin

Files:

  • Modify: apps/frontend/messages/en.json

  • Step 1: Add inquiry admin i18n keys

Add the following keys to the JSON (merge at appropriate alphabetical location or at end):

{
  "admin_inquiry_nav_contact": "Contact",
  "admin_inquiry_nav_privateEvents": "Private Events",
  "admin_inquiry_nav_venueRental": "Venue Rental",
  "admin_inquiry_nav_workshops": "Workshops",
  "admin_inquiry_nav_artistProposal": "Artist Proposals",
  "admin_inquiry_nav_hostAnEvent": "Host an Event",
  "admin_inquiry_contact_title": "Contact Inquiries",
  "admin_inquiry_contact_subtitle": "View and manage contact form submissions",
  "admin_inquiry_privateEvents_title": "Private Event Inquiries",
  "admin_inquiry_privateEvents_subtitle": "View and manage private event booking requests",
  "admin_inquiry_venueRental_title": "Venue Rental Inquiries",
  "admin_inquiry_venueRental_subtitle": "View and manage venue rental requests",
  "admin_inquiry_workshops_title": "Workshop Proposals",
  "admin_inquiry_workshops_subtitle": "View and manage workshop proposals",
  "admin_inquiry_artistProposal_title": "Artist Proposals",
  "admin_inquiry_artistProposal_subtitle": "View and manage artist performance proposals",
  "admin_inquiry_hostAnEvent_title": "Host an Event Inquiries",
  "admin_inquiry_hostAnEvent_subtitle": "View and manage event hosting requests",
  "admin_inquiry_subject": "Subject",
  "admin_inquiry_eventType": "Event Type",
  "admin_inquiry_preferredDate": "Preferred Date",
  "admin_inquiry_eventDate": "Event Date",
  "admin_inquiry_guestCount": "Guest Count",
  "admin_inquiry_participants": "Participants",
  "admin_inquiry_projectType": "Project Type",
  "admin_inquiry_workshopType": "Workshop Type",
  "admin_inquiry_artistName": "Artist Name",
  "admin_inquiry_performanceType": "Performance Type",
  "admin_inquiry_performanceLength": "Performance Length (min)",
  "admin_inquiry_stageRequirements": "Stage Requirements",
  "admin_inquiry_videoLink": "Video Link",
  "admin_inquiry_status_new": "New",
  "admin_inquiry_status_read": "Read",
  "admin_inquiry_status_replied": "Replied",
  "admin_inquiry_status_archived": "Archived",
  "admin_inquiry_all": "All",
  "admin_inquiry_notes": "Admin Notes",
  "admin_inquiry_addNote": "Add note...",
  "admin_inquiry_markRead": "Mark as Read",
  "admin_inquiry_markReplied": "Mark as Replied",
  "admin_inquiry_archive": "Archive",
  "admin_inquiry_restore": "Restore",
  "admin_inquiry_submittedAt": "Submitted",
  "admin_inquiry_noInquiries": "No inquiries yet",
  "admin_inquiry_noInquiriesDescription": "Form submissions will appear here",
  "admin_inquiry_nav_inquiries": "Inquiries"
}
  • Step 2: Commit
git add apps/frontend/messages/en.json
git commit -m "feat(admin): add i18n keys for inquiry management"

Task 6: Create Inquiry Filters Component

Files:

  • Create: apps/frontend/components/admin/inquiry-filters.tsx

  • Step 1: Write InquiryFilters component

// apps/frontend/components/admin/inquiry-filters.tsx
// SoC: Pure presentation — status filter tabs + search input
 
"use client";
 
import { Search, Mail, Calendar, Building, GraduationCap, Mic, PartyPopper } from "lucide-react";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
import * as m from "~/src/paraglide/messages";
import type { InquiryStatus, InquiryFormType } from "~/lib/inquiry-config";
 
const STATUS_TABS: Array<{ value: InquiryStatus | "ALL"; labelKey: string }> = [
  { value: "ALL", labelKey: "admin_inquiry_all" },
  { value: "NEW", labelKey: "admin_inquiry_status_new" },
  { value: "READ", labelKey: "admin_inquiry_status_read" },
  { value: "REPLIED", labelKey: "admin_inquiry_status_replied" },
  { value: "ARCHIVED", labelKey: "admin_inquiry_status_archived" },
];
 
const FORM_TYPE_ICONS: Record<InquiryFormType, React.ElementType> = {
  CONTACT: Mail,
  PRIVATE_EVENTS: Calendar,
  VENUE_RENTAL: Building,
  WORKSHOPS: GraduationCap,
  ARTIST_PROPOSAL: Mic,
  HOST_AN_EVENT: PartyPopper,
};
 
interface InquiryFiltersProps {
  formType: InquiryFormType;
  activeStatus: InquiryStatus | "ALL";
  onStatusChange: (status: InquiryStatus | "ALL") => void;
  searchQuery: string;
  onSearchChange: (query: string) => void;
  counts: Record<InquiryStatus, number>;
}
 
export function InquiryFilters({
  formType,
  activeStatus,
  onStatusChange,
  searchQuery,
  onSearchChange,
  counts,
}: InquiryFiltersProps) {
  const Icon = FORM_TYPE_ICONS[formType];
 
  const total = Object.values(counts).reduce((sum, c) => sum + c, 0);
 
  return (
    <div className="space-y-4">
      {/* Search */}
      <div className="relative max-w-sm">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-muted-foreground)]" />
        <Input
          placeholder="Search by name, email..."
          value={searchQuery}
          onChange={(e) => onSearchChange(e.target.value)}
          className="pl-9"
        />
      </div>
 
      {/* Status tabs */}
      <div className="flex items-center gap-1 border-b border-[var(--color-border)]">
        {STATUS_TABS.map((tab) => {
          const count =
            tab.value === "ALL" ? total : counts[tab.value as InquiryStatus] ?? 0;
          const isActive = activeStatus === tab.value;
 
          return (
            <button
              key={tab.value}
              onClick={() => onStatusChange(tab.value)}
              className={cn(
                "relative flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors",
                isActive
                  ? "text-[var(--color-gold)]"
                  : "text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]",
              )}
            >
              <span>{m[tab.labelKey]()}</span>
              {count > 0 && (
                <span
                  className={cn(
                    "inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs",
                    isActive
                      ? "bg-[var(--color-gold)] text-black"
                      : "bg-[var(--color-muted)] text-[var(--color-muted-foreground)]",
                  )}
                >
                  {count}
                </span>
              )}
              {isActive && (
                <span className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-gold)] rounded-full" />
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/inquiry-filters.tsx
git commit -m "feat(admin): add inquiry filters component with status tabs"

Task 7: Create Inquiry Detail Panel (Slide-over)

Files:

  • Create: apps/frontend/components/admin/inquiry-detail-panel.tsx

  • Step 1: Write InquiryDetailPanel component

// apps/frontend/components/admin/inquiry-detail-panel.tsx
// SoC: Presentation + Convex mutations — slide-over panel for viewing and managing inquiry
 
"use client";
 
import { useState, useCallback } from "react";
import { useMutation } from "convex/react";
import { X, Mail, Phone, Calendar, User, FileText, ExternalLink } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Textarea } from "~/components/ui/textarea";
import { Skeleton } from "~/components/ui/skeleton";
import { cn } from "~/lib/utils";
import * as m from "~/src/paraglide/messages";
import { formatDateShort, formatTime } from "~/lib/utils/date";
import { api } from "@packages/backend/convex/_generated/api";
import type { InquiryStatus, FormTypeConfig, InquiryFormType } from "~/lib/inquiry-config";
 
interface InquiryDetailPanelProps {
  inquiry: {
    _id: string;
    status: InquiryStatus;
    adminNotes?: string;
    reviewedAt?: number;
    formData: Record<string, unknown>;
    submittedAt: number;
  } | null;
  formType: InquiryFormType;
  config: FormTypeConfig;
  onClose: () => void;
  onStatusChange: (id: string, status: InquiryStatus) => void;
}
 
const STATUS_COLORS: Record<InquiryStatus, string> = {
  NEW: "bg-blue-500/20 text-blue-400 border-blue-500/30",
  READ: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
  REPLIED: "bg-green-500/20 text-green-400 border-green-500/30",
  ARCHIVED: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
 
const STATUS_LABELS: Record<InquiryStatus, string> = {
  NEW: "admin_inquiry_status_new",
  READ: "admin_inquiry_status_read",
  REPLIED: "admin_inquiry_status_replied",
  ARCHIVED: "admin_inquiry_status_archived",
};
 
function FieldDisplay({
  field,
  value,
}: {
  field: { key: string; labelKey: string; type: string };
  value: unknown;
}) {
  if (value === undefined || value === null || value === "") return null;
 
  const label = m[field.labelKey]?.() ?? field.labelKey;
 
  return (
    <div className="space-y-1">
      <p className="text-xs text-[var(--color-muted-foreground)] uppercase tracking-wide">
        {label}
      </p>
      <div className="flex items-start gap-2">
        {field.type === "email" && (
          <a
            href={`mailto:${String(value)}`}
            className="text-[var(--color-gold)] hover:underline flex items-center gap-1"
          >
            <Mail className="w-3.5 h-3.5 shrink-0" />
            {String(value)}
          </a>
        )}
        {field.type === "phone" && (
          <a
            href={`tel:${String(value)}`}
            className="text-[var(--color-gold)] hover:underline flex items-center gap-1"
          >
            <Phone className="w-3.5 h-3.5 shrink-0" />
            {String(value)}
          </a>
        )}
        {field.type === "url" && (
          <a
            href={String(value)}
            target="_blank"
            rel="noopener noreferrer"
            className="text-[var(--color-gold)] hover:underline flex items-center gap-1"
          >
            <ExternalLink className="w-3.5 h-3.5 shrink-0" />
            {String(value)}
          </a>
        )}
        {field.type === "date" && (
          <span className="flex items-center gap-1">
            <Calendar className="w-3.5 h-3.5 shrink-0 text-[var(--color-muted-foreground)]" />
            {String(value)}
          </span>
        )}
        {field.type === "textarea" && (
          <span className="text-[var(--color-foreground)] whitespace-pre-wrap">
            {String(value)}
          </span>
        )}
        {field.type === "text" && (
          <span className="text-[var(--color-foreground)]">{String(value)}</span>
        )}
        {!["email", "phone", "url", "date", "textarea", "text"].includes(field.type) && (
          <span className="text-[var(--color-foreground)]">{String(value)}</span>
        )}
      </div>
    </div>
  );
}
 
export function InquiryDetailPanel({
  inquiry,
  formType,
  config,
  onClose,
  onStatusChange,
}: InquiryDetailPanelProps) {
  const [notes, setNotes] = useState(inquiry?.adminNotes ?? "");
  const [isSavingNotes, setIsSavingNotes] = useState(false);
  const updateNotes = useMutation(api.domains.inquiries.updateNotes);
 
  const handleSaveNotes = useCallback(async () => {
    if (!inquiry) return;
    setIsSavingNotes(true);
    try {
      await updateNotes({ inquiryId: inquiry._id, notes });
    } finally {
      setIsSavingNotes(false);
    }
  }, [inquiry, notes, updateNotes]);
 
  const handleStatusChange = useCallback(
    (status: InquiryStatus) => {
      if (!inquiry) return;
      onStatusChange(inquiry._id, status);
    },
    [inquiry, onStatusChange],
  );
 
  const statusOrder: InquiryStatus[] = ["NEW", "READ", "REPLIED", "ARCHIVED"];
 
  return (
    <>
      {/* Backdrop */}
      <div
        className="fixed inset-0 bg-black/40 z-40 animate-fade-in"
        onClick={onClose}
      />
 
      {/* Panel */}
      <div className="fixed right-0 top-0 h-full w-full max-w-lg bg-[var(--color-background)] border-l border-[var(--color-border)] z-50 animate-slide-in-from-right overflow-y-auto">
        {inquiry === null ? (
          <div className="p-8 text-center">
            <Skeleton className="h-4 w-32 mx-auto mb-2" />
            <Skeleton className="h-3 w-48 mx-auto" />
          </div>
        ) : (
          <div className="flex flex-col h-full">
            {/* Header */}
            <div className="shrink-0 p-6 border-b border-[var(--color-border)]">
              <div className="flex items-start justify-between gap-4">
                <div>
                  <h2 className="text-lg font-serif text-[var(--color-gold)]">
                    {m[config.titleKey]?.() ?? formType}
                  </h2>
                  <p className="text-sm text-[var(--color-muted-foreground)] mt-1">
                    {m.admin_inquiry_submittedAt()}: {formatDateShort(new Date(inquiry.submittedAt).toISOString())} at{" "}
                    {formatTime(new Date(inquiry.submittedAt).toISOString())}
                  </p>
                </div>
                <Button
                  variant="ghost"
                  size="sm"
                  onClick={onClose}
                  className="h-auto p-1 shrink-0"
                >
                  <X className="w-5 h-5" />
                </Button>
              </div>
 
              {/* Status badge + quick actions */}
              <div className="flex items-center gap-2 mt-4">
                <span
                  className={cn(
                    "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
                    STATUS_COLORS[inquiry.status],
                  )}
                >
                  {m[STATUS_LABELS[inquiry.status]]?.() ?? inquiry.status}
                </span>
                {inquiry.status === "NEW" && (
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleStatusChange("READ")}
                    className="h-auto py-1 text-xs"
                  >
                    {m.admin_inquiry_markRead()}
                  </Button>
                )}
                {inquiry.status === "READ" && (
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleStatusChange("REPLIED")}
                    className="h-auto py-1 text-xs"
                  >
                    {m.admin_inquiry_markReplied()}
                  </Button>
                )}
                {inquiry.status !== "ARCHIVED" && (
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleStatusChange("ARCHIVED")}
                    className="h-auto py-1 text-xs"
                  >
                    {m.admin_inquiry_archive()}
                  </Button>
                )}
                {inquiry.status === "ARCHIVED" && (
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => handleStatusChange("NEW")}
                    className="h-auto py-1 text-xs"
                  >
                    {m.admin_inquiry_restore()}
                  </Button>
                )}
              </div>
            </div>
 
            {/* Form data fields */}
            <div className="flex-1 p-6 space-y-5">
              {config.detailFields.map((field) => (
                <FieldDisplay
                  key={field.key}
                  field={field}
                  value={inquiry.formData?.[field.key]}
                />
              ))}
            </div>
 
            {/* Admin notes + status timeline */}
            <div className="shrink-0 p-6 border-t border-[var(--color-border)] space-y-4">
              <div>
                <label className="text-xs text-[var(--color-muted-foreground)] uppercase tracking-wide block mb-2">
                  {m.admin_inquiry_notes()}
                </label>
                <Textarea
                  value={notes}
                  onChange={(e) => setNotes(e.target.value)}
                  placeholder={m.admin_inquiry_addNote()}
                  rows={3}
                  className="resize-none"
                />
                <div className="flex justify-end mt-2">
                  <Button
                    variant="gold"
                    size="sm"
                    onClick={handleSaveNotes}
                    disabled={isSavingNotes || notes === (inquiry.adminNotes ?? "")}
                  >
                    {isSavingNotes ? "Saving..." : "Save Note"}
                  </Button>
                </div>
              </div>
            </div>
          </div>
        )}
      </div>
    </>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/inquiry-detail-panel.tsx
git commit -m "feat(admin): add inquiry detail panel with slide-over and status management"

Task 8: Create Inquiry Table Component

Files:

  • Create: apps/frontend/components/admin/inquiry-table.tsx

  • Step 1: Write InquiryTable component

// apps/frontend/components/admin/inquiry-table.tsx
// SoC: Pure presentation — renders rows based on form type config
 
"use client";
 
import { useState, useMemo } from "react";
import { formatDateShort, formatTime } from "~/lib/utils/date";
import { cn } from "~/lib/utils";
import { Skeleton } from "~/components/ui/skeleton";
import type { FormTypeConfig, InquiryFormType } from "~/lib/inquiry-config";
 
type InquiryStatus = "NEW" | "READ" | "REPLIED" | "ARCHIVED";
 
type InquiryRow = {
  _id: string;
  status: InquiryStatus;
  adminNotes?: string;
  formData: Record<string, unknown>;
  submittedAt: number;
};
 
const STATUS_COLORS: Record<InquiryStatus, string> = {
  NEW: "bg-blue-500/20 text-blue-400",
  READ: "bg-yellow-500/20 text-yellow-400",
  REPLIED: "bg-green-500/20 text-green-400",
  ARCHIVED: "bg-gray-500/20 text-gray-400",
};
 
interface InquiryTableProps {
  inquiries: InquiryRow[] | undefined;
  isLoading: boolean;
  formType: InquiryFormType;
  config: FormTypeConfig;
  onRowClick: (inquiry: InquiryRow) => void;
}
 
function TableCell({
  field,
  value,
}: {
  field: { key: string; labelKey: string; type: string };
  value: unknown;
}) {
  if (value === undefined || value === null || value === "") {
    return <span className="text-[var(--color-muted-foreground)]">—</span>;
  }
 
  if (field.type === "email") {
    return (
      <a href={`mailto:${String(value)}`} className="text-[var(--color-gold)] hover:underline">
        {String(value)}
      </a>
    );
  }
 
  return <span className="truncate max-w-[200px] block">{String(value)}</span>;
}
 
export function InquiryTable({
  inquiries,
  isLoading,
  formType,
  config,
  onRowClick,
}: InquiryTableProps) {
  if (isLoading) {
    return (
      <div className="space-y-2">
        <Skeleton className="h-12 w-full" />
        <Skeleton className="h-12 w-full" />
        <Skeleton className="h-12 w-full" />
      </div>
    );
  }
 
  if (!inquiries || inquiries.length === 0) {
    return null;
  }
 
  return (
    <div className="border border-[var(--color-border)] rounded-lg overflow-hidden">
      <table className="w-full">
        <thead>
          <tr className="border-b border-[var(--color-border)] bg-[var(--color-muted)]/30">
            <th className="text-left text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide px-4 py-3">
              Date
            </th>
            {config.columns.map((col) => (
              <th
                key={col.key}
                className="text-left text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide px-4 py-3"
              >
                {col.labelKey}
              </th>
            ))}
            <th className="text-left text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide px-4 py-3">
              Status
            </th>
          </tr>
        </thead>
        <tbody className="divide-y divide-[var(--color-border)]">
          {inquiries.map((inquiry) => (
            <tr
              key={inquiry._id}
              onClick={() => onRowClick(inquiry)}
              className="cursor-pointer hover:bg-[var(--color-muted)]/20 transition-colors"
            >
              <td className="px-4 py-3">
                <div className="text-sm">
                  {formatDateShort(new Date(inquiry.submittedAt).toISOString())}
                </div>
                <div className="text-xs text-[var(--color-muted-foreground)]">
                  {formatTime(new Date(inquiry.submittedAt).toISOString())}
                </div>
              </td>
              {config.columns.map((col) => (
                <td key={col.key} className="px-4 py-3 text-sm">
                  <TableCell field={col} value={inquiry.formData?.[col.key]} />
                </td>
              ))}
              <td className="px-4 py-3">
                <span
                  className={cn(
                    "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
                    STATUS_COLORS[inquiry.status],
                  )}
                >
                  {inquiry.status}
                </span>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/inquiry-table.tsx
git commit -m "feat(admin): add inquiry table component with form-specific columns"

Task 9: Create Inquiry Admin Page

Files:

  • Create: apps/frontend/app/[locale]/dashboard/inquiries/[formType]/page.tsx

  • Step 1: Write the inquiry admin page

// apps/frontend/app/[locale]/dashboard/inquiries/[formType]/page.tsx
// SoC: Page component — fetches data, composes filters + table + detail panel
 
"use client";
 
import { useState, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "convex/react";
import { Mail, Calendar, Building, GraduationCap, Mic, PartyPopper } from "lucide-react";
import { DashboardPage } from "~/components/admin/dashboard-page";
import { InquiryFilters } from "~/components/admin/inquiry-filters";
import { InquiryTable } from "~/components/admin/inquiry-table";
import { InquiryDetailPanel } from "~/components/admin/inquiry-detail-panel";
import { Skeleton } from "~/components/ui/skeleton";
import { Card } from "~/components/ui/card";
import * as m from "~/src/paraglide/messages";
import { api } from "@packages/backend/convex/_generated/api";
import {
  INQUIRY_FORM_CONFIGS,
  INQUIRY_NAV_ITEMS,
  type InquiryFormType,
  type InquiryStatus,
} from "~/lib/inquiry-config";
 
const FORM_TYPE_ICONS: Record<InquiryFormType, React.ElementType> = {
  CONTACT: Mail,
  PRIVATE_EVENTS: Calendar,
  VENUE_RENTAL: Building,
  WORKSHOPS: GraduationCap,
  ARTIST_PROPOSAL: Mic,
  HOST_AN_EVENT: PartyPopper,
};
 
const VALID_FORM_TYPES: InquiryFormType[] = [
  "CONTACT",
  "PRIVATE_EVENTS",
  "VENUE_RENTAL",
  "WORKSHOPS",
  "ARTIST_PROPOSAL",
  "HOST_AN_EVENT",
];
 
type InquiryRow = {
  _id: string;
  status: InquiryStatus;
  adminNotes?: string;
  formData: Record<string, unknown>;
  submittedAt: number;
};
 
export default function InquiryFormTypePage({
  params,
}: {
  params: { formType: string };
}) {
  const formType = params.formType.toUpperCase() as InquiryFormType;
 
  // Validate form type
  if (!VALID_FORM_TYPES.includes(formType)) {
    return (
      <DashboardPage>
        <div className="text-center py-12">
          <p className="text-[var(--color-muted-foreground)]">
            Invalid inquiry type: {formType}
          </p>
        </div>
      </DashboardPage>
    );
  }
 
  const config = INQUIRY_FORM_CONFIGS[formType];
  const Icon = FORM_TYPE_ICONS[formType];
 
  const [activeStatus, setActiveStatus] = useState<InquiryStatus | "ALL">("ALL");
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedInquiry, setSelectedInquiry] = useState<InquiryRow | null>(null);
 
  // Fetch inquiries
  const inquiries = useQuery(api.domains.inquiries.listByFormType, {
    formType,
    status: activeStatus === "ALL" ? undefined : activeStatus,
    limit: 100,
  }) as InquiryRow[] | undefined;
 
  // Fetch counts for filter badges
  const counts = useQuery(api.domains.inquiries.countByStatus, {
    formType,
  }) as Record<InquiryStatus, number> | undefined;
 
  const updateStatus = useMutation(api.domains.inquiries.updateStatus);
 
  const handleStatusChange = useCallback(
    async (inquiryId: string, status: InquiryStatus) => {
      await updateStatus({ inquiryId, status });
      // Update local state
      setSelectedInquiry((prev) =>
        prev?._id === inquiryId ? { ...prev, status } : prev,
      );
    },
    [updateStatus],
  );
 
  // Filter by search query
  const filteredInquiries = useMemo(() => {
    if (!inquiries) return [];
    if (!searchQuery.trim()) return inquiries;
 
    const query = searchQuery.toLowerCase();
    return inquiries.filter((inq) => {
      const data = inq.formData as Record<string, unknown>;
      return (
        (data.fullName as string)?.toLowerCase().includes(query) ||
        (data.name as string)?.toLowerCase().includes(query) ||
        (data.email as string)?.toLowerCase().includes(query) ||
        (data.subject as string)?.toLowerCase().includes(query)
      );
    });
  }, [inquiries, searchQuery]);
 
  const defaultCounts: Record<InquiryStatus, number> = { NEW: 0, READ: 0, REPLIED: 0, ARCHIVED: 0 };
 
  return (
    <DashboardPage>
      {/* Page header */}
      <div className="flex items-center gap-3 mb-2">
        <Icon className="w-6 h-6 text-[var(--color-gold)]" />
        <div>
          <h1 className="text-2xl font-serif text-[var(--color-gold)]">
            {m[config.titleKey]?.() ?? formType}
          </h1>
          <p className="text-sm text-[var(--color-muted-foreground)]">
            {m[config.subtitleKey]?.() ?? ""}
          </p>
        </div>
      </div>
 
      {/* Filters */}
      <div className="mt-6">
        <InquiryFilters
          formType={formType}
          activeStatus={activeStatus}
          onStatusChange={setActiveStatus}
          searchQuery={searchQuery}
          onSearchChange={setSearchQuery}
          counts={counts ?? defaultCounts}
        />
      </div>
 
      {/* Table or empty state */}
      <div className="mt-6">
        {inquiries === undefined ? (
          <div className="space-y-2">
            <Skeleton className="h-12 w-full" />
            <Skeleton className="h-12 w-full" />
            <Skeleton className="h-12 w-full" />
          </div>
        ) : filteredInquiries.length === 0 ? (
          <Card
            variant="glass"
            rounded="round"
            hover="none"
            className="p-12 text-center"
          >
            <Icon className="w-10 h-10 mx-auto text-[var(--color-muted-foreground)] mb-3" />
            <p className="text-[var(--color-muted-foreground)]">
              {m.admin_inquiry_noInquiries()}
            </p>
            <p className="text-sm text-[var(--color-muted-foreground)] mt-1">
              {m.admin_inquiry_noInquiriesDescription()}
            </p>
          </Card>
        ) : (
          <InquiryTable
            inquiries={filteredInquiries}
            isLoading={false}
            formType={formType}
            config={config}
            onRowClick={setSelectedInquiry}
          />
        )}
      </div>
 
      {/* Detail panel */}
      <InquiryDetailPanel
        inquiry={selectedInquiry}
        formType={formType}
        config={config}
        onClose={() => setSelectedInquiry(null)}
        onStatusChange={handleStatusChange}
      />
    </DashboardPage>
  );
}
  • Step 2: Commit
git add apps/frontend/app/\[locale\]/dashboard/inquiries/\[formType\]/page.tsx
git commit -m "feat(admin): add inquiry admin page for each form type"

Task 10: Create Inquiries Index Page (Redirect)

Files:

  • Create: apps/frontend/app/[locale]/dashboard/inquiries/page.tsx

  • Step 1: Write redirect page

// apps/frontend/app/[locale]/dashboard/inquiries/page.tsx
// SoC: Redirect to contact inquiries page by default
 
import { redirect } from "next/navigation";
 
export default function InquiriesPage() {
  redirect("/dashboard/inquiries/contact");
}
  • Step 2: Commit
git add apps/frontend/app/\[locale\]/dashboard/inquiries/page.tsx
git commit -m "feat(admin): add inquiries index redirect page"

Task 11: Update Sidebar Navigation

Files:

  • Modify: apps/frontend/components/admin/sidebar.tsx

  • Step 1: Add inquiries nav section with sub-items

Find the navItems array (line ~16) and add:

// Inquiries section
{
  navKey: "inquiries",
  icon: Inbox,
  roles: ["ADMIN"],
  children: [
    { formType: "CONTACT", titleKey: "admin_inquiry_nav_contact", icon: Mail },
    { formType: "PRIVATE_EVENTS", titleKey: "admin_inquiry_nav_privateEvents", icon: Calendar },
    { formType: "VENUE_RENTAL", titleKey: "admin_inquiry_nav_venueRental", icon: Building },
    { formType: "WORKSHOPS", titleKey: "admin_inquiry_nav_workshops", icon: GraduationCap },
    { formType: "ARTIST_PROPOSAL", titleKey: "admin_inquiry_nav_artistProposal", icon: Mic },
    { formType: "HOST_AN_EVENT", titleKey: "admin_inquiry_nav_hostAnEvent", icon: PartyPopper },
  ],
},

Then update navLabel to handle "inquiries" key and add a getNavHref helper for the sub-items.

Or alternatively, add each as a top-level nav item with a label prefix. The key is: add all 6 inquiry pages to the sidebar navigation so admin can access them.

  • Step 2: Commit
git add apps/frontend/components/admin/sidebar.tsx
git commit -m "feat(admin): add inquiries nav items to sidebar"

Task 12: Build and Smoke Test

  • Step 1: Run type check

Run: cd apps/frontend && npx tsc --noEmit 2>&1 | head -50 Expected: No TypeScript errors (or only pre-existing ones)

  • Step 2: Run lint

Run: cd apps/frontend && npx eslint app/\[locale\]/dashboard/inquiries components/admin/inquiry-* lib/inquiry-config.ts --ext .ts,.tsx 2>&1 | head -30 Expected: No errors

  • Step 3: Commit all remaining changes
git add -A
git commit -m "feat(admin): complete inquiry management admin UI"

Self-Review Checklist

1. Spec coverage:

  • Contact form admin page (/dashboard/inquiries/contact)
  • Private Events admin page (/dashboard/inquiries/private-events)
  • Venue Rental admin page (/dashboard/inquiries/venue-rental)
  • Workshops admin page (/dashboard/inquiries/workshops)
  • Artist Proposals admin page (/dashboard/inquiries/artist-proposals)
  • Host an Event admin page (/dashboard/inquiries/host-an-event)
  • Filter by status (NEW/READ/REPLIED/ARCHIVED)
  • Search by name/email
  • Detail panel with full form data
  • Status management (mark read, replied, archive, restore)
  • Admin notes
  • Sidebar navigation

2. Placeholder scan:

  • No "TBD", "TODO", or unimplemented placeholders
  • No vague steps — every step has actual code
  • No "similar to X" without repeating code

3. Type consistency:

  • InquiryFormType used consistently across tasks
  • InquiryStatus used consistently (NEW, READ, REPLIED, ARCHIVED)
  • formType param throughout uses the same enum values
  • Convex API path: api.domains.inquiries.* matches Task 2 implementation

4. Open issues:

  • FRENCH_MENTALIST and DINNER_THEATER form types exist in schema but have no front-end form components — no admin page needed unless a form is added
  • Existing formSessions entries (before this feature is deployed) will NOT have corresponding inquirySessions — they will not appear in admin UI. A migration script or backfill mutation could be added later if needed.
  • The ensureInquirySession is called after form insert — but only for NEW submissions going forward. Historical data requires a backfill.

Plan complete and saved to docs/superpowers/plans/2026-05-10-inquiry-management-admin.md.

Two execution options:

1. Subagent-Driven (recommended) — I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution — Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?