plans
2026-05-10
2026 05 10 Inquiry Kanban Board

Inquiry Kanban Board — Implementation Plan (Unified Page)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace all 6 per-form-type inquiry pages with a single unified Kanban board page at /dashboard/inquiries. Form-type tabs filter which inquiries appear. The Kanban shows all statuses simultaneously.

Architecture:

  • Single page at /dashboard/inquiries with form-type tabs + Kanban board
  • New listAllInquiries Convex query (no formType filter) returns all inquiries
  • @hello-pangea/dnd for drag-and-drop between status columns
  • InquiryFilters replaced with form-type tab filter + search input
  • All 6 existing /dashboard/inquiries/[formType] pages redirect to /dashboard/inquiries

Tech Stack: React 19, Tailwind CSS v4, @hello-pangea/dnd, shadcn/ui, Lucide icons, paraglide-js


File Structure

packages/backend/convex/
├── schema.ts                              # No changes (already done in previous plan)
└── domains/inquiries.ts                  # MODIFIED: add listAllInquiries query

apps/frontend/
├── app/[locale]/dashboard/inquiries/
│   ├── page.tsx                          # MODIFIED: single unified page (was redirect, now full content)
│   └── [formType]/page.tsx             # MODIFIED: redirect to /dashboard/inquiries
├── components/admin/
│   ├── inquiry-kanban-board.tsx         # NEW: Kanban with 4 status columns
│   ├── inquiry-kanban-card.tsx          # NEW: Draggable card
│   ├── inquiry-kanban-filters.tsx      # NEW: Form-type tabs + search (replaces InquiryFilters)
│   └── inquiry-detail-panel.tsx          # No changes (already updated in previous plan)

Task 1: Add listAllInquiries Convex Query

Files:

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

  • Step 1: Read the current inquiries.ts to find where to add

Look at the existing listByFormType query (around line 19) as a reference.

  • Step 2: Add listAllInquiries query after countByStatus

Append this after the countByStatus query (around line 75):

// List all inquiries across all form types, optionally filtered
export const listAllInquiries = zQuery({
  args: {
    formType: z.enum(FORM_TYPES).optional(),
    status: z.enum(INQUIRY_STATUSES).optional(),
    limit: z.number().optional().default(200),
  },
  handler: async (ctx, { formType, status, limit = 200 }) => {
    let query;
 
    if (formType && status) {
      query = ctx.db
        .query("inquirySessions")
        .withIndex("by_formType_status", (q) =>
          q.eq("formType", formType).eq("status", status),
        );
    } else if (formType) {
      query = ctx.db
        .query("inquirySessions")
        .withIndex("by_formType", (q) => q.eq("formType", formType));
    } else if (status) {
      query = ctx.db
        .query("inquirySessions")
        .withIndex("by_status", (q) => q.eq("status", status));
    } else {
      query = ctx.db.query("inquirySessions");
    }
 
    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(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;
  },
});
 
// Count all inquiries across all form types
export const countAllByStatus = zQuery({
  args: {},
  handler: async (ctx) => {
    const all = await ctx.db.query("inquirySessions").collect();
 
    const counts: Record<InquiryStatus, number> = {
      NEW: 0,
      READ: 0,
      REPLIED: 0,
      ARCHIVED: 0,
    };
    for (const inquiry of all) {
      counts[inquiry.status]++;
    }
    return counts;
  },
});
 
// Count by form type + status combined
export const countByFormTypeAndStatus = 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: Record<InquiryStatus, number> = {
      NEW: 0,
      READ: 0,
      REPLIED: 0,
      ARCHIVED: 0,
    };
    for (const inquiry of all) {
      counts[inquiry.status]++;
    }
    return counts;
  },
});
  • Step 3: Regenerate Convex types

Run: cd packages/backend && npx convex codegen

  • Step 4: Commit
git add packages/backend/convex/domains/inquiries.ts packages/backend/convex/_generated/
git commit -m "feat(inquiry-kanban): add listAllInquiries and count queries for unified board"

Task 2: Create InquiryKanbanCard Component

Files:

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

  • Step 1: Write InquiryKanbanCard

// apps/frontend/components/admin/inquiry-kanban-card.tsx
// SoC: Pure presentation — draggable card for kanban board
 
"use client";
 
import { Draggable } from "@hello-pangea/dnd";
import { Mail, Phone, Calendar } from "lucide-react";
import { cn } from "~/lib/utils";
import type { InquiryStatus } from "~/lib/inquiry-config";
 
type InquiryRow = {
  _id: string;
  status: InquiryStatus;
  adminNotes?: string;
  formData: Record<string, unknown>;
  submittedAt: number;
  formType: string;
};
 
const STATUS_COLORS: Record<InquiryStatus, string> = {
  NEW: "border-l-blue-500",
  READ: "border-l-yellow-500",
  REPLIED: "border-l-green-500",
  ARCHIVED: "border-l-gray-500",
};
 
interface InquiryKanbanCardProps {
  inquiry: InquiryRow;
  index: number;
  onClick: () => void;
}
 
export function InquiryKanbanCard({
  inquiry,
  index,
  onClick,
}: InquiryKanbanCardProps) {
  const formData = inquiry.formData as Record<string, unknown>;
 
  const primaryValue =
    (formData.name as string) || (formData.fullName as string) || "Unknown";
  const email = formData.email as string;
  const phone = formData.phone as string;
 
  const secondaryValue =
    (formData.subject as string) ||
    (formData.eventType as string) ||
    (formData.workshopType as string) ||
    (formData.artistName as string) ||
    null;
 
  const submittedDate = new Date(inquiry.submittedAt);
  const dateStr = submittedDate.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
  });
  const timeStr = submittedDate.toLocaleTimeString("en-US", {
    hour: "numeric",
    minute: "2-digit",
  });
 
  return (
    <Draggable draggableId={inquiry._id} index={index}>
      {(provided, snapshot) => (
        <div
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
          onClick={onClick}
          className={cn(
            "bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg p-3 cursor-pointer",
            "border-l-4 transition-shadow",
            STATUS_COLORS[inquiry.status],
            snapshot.isDragging
              ? "shadow-lg ring-2 ring-[var(--color-gold)] rotate-1"
              : "hover:shadow-md hover:border-[var(--color-gold)]/50",
          )}
        >
          <p className="text-sm font-medium text-[var(--color-foreground)] truncate mb-1">
            {primaryValue}
          </p>
 
          {email && (
            <p className="text-xs text-[var(--color-gold)] truncate flex items-center gap-1 mb-1">
              <Mail className="w-3 h-3 shrink-0" />
              {email}
            </p>
          )}
 
          {secondaryValue && (
            <p className="text-xs text-[var(--color-muted-foreground)] truncate mb-1">
              {secondaryValue}
            </p>
          )}
 
          <div className="flex items-center justify-between mt-2 pt-2 border-t border-[var(--color-border)]">
            <p className="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
              <Calendar className="w-3 h-3" />
              {dateStr} {timeStr}
            </p>
            <div className="flex items-center gap-1">
              {inquiry.adminNotes && (
                <span className="text-xs text-[var(--color-gold)]">
                  &#x1F4DD;
                </span>
              )}
              {phone && (
                <Phone className="w-3 h-3 text-[var(--color-muted-foreground)]" />
              )}
            </div>
          </div>
 
          {/* Form type badge */}
          <div className="mt-1">
            <span className="text-[10px] uppercase tracking-wide text-[var(--color-muted-foreground)] bg-[var(--color-muted)] px-1.5 py-0.5 rounded">
              {inquiry.formType.replace("_", " ")}
            </span>
          </div>
        </div>
      )}
    </Draggable>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/inquiry-kanban-card.tsx
git commit -m "feat(inquiry-kanban): add InquiryKanbanCard component"

Task 3: Create InquiryKanbanBoard Component

Files:

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

  • Step 1: Write InquiryKanbanBoard

// apps/frontend/components/admin/inquiry-kanban-board.tsx
// SoC: Pure presentation — kanban board with 4 status columns + drag-and-drop
 
"use client";
 
import { useMemo } from "react";
import { DragDropContext, Droppable, type DropResult } from "@hello-pangea/dnd";
import { InquiryKanbanCard } from "~/components/admin/inquiry-kanban-card";
import { Skeleton } from "~/components/ui/skeleton";
import { cn } from "~/lib/utils";
import type { InquiryStatus } from "~/lib/inquiry-config";
 
type InquiryRow = {
  _id: string;
  status: InquiryStatus;
  adminNotes?: string;
  formData: Record<string, unknown>;
  submittedAt: number;
  formType: string;
};
 
const STATUSES: InquiryStatus[] = ["NEW", "READ", "REPLIED", "ARCHIVED"];
 
const STATUS_LABELS: Record<InquiryStatus, () => string> = {
  NEW: () => "New",
  READ: () => "Read",
  REPLIED: () => "Replied",
  ARCHIVED: () => "Archived",
};
 
const STATUS_COLORS: Record<InquiryStatus, string> = {
  NEW: "bg-blue-500/10 border-blue-500/30",
  READ: "bg-yellow-500/10 border-yellow-500/30",
  REPLIED: "bg-green-500/10 border-green-500/30",
  ARCHIVED: "bg-gray-500/10 border-gray-500/30",
};
 
const STATUS_HEADER_COLORS: Record<InquiryStatus, string> = {
  NEW: "text-blue-400",
  READ: "text-yellow-400",
  REPLIED: "text-green-400",
  ARCHIVED: "text-gray-400",
};
 
interface InquiryKanbanBoardProps {
  inquiries: InquiryRow[] | undefined;
  isLoading: boolean;
  onCardClick: (inquiry: InquiryRow) => void;
  onDragEnd: (inquiryId: string, newStatus: InquiryStatus) => void;
}
 
export function InquiryKanbanBoard({
  inquiries,
  isLoading,
  onCardClick,
  onDragEnd,
}: InquiryKanbanBoardProps) {
  const grouped = useMemo(() => {
    if (!inquiries) return null;
    const groups: Record<InquiryStatus, InquiryRow[]> = {
      NEW: [],
      READ: [],
      REPLIED: [],
      ARCHIVED: [],
    };
    for (const inquiry of inquiries) {
      groups[inquiry.status].push(inquiry);
    }
    for (const status of STATUSES) {
      groups[status].sort((a, b) => b.submittedAt - a.submittedAt);
    }
    return groups;
  }, [inquiries]);
 
  const handleDragEnd = (result: DropResult) => {
    if (!result.destination) return;
    const { draggableId, destination } = result;
    const newStatus = destination.droppableId as InquiryStatus;
    if (!STATUSES.includes(newStatus)) return;
    onDragEnd(draggableId, newStatus);
  };
 
  if (isLoading) {
    return (
      <div className="flex gap-4 overflow-x-auto pb-4">
        {STATUSES.map((status) => (
          <div key={status} className="flex-1 min-w-[280px] max-w-[320px]">
            <div className="bg-[var(--color-muted)]/20 rounded-lg p-3 mb-3">
              <Skeleton className="h-4 w-20" />
            </div>
            <div className="space-y-2">
              <Skeleton className="h-24 w-full" />
              <Skeleton className="h-24 w-full" />
            </div>
          </div>
        ))}
      </div>
    );
  }
 
  if (!grouped) return null;
 
  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <div className="flex gap-4 overflow-x-auto pb-4 px-1">
        {STATUSES.map((status) => (
          <div key={status} className="flex-1 min-w-[280px] max-w-[320px]">
            {/* Column header */}
            <div
              className={cn(
                "rounded-t-lg p-3 border border-b-0 flex items-center justify-between",
                STATUS_COLORS[status],
              )}
            >
              <h3
                className={cn(
                  "text-sm font-semibold uppercase tracking-wide",
                  STATUS_HEADER_COLORS[status],
                )}
              >
                {STATUS_LABELS[status]()}
              </h3>
              <span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full text-xs font-medium bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">
                {grouped[status].length}
              </span>
            </div>
 
            {/* Droppable column */}
            <Droppable droppableId={status}>
              {(provided, snapshot) => (
                <div
                  ref={provided.innerRef}
                  {...provided.droppableProps}
                  className={cn(
                    "rounded-b-lg border p-2 min-h-[200px] space-y-2 transition-colors",
                    STATUS_COLORS[status],
                    snapshot.isDraggingOver
                      ? "bg-[var(--color-gold)]/5 ring-1 ring-[var(--color-gold)]/30"
                      : "",
                  )}
                >
                  {grouped[status].map((inquiry, index) => (
                    <InquiryKanbanCard
                      key={inquiry._id}
                      inquiry={inquiry}
                      index={index}
                      onClick={() => onCardClick(inquiry)}
                    />
                  ))}
                  {provided.placeholder}
 
                  {grouped[status].length === 0 && !snapshot.isDraggingOver && (
                    <div className="flex items-center justify-center h-20 text-xs text-[var(--color-muted-foreground)]">
                      Drop here
                    </div>
                  )}
                </div>
              )}
            </Droppable>
          </div>
        ))}
      </div>
    </DragDropContext>
  );
}
  • Step 2: Commit
git add apps/frontend/components/admin/inquiry-kanban-board.tsx
git commit -m "feat(inquiry-kanban): add InquiryKanbanBoard component with drag-and-drop"

Task 4: Create InquiryKanbanFilters Component

Files:

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

  • Step 1: Write InquiryKanbanFilters

// apps/frontend/components/admin/inquiry-kanban-filters.tsx
// SoC: Pure presentation — form-type tabs + search input
 
"use client";
 
import { Search } from "lucide-react";
import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
import type { InquiryFormType } from "~/lib/inquiry-config";
 
const FORM_TYPE_TABS: Array<{
  value: InquiryFormType | "ALL";
  label: () => string;
}> = [
  { value: "ALL", label: () => "All" },
  { value: "CONTACT", label: () => "Contact" },
  { value: "PRIVATE_EVENTS", label: () => "Private Events" },
  { value: "VENUE_RENTAL", label: () => "Venue Rental" },
  { value: "WORKSHOPS", label: () => "Workshops" },
  { value: "ARTIST_PROPOSAL", label: () => "Artist Proposals" },
  { value: "HOST_AN_EVENT", label: () => "Host an Event" },
];
 
const FORM_TYPE_COUNTS: Record<string, number> = {
  ALL: 0,
  CONTACT: 0,
  PRIVATE_EVENTS: 0,
  VENUE_RENTAL: 0,
  WORKSHOPS: 0,
  ARTIST_PROPOSAL: 0,
  HOST_AN_EVENT: 0,
};
 
interface InquiryKanbanFiltersProps {
  activeFormType: InquiryFormType | "ALL";
  onFormTypeChange: (formType: InquiryFormType | "ALL") => void;
  searchQuery: string;
  onSearchChange: (query: string) => void;
  counts?: Record<string, number>;
}
 
export function InquiryKanbanFilters({
  activeFormType,
  onFormTypeChange,
  searchQuery,
  onSearchChange,
  counts = FORM_TYPE_COUNTS,
}: InquiryKanbanFiltersProps) {
  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, subject..."
          value={searchQuery}
          onChange={(e) => onSearchChange(e.target.value)}
          className="pl-9"
        />
      </div>
 
      {/* Form type tabs */}
      <div className="flex items-center gap-1 border-b border-[var(--color-border)] overflow-x-auto">
        {FORM_TYPE_TABS.map((tab) => {
          const isActive = activeFormType === tab.value;
          const count =
            tab.value === "ALL"
              ? Object.values(counts).reduce((sum, c) => sum + c, 0)
              : (counts[tab.value] ?? 0);
 
          return (
            <button
              key={tab.value}
              onClick={() => onFormTypeChange(tab.value)}
              className={cn(
                "relative flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap",
                isActive
                  ? "text-[var(--color-gold)]"
                  : "text-[var(--color-muted-foreground)] hover:text-[var(--color-foreground)]",
              )}
            >
              <span>{tab.label()}</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-kanban-filters.tsx
git commit -m "feat(inquiry-kanban): add InquiryKanbanFilters with form-type tabs"

Task 5: Rewrite Unified Inquiry Page

Files:

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

  • Step 1: Read the current page

Current file just does a redirect to /dashboard/inquiries/contact. Replace it entirely.

  • Step 2: Write the unified page
// apps/frontend/app/[locale]/dashboard/inquiries/page.tsx
// SoC: Unified inquiry Kanban board — all form types in one view
 
"use client";
 
import { useState, useCallback, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { Inbox } from "lucide-react";
import { DashboardPage } from "~/components/admin/dashboard-page";
import { InquiryKanbanFilters } from "~/components/admin/inquiry-kanban-filters";
import { InquiryKanbanBoard } from "~/components/admin/inquiry-kanban-board";
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 type { Id } from "@packages/backend/convex/_generated/dataModel";
import type { InquiryStatus, InquiryFormType } from "~/lib/inquiry-config";
import { INQUIRY_FORM_CONFIGS } from "~/lib/inquiry-config";
 
type InquiryRow = {
  _id: string;
  status: InquiryStatus;
  formType: InquiryFormType;
  adminNotes?: string;
  formData: Record<string, unknown>;
  submittedAt: number;
};
 
export default function InquiriesPage() {
  const [activeFormType, setActiveFormType] = useState<InquiryFormType | "ALL">(
    "ALL",
  );
  const [searchQuery, setSearchQuery] = useState("");
  const [selectedInquiry, setSelectedInquiry] = useState<InquiryRow | null>(
    null,
  );
 
  // Fetch all inquiries (no formType filter — single source)
  const allInquiries = useQuery(api.domains.inquiries.listAllInquiries, {
    limit: 300,
  }) as InquiryRow[] | undefined;
 
  // Fetch counts by form type for tab badges
  const countsByFormType = useQuery(
    api.domains.inquiries.countAllByStatus,
    {},
  ) as Record<string, number> | undefined;
 
  // Filter by form type
  const filteredInquiries = useMemo(() => {
    if (!allInquiries) return [];
    let result = allInquiries;
    if (activeFormType !== "ALL") {
      result = result.filter((inq) => inq.formType === activeFormType);
    }
    return result;
  }, [allInquiries, activeFormType]);
 
  // Search filter
  const searchedInquiries = useMemo(() => {
    if (!searchQuery.trim()) return filteredInquiries;
    const query = searchQuery.toLowerCase();
    return filteredInquiries.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)
      );
    });
  }, [filteredInquiries, searchQuery]);
 
  const updateStatus = useMutation(api.domains.inquiries.updateStatus);
 
  const handleStatusChange = useCallback(
    async (inquiryId: string, newStatus: InquiryStatus) => {
      await updateStatus({
        inquiryId: inquiryId as Id<"inquirySessions">,
        status: newStatus,
      });
      setSelectedInquiry((prev) =>
        prev?._id === inquiryId ? { ...prev, status: newStatus } : prev,
      );
    },
    [updateStatus],
  );
 
  const handleDragEnd = useCallback(
    async (inquiryId: string, newStatus: InquiryStatus) => {
      await handleStatusChange(inquiryId, newStatus);
    },
    [handleStatusChange],
  );
 
  const selectedConfig = selectedInquiry
    ? INQUIRY_FORM_CONFIGS[selectedInquiry.formType]
    : null;
 
  const totalCount = countsByFormType
    ? Object.values(countsByFormType).reduce((sum, c) => sum + c, 0)
    : 0;
 
  return (
    <DashboardPage>
      {/* Page header */}
      <div className="flex items-center justify-between mb-6">
        <div className="flex items-center gap-3">
          <Inbox className="w-6 h-6 text-[var(--color-gold)]" />
          <div>
            <h1 className="text-2xl font-serif text-[var(--color-gold)]">
              {(m.admin_inquiry_nav_inquiries as () => string)?.() ??
                "Inquiries"}
            </h1>
            <p className="text-sm text-[var(--color-muted-foreground)]">
              All form submissions
            </p>
          </div>
        </div>
        {countsByFormType && (
          <div className="text-sm text-[var(--color-muted-foreground)]">
            {totalCount} total
          </div>
        )}
      </div>
 
      {/* Filters */}
      <div className="mb-6">
        <InquiryKanbanFilters
          activeFormType={activeFormType}
          onFormTypeChange={setActiveFormType}
          searchQuery={searchQuery}
          onSearchChange={setSearchQuery}
          counts={countsByFormType ?? {}}
        />
      </div>
 
      {/* Kanban board or empty state */}
      {allInquiries === undefined ? (
        <div className="flex gap-4">
          {[1, 2, 3, 4].map((i) => (
            <div key={i} className="flex-1 min-w-[280px] max-w-[320px]">
              <Skeleton className="h-10 w-full mb-3" />
              <div className="space-y-2">
                <Skeleton className="h-24 w-full" />
                <Skeleton className="h-24 w-full" />
              </div>
            </div>
          ))}
        </div>
      ) : searchedInquiries.length === 0 ? (
        <Card
          variant="glass"
          rounded="round"
          hover="none"
          className="p-12 text-center"
        >
          <Inbox className="w-10 h-10 mx-auto text-[var(--color-muted-foreground)] mb-3" />
          <p className="text-[var(--color-muted-foreground)]">
            {activeFormType === "ALL"
              ? ((m.admin_inquiry_noInquiries as () => string)?.() ??
                "No inquiries yet")
              : "No inquiries for this category"}
          </p>
        </Card>
      ) : (
        <InquiryKanbanBoard
          inquiries={searchedInquiries}
          isLoading={false}
          onCardClick={setSelectedInquiry}
          onDragEnd={handleDragEnd}
        />
      )}
 
      {/* Detail panel */}
      {selectedInquiry && selectedConfig && (
        <InquiryDetailPanel
          inquiry={selectedInquiry}
          formType={selectedInquiry.formType}
          config={selectedConfig}
          onClose={() => setSelectedInquiry(null)}
          onStatusChange={handleStatusChange}
        />
      )}
    </DashboardPage>
  );
}
  • Step 3: Commit
git add apps/frontend/app/\[locale\]/dashboard/inquiries/page.tsx
git commit -m "feat(inquiry-kanban): rewrite to unified Kanban board page"

Task 6: Redirect Per-Form-Type Pages to Unified Page

Files:

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

There are 6 form-type pages:

  • artist-proposals/page.tsx

  • contact/page.tsx

  • host-an-event/page.tsx

  • private-events/page.tsx

  • venue-rental/page.tsx

  • workshops/page.tsx

  • Step 1: Rewrite each page to redirect to /dashboard/inquiries

For each file, replace the content with:

// apps/frontend/app/[locale]/dashboard/inquiries/[formType]/page.tsx
// SoC: Redirect to unified inquiries page
 
import { redirect } from "next/navigation";
 
export default function InquiryFormTypePage() {
  redirect("/dashboard/inquiries");
}

Apply this to all 6 pages:

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

  • apps/frontend/app/[locale]/dashboard/inquiries/private-events/page.tsx

  • apps/frontend/app/[locale]/dashboard/inquiries/venue-rental/page.tsx

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

  • apps/frontend/app/[locale]/dashboard/inquiries/artist-proposals/page.tsx

  • apps/frontend/app/[locale]/dashboard/inquiries/host-an-event/page.tsx

  • Step 2: Commit all redirects

git add apps/frontend/app/\[locale\]/dashboard/inquiries/*/page.tsx
git commit -m "feat(inquiry-kanban): redirect per-form-type pages to unified board"

Task 7: Build and Smoke Test

  • Step 1: Run type check
cd apps/frontend && npx tsc --noEmit 2>&1 | head -30

Expected: No TypeScript errors

  • Step 2: Run lint
cd apps/frontend && npx eslint components/admin/inquiry-kanban-* app/\[locale\]/dashboard/inquiries/page.tsx --ext .tsx 2>&1 | head -20

Expected: No errors

  • Step 3: Manual test

Navigate to /dashboard/inquiries and verify:

  • 4 columns visible (NEW, READ, REPLIED, ARCHIVED)
  • Form-type tabs at top (All, Contact, Private Events, ...)
  • Count badges on tabs
  • Cards show name, email, date, form type badge
  • Dragging card to another column updates status
  • Clicking card opens detail panel
  • Search filters across all visible cards
  • Clicking a form-type tab filters the board
  • Old URLs (/dashboard/inquiries/contact) redirect to /dashboard/inquiries

Self-Review Checklist

Spec coverage:

  • Single unified page at /dashboard/inquiries
  • Kanban board with 4 status columns (NEW, READ, REPLIED, ARCHIVED)
  • Form-type tabs filter the board
  • Search works across all visible cards
  • Drag-and-drop updates status via mutation
  • Detail panel works with selected inquiry
  • All 6 old URLs redirect to unified page

No regressions:

  • InquiryDetailPanel unchanged
  • inquiryFollowUps (from previous plan) still works
  • Convex mutations (updateStatus, listFollowUps, createFollowUp) unchanged

Code quality:

  • @hello-pangea/dnd used (React 19 compatible)
  • Proper TypeScript types throughout
  • Optimistic status update on drag
  • listAllInquiries with 300 limit to handle volume