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/inquirieswith form-type tabs + Kanban board - New
listAllInquiriesConvex query (no formType filter) returns all inquiries @hello-pangea/dndfor drag-and-drop between status columnsInquiryFiltersreplaced 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
listAllInquiriesquery aftercountByStatus
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)]">
📝
</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 -30Expected: 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 -20Expected: 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:
-
InquiryDetailPanelunchanged -
inquiryFollowUps(from previous plan) still works - Convex mutations (
updateStatus,listFollowUps,createFollowUp) unchanged
Code quality:
-
@hello-pangea/dndused (React 19 compatible) - Proper TypeScript types throughout
- Optimistic status update on drag
-
listAllInquirieswith 300 limit to handle volume