diff --git a/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx b/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
index 92b2989e..81836afa 100644
--- a/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/admin/inquiries/page.tsx
@@ -1,5 +1,15 @@
-import { InquiryInbox } from '@/components/admin/inquiry-inbox';
+import { redirect } from 'next/navigation';
-export default function InquiriesPage() {
- return ;
+/**
+ * The inquiry inbox is now a top-level, permission-gated page at
+ * `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
+ * Redirect the legacy admin URL so old bookmarks/links still land.
+ */
+interface AdminInquiriesRedirectProps {
+ params: Promise<{ portSlug: string }>;
+}
+
+export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
+ const { portSlug } = await params;
+ redirect(`/${portSlug}/inquiries`);
}
diff --git a/src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx b/src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
new file mode 100644
index 00000000..7cbd03ab
--- /dev/null
+++ b/src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
@@ -0,0 +1,11 @@
+import { Skeleton } from '@/components/ui/skeleton';
+
+export default function Loading() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx b/src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
new file mode 100644
index 00000000..433e5d74
--- /dev/null
+++ b/src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
@@ -0,0 +1,10 @@
+import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
+
+interface InquiryDetailPageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
+ const { id } = await params;
+ return ;
+}
diff --git a/src/app/(dashboard)/[portSlug]/inquiries/page.tsx b/src/app/(dashboard)/[portSlug]/inquiries/page.tsx
new file mode 100644
index 00000000..817fe848
--- /dev/null
+++ b/src/app/(dashboard)/[portSlug]/inquiries/page.tsx
@@ -0,0 +1,5 @@
+import { InquiryList } from '@/components/inquiries/inquiry-list';
+
+export default function InquiriesPage() {
+ return ;
+}
diff --git a/src/components/inquiries/inquiry-card.tsx b/src/components/inquiries/inquiry-card.tsx
new file mode 100644
index 00000000..9d0a9316
--- /dev/null
+++ b/src/components/inquiries/inquiry-card.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import Link from 'next/link';
+import { formatDistanceToNowStrict } from 'date-fns';
+
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent } from '@/components/ui/card';
+import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
+
+export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
+ return (
+
+
+
+
+
+
{inquiry.contactName || '(no name)'}
+ {inquiry.contactEmail ? (
+
{inquiry.contactEmail}
+ ) : null}
+
+
{inquiry.triageState}
+
+
+ {KIND_LABELS[inquiry.kind]}
+ ·
+
+ {formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
+
+
+
+
+
+ );
+}
diff --git a/src/components/inquiries/inquiry-columns.tsx b/src/components/inquiries/inquiry-columns.tsx
new file mode 100644
index 00000000..735534c6
--- /dev/null
+++ b/src/components/inquiries/inquiry-columns.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import Link from 'next/link';
+import { format, formatDistanceToNowStrict } from 'date-fns';
+import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
+import type { ColumnDef } from '@tanstack/react-table';
+
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+
+export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
+export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
+
+export interface InquiryRow {
+ id: string;
+ kind: InquiryKind;
+ contactName: string | null;
+ contactEmail: string | null;
+ receivedAt: string;
+ triageState: InquiryTriageState;
+ convertedClientId: string | null;
+ convertedInterestId: string | null;
+ sourceIp: string | null;
+ utmSource?: string | null;
+}
+
+export const KIND_LABELS: Record = {
+ berth_inquiry: 'Berth',
+ residence_inquiry: 'Residence',
+ contact_form: 'Contact',
+};
+
+const KIND_TONE: Record = {
+ berth_inquiry: 'bg-blue-100 text-blue-800',
+ residence_inquiry: 'bg-amber-100 text-amber-900',
+ contact_form: 'bg-slate-100 text-slate-800',
+};
+
+export const TRIAGE_TONE: Record = {
+ open: 'bg-blue-100 text-blue-800',
+ assigned: 'bg-amber-100 text-amber-900',
+ converted: 'bg-emerald-100 text-emerald-800',
+ dismissed: 'bg-slate-100 text-slate-600',
+};
+
+export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
+ { id: 'contactEmail', label: 'Email' },
+ { id: 'kind', label: 'Type' },
+ { id: 'triageState', label: 'Status' },
+ { id: 'utmSource', label: 'UTM source' },
+ { id: 'receivedAt', label: 'Received' },
+];
+
+export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
+
+interface GetColumnsOptions {
+ portSlug: string;
+ onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
+}
+
+export function getInquiryColumns({
+ portSlug,
+ onTriage,
+}: GetColumnsOptions): ColumnDef[] {
+ return [
+ {
+ id: 'contactName',
+ accessorKey: 'contactName',
+ header: 'Name',
+ cell: ({ row }) => (
+ e.stopPropagation()}
+ >
+ {row.original.contactName || '(no name)'}
+
+ ),
+ },
+ {
+ id: 'contactEmail',
+ accessorKey: 'contactEmail',
+ header: 'Email',
+ enableSorting: false,
+ cell: ({ getValue }) => {
+ const email = getValue() as string | null;
+ return email ? (
+ {email}
+ ) : (
+ -
+ );
+ },
+ },
+ {
+ id: 'kind',
+ accessorKey: 'kind',
+ header: 'Type',
+ cell: ({ getValue }) => {
+ const kind = getValue() as InquiryKind;
+ return {KIND_LABELS[kind]};
+ },
+ },
+ {
+ id: 'triageState',
+ accessorKey: 'triageState',
+ header: 'Status',
+ cell: ({ row }) => {
+ const state = row.original.triageState;
+ return (
+
+ {state}
+ {row.original.convertedInterestId ? (
+ e.stopPropagation()}
+ >
+ interest →
+
+ ) : row.original.convertedClientId ? (
+ e.stopPropagation()}
+ >
+ client →
+
+ ) : null}
+
+ );
+ },
+ },
+ {
+ id: 'utmSource',
+ accessorKey: 'utmSource',
+ header: 'UTM source',
+ enableSorting: false,
+ cell: ({ getValue }) => {
+ const utm = getValue() as string | null;
+ return utm ? (
+ {utm}
+ ) : (
+ -
+ );
+ },
+ },
+ {
+ id: 'receivedAt',
+ accessorKey: 'receivedAt',
+ header: 'Received',
+ cell: ({ getValue }) => {
+ const iso = getValue() as string;
+ const d = new Date(iso);
+ return (
+
+ {formatDistanceToNowStrict(d, { addSuffix: true })}
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ header: '',
+ enableSorting: false,
+ size: 48,
+ cell: ({ row }) => {
+ const isResolved =
+ row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
+ return (
+
+
+
+
+
+
+
+
+ Open
+
+
+ {!isResolved ? (
+ <>
+ onTriage(row.original, 'assigned')}>
+
+ Assign to me
+
+ onTriage(row.original, 'dismissed')}>
+
+ Dismiss
+
+ >
+ ) : (
+ onTriage(row.original, 'open')}>
+ Reopen
+
+ )}
+
+
+ );
+ },
+ },
+ ];
+}
diff --git a/src/components/inquiries/inquiry-convert-actions.tsx b/src/components/inquiries/inquiry-convert-actions.tsx
new file mode 100644
index 00000000..4538e2b7
--- /dev/null
+++ b/src/components/inquiries/inquiry-convert-actions.tsx
@@ -0,0 +1,119 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { PermissionGate } from '@/components/shared/permission-gate';
+import { apiFetch } from '@/lib/api/client';
+import { toastError } from '@/lib/api/toast-error';
+
+interface InquiryConvertActionsProps {
+ portSlug: string;
+ inquiry: {
+ id: string;
+ triageState: string;
+ convertedClientId: string | null;
+ convertedInterestId: string | null;
+ };
+}
+
+export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: ['inquiries'] });
+ };
+
+ const convert = useMutation({
+ mutationFn: (target: 'client' | 'interest') =>
+ apiFetch<{ data: { clientId: string; interestId: string | null } }>(
+ `/api/v1/inquiries/${inquiry.id}/convert`,
+ { method: 'POST', body: { target } },
+ ),
+ onSuccess: (res, target) => {
+ invalidate();
+ if (target === 'interest' && res.data.interestId) {
+ toast.success('Converted to interest.');
+ router.push(`/${portSlug}/interests/${res.data.interestId}`);
+ } else {
+ toast.success('Converted to client.');
+ router.push(`/${portSlug}/clients/${res.data.clientId}`);
+ }
+ },
+ onError: (err: unknown) => toastError(err, 'Convert failed'),
+ });
+
+ const triage = useMutation({
+ mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
+ apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
+ onSuccess: (_d, state) => {
+ invalidate();
+ toast.success(`Marked ${state}.`);
+ },
+ onError: (err: unknown) => toastError(err, 'Update failed'),
+ });
+
+ const busy = convert.isPending || triage.isPending;
+ const alreadyInterest = Boolean(inquiry.convertedInterestId);
+
+ return (
+
+
+ {alreadyInterest ? (
+
+ ) : (
+
+ )}
+
+ {inquiry.convertedClientId ? (
+
+ ) : (
+
+ )}
+
+ {inquiry.triageState === 'open' ? (
+
+ ) : null}
+
+ {inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/inquiries/inquiry-detail.tsx b/src/components/inquiries/inquiry-detail.tsx
new file mode 100644
index 00000000..eed42dd7
--- /dev/null
+++ b/src/components/inquiries/inquiry-detail.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useQuery } from '@tanstack/react-query';
+import { useParams } from 'next/navigation';
+import { format } from 'date-fns';
+
+import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
+import { DetailNotFound } from '@/components/shared/detail-not-found';
+import { Badge } from '@/components/ui/badge';
+import { apiFetch } from '@/lib/api/client';
+import {
+ KIND_LABELS,
+ TRIAGE_TONE,
+ type InquiryKind,
+ type InquiryTriageState,
+} from '@/components/inquiries/inquiry-columns';
+import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
+
+interface InquiryDetailData {
+ id: string;
+ kind: InquiryKind;
+ contactName: string | null;
+ contactEmail: string | null;
+ payload: Record | null;
+ receivedAt: string;
+ sourceIp: string | null;
+ utmSource: string | null;
+ utmMedium: string | null;
+ utmCampaign: string | null;
+ triageState: InquiryTriageState;
+ triagedAt: string | null;
+ convertedClientId: string | null;
+ convertedInterestId: string | null;
+ convertedClient: { id: string; fullName: string } | null;
+ convertedInterest: { id: string; pipelineStage: string } | null;
+}
+
+function Row({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+
+ {value || —}
+
+
+ );
+}
+
+export function InquiryDetail({ id }: { id: string }) {
+ const params = useParams<{ portSlug: string }>();
+ const portSlug = params?.portSlug ?? '';
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['inquiries', id],
+ queryFn: () =>
+ apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
+ retry: (count, err) => {
+ const status = (err as { status?: number })?.status;
+ return status === 404 || status === 403 ? false : count < 2;
+ },
+ });
+
+ if (error && !isLoading) {
+ const status = (error as { status?: number })?.status;
+ return (
+
+ );
+ }
+
+ const p = (data?.payload ?? {}) as Record;
+ const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
+
+ const tabs: DetailTab[] = [
+ {
+ id: 'overview',
+ label: 'Overview',
+ content: (
+
+
+
+
+ {data?.kind === 'residence_inquiry' ? (
+
+ ) : null}
+ {data?.kind === 'berth_inquiry' ?
: null}
+ {data?.kind === 'contact_form' ?
: null}
+
+
+
+
+
+
+
+ ),
+ },
+ {
+ id: 'tracking',
+ label: 'Tracking',
+ content: (
+
+ {data.triageState}
+ ) : (
+ ''
+ )
+ }
+ />
+
+
+ {data.convertedClient.fullName}
+
+ ) : null
+ }
+ />
+
+ View interest ({data.convertedInterest.pipelineStage})
+
+ ) : null
+ }
+ />
+
+ ),
+ },
+ {
+ id: 'payload',
+ label: 'Raw payload',
+ content: (
+
+ {JSON.stringify(data?.payload ?? {}, null, 2)}
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
{data?.contactName || '(no name)'}
+ {data ? (
+ {data.triageState}
+ ) : null}
+
+
+ {data ? KIND_LABELS[data.kind] : ''} inquiry
+ {data?.contactEmail ? ` · ${data.contactEmail}` : ''}
+
+
+ {data ? : null}
+
+ }
+ tabs={tabs}
+ defaultTab="overview"
+ />
+ );
+}
diff --git a/src/components/inquiries/inquiry-filters.tsx b/src/components/inquiries/inquiry-filters.tsx
new file mode 100644
index 00000000..108cb30a
--- /dev/null
+++ b/src/components/inquiries/inquiry-filters.tsx
@@ -0,0 +1,33 @@
+import type { FilterDefinition } from '@/components/shared/filter-bar';
+
+export const inquiryFilterDefinitions: FilterDefinition[] = [
+ {
+ key: 'search',
+ label: 'Search',
+ type: 'text',
+ placeholder: 'Search name or email…',
+ },
+ {
+ key: 'kind',
+ label: 'Type',
+ type: 'select',
+ options: [
+ { label: 'Berth', value: 'berth_inquiry' },
+ { label: 'Residence', value: 'residence_inquiry' },
+ { label: 'Contact', value: 'contact_form' },
+ ],
+ },
+ {
+ key: 'state',
+ label: 'Status',
+ type: 'select',
+ options: [
+ { label: 'Inbox (open + assigned)', value: 'inbox' },
+ { label: 'Open', value: 'open' },
+ { label: 'Assigned', value: 'assigned' },
+ { label: 'Converted', value: 'converted' },
+ { label: 'Dismissed', value: 'dismissed' },
+ { label: 'All', value: 'all' },
+ ],
+ },
+];
diff --git a/src/components/inquiries/inquiry-list.tsx b/src/components/inquiries/inquiry-list.tsx
new file mode 100644
index 00000000..2ad21c5d
--- /dev/null
+++ b/src/components/inquiries/inquiry-list.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useParams } from 'next/navigation';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner';
+
+import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
+import { DataTable } from '@/components/shared/data-table';
+import { FilterBar } from '@/components/shared/filter-bar';
+import { ColumnPicker } from '@/components/shared/column-picker';
+import { PageHeader } from '@/components/shared/page-header';
+import { EmptyState } from '@/components/shared/empty-state';
+import { TableSkeleton } from '@/components/shared/loading-skeleton';
+import { usePaginatedQuery } from '@/hooks/use-paginated-query';
+import { useTablePreferences } from '@/hooks/use-table-preferences';
+import { apiFetch } from '@/lib/api/client';
+import { toastError } from '@/lib/api/toast-error';
+import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
+import {
+ getInquiryColumns,
+ INQUIRY_COLUMN_OPTIONS,
+ INQUIRY_DEFAULT_HIDDEN,
+ type InquiryRow,
+ type InquiryTriageState,
+} from '@/components/inquiries/inquiry-columns';
+import { InquiryCard } from '@/components/inquiries/inquiry-card';
+
+export function InquiryList() {
+ const params = useParams<{ portSlug: string }>();
+ const portSlug = params?.portSlug ?? '';
+ const queryClient = useQueryClient();
+
+ const { setChrome } = useMobileChrome();
+ useEffect(() => {
+ setChrome({ title: 'Inquiries', showBackButton: false });
+ return () => setChrome({ title: null, showBackButton: false });
+ }, [setChrome]);
+
+ const {
+ data,
+ pagination,
+ isLoading,
+ isFetching,
+ sort,
+ setSort,
+ setPage,
+ setPageSize,
+ filters,
+ setFilter,
+ clearFilters,
+ } = usePaginatedQuery({
+ queryKey: ['inquiries'],
+ endpoint: '/api/v1/inquiries',
+ initialSort: { field: 'receivedAt', direction: 'desc' },
+ filterDefinitions: inquiryFilterDefinitions,
+ });
+
+ const triageMutation = useMutation({
+ mutationFn: (args: { id: string; state: InquiryTriageState }) =>
+ apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
+ method: 'PATCH',
+ body: { state: args.state },
+ }),
+ onSuccess: (_d, vars) => {
+ queryClient.invalidateQueries({ queryKey: ['inquiries'] });
+ toast.success(`Marked ${vars.state}.`);
+ },
+ onError: (err: unknown) => toastError(err, 'Update failed'),
+ });
+
+ const columns = getInquiryColumns({
+ portSlug,
+ onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
+ });
+
+ const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
+ const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
{
+ setPage(p);
+ setPageSize(ps);
+ }}
+ sort={sort}
+ onSortChange={setSort}
+ isLoading={isFetching && !isLoading}
+ getRowId={(row) => row.id}
+ cardRender={(row) => }
+ emptyState={
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx
index 595e51dd..e4aed6e5 100644
--- a/src/components/layout/mobile/more-sheet.tsx
+++ b/src/components/layout/mobile/more-sheet.tsx
@@ -6,6 +6,7 @@ import {
Bookmark,
Building2,
FileSignature,
+ MailQuestion,
FileText,
Globe,
Home,
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
items: [
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
+ { label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index f888ff31..0cee1a11 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -16,6 +16,7 @@ import {
FileText,
FileBarChart,
Inbox,
+ MailQuestion,
Camera,
Globe,
Settings,
@@ -115,6 +116,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
+ { href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
{
href: `${base}/tenancies`,