From 0cc05f302f44dbdbbdb77e5b53e09b0c42c7aacb Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 17 Jun 2026 18:23:13 +0200 Subject: [PATCH] feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox Co-Authored-By: Claude Fable 5 --- .../[portSlug]/admin/inquiries/page.tsx | 16 +- .../[portSlug]/inquiries/[id]/loading.tsx | 11 + .../[portSlug]/inquiries/[id]/page.tsx | 10 + .../(dashboard)/[portSlug]/inquiries/page.tsx | 5 + src/components/inquiries/inquiry-card.tsx | 35 +++ src/components/inquiries/inquiry-columns.tsx | 217 ++++++++++++++++++ .../inquiries/inquiry-convert-actions.tsx | 119 ++++++++++ src/components/inquiries/inquiry-detail.tsx | 184 +++++++++++++++ src/components/inquiries/inquiry-filters.tsx | 33 +++ src/components/inquiries/inquiry-list.tsx | 127 ++++++++++ src/components/layout/mobile/more-sheet.tsx | 2 + src/components/layout/sidebar.tsx | 2 + 12 files changed, 758 insertions(+), 3 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx create mode 100644 src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx create mode 100644 src/app/(dashboard)/[portSlug]/inquiries/page.tsx create mode 100644 src/components/inquiries/inquiry-card.tsx create mode 100644 src/components/inquiries/inquiry-columns.tsx create mode 100644 src/components/inquiries/inquiry-convert-actions.tsx create mode 100644 src/components/inquiries/inquiry-detail.tsx create mode 100644 src/components/inquiries/inquiry-filters.tsx create mode 100644 src/components/inquiries/inquiry-list.tsx 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`,