feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function InquiriesPage() {
|
/**
|
||||||
return <InquiryInbox />;
|
* 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`);
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
11
src/app/(dashboard)/[portSlug]/inquiries/[id]/loading.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
10
src/app/(dashboard)/[portSlug]/inquiries/[id]/page.tsx
Normal file
@@ -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 <InquiryDetail id={id} />;
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
5
src/app/(dashboard)/[portSlug]/inquiries/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { InquiryList } from '@/components/inquiries/inquiry-list';
|
||||||
|
|
||||||
|
export default function InquiriesPage() {
|
||||||
|
return <InquiryList />;
|
||||||
|
}
|
||||||
35
src/components/inquiries/inquiry-card.tsx
Normal file
35
src/components/inquiries/inquiry-card.tsx
Normal file
@@ -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 (
|
||||||
|
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
|
||||||
|
<Card className="transition-shadow hover:shadow-sm">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
|
||||||
|
{inquiry.contactEmail ? (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{KIND_LABELS[inquiry.kind]}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>
|
||||||
|
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
src/components/inquiries/inquiry-columns.tsx
Normal file
217
src/components/inquiries/inquiry-columns.tsx
Normal file
@@ -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<InquiryKind, string> = {
|
||||||
|
berth_inquiry: 'Berth',
|
||||||
|
residence_inquiry: 'Residence',
|
||||||
|
contact_form: 'Contact',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_TONE: Record<InquiryKind, string> = {
|
||||||
|
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<InquiryTriageState, string> = {
|
||||||
|
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<InquiryRow, unknown>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'contactName',
|
||||||
|
accessorKey: 'contactName',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/inquiries/${row.original.id}`}
|
||||||
|
className="truncate font-medium text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row.original.contactName || '(no name)'}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contactEmail',
|
||||||
|
accessorKey: 'contactEmail',
|
||||||
|
header: 'Email',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const email = getValue() as string | null;
|
||||||
|
return email ? (
|
||||||
|
<span className="text-sm">{email}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kind',
|
||||||
|
accessorKey: 'kind',
|
||||||
|
header: 'Type',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const kind = getValue() as InquiryKind;
|
||||||
|
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'triageState',
|
||||||
|
accessorKey: 'triageState',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const state = row.original.triageState;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Badge className={TRIAGE_TONE[state]}>{state}</Badge>
|
||||||
|
{row.original.convertedInterestId ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
|
||||||
|
className="text-primary hover:underline text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
interest →
|
||||||
|
</Link>
|
||||||
|
) : row.original.convertedClientId ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
|
||||||
|
className="text-primary hover:underline text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
client →
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utmSource',
|
||||||
|
accessorKey: 'utmSource',
|
||||||
|
header: 'UTM source',
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const utm = getValue() as string | null;
|
||||||
|
return utm ? (
|
||||||
|
<span className="text-sm">{utm}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'receivedAt',
|
||||||
|
accessorKey: 'receivedAt',
|
||||||
|
header: 'Received',
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const iso = getValue() as string;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
|
||||||
|
{formatDistanceToNowStrict(d, { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
enableSorting: false,
|
||||||
|
size: 48,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isResolved =
|
||||||
|
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
|
||||||
|
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{!isResolved ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
|
||||||
|
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Assign to me
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
|
||||||
|
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
|
||||||
|
Reopen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
119
src/components/inquiries/inquiry-convert-actions.tsx
Normal file
@@ -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 (
|
||||||
|
<PermissionGate resource="inquiries" action="manage">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{alreadyInterest ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
|
||||||
|
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Convert to interest
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inquiry.convertedClientId ? (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => convert.mutate('client')}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Convert to client
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inquiry.triageState === 'open' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => triage.mutate('assigned')}
|
||||||
|
>
|
||||||
|
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Assign to me
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => triage.mutate('dismissed')}
|
||||||
|
>
|
||||||
|
<X className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PermissionGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
src/components/inquiries/inquiry-detail.tsx
Normal file
184
src/components/inquiries/inquiry-detail.tsx
Normal file
@@ -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<string, unknown> | 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 (
|
||||||
|
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 break-words">
|
||||||
|
{value || <span className="text-muted-foreground">—</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InquiryDetail({ id }: { id: string }) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery<InquiryDetailData>({
|
||||||
|
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 (
|
||||||
|
<DetailNotFound
|
||||||
|
entity="inquiry"
|
||||||
|
backHref={`/${portSlug}/inquiries`}
|
||||||
|
backLabel="Back to inquiries"
|
||||||
|
status={status}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = (data?.payload ?? {}) as Record<string, unknown>;
|
||||||
|
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
|
||||||
|
|
||||||
|
const tabs: DetailTab[] = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<Row label="Name" value={data?.contactName} />
|
||||||
|
<Row label="Email" value={data?.contactEmail} />
|
||||||
|
<Row label="Phone" value={str('phone')} />
|
||||||
|
{data?.kind === 'residence_inquiry' ? (
|
||||||
|
<Row label="Place of residence" value={str('address')} />
|
||||||
|
) : null}
|
||||||
|
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
|
||||||
|
{data?.kind === 'contact_form' ? <Row label="Comments" value={str('comments')} /> : null}
|
||||||
|
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
|
||||||
|
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
|
||||||
|
<Row label="Source IP" value={data?.sourceIp} />
|
||||||
|
<Row label="UTM source" value={data?.utmSource} />
|
||||||
|
<Row label="UTM medium" value={data?.utmMedium} />
|
||||||
|
<Row label="UTM campaign" value={data?.utmCampaign} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tracking',
|
||||||
|
label: 'Tracking',
|
||||||
|
content: (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<Row
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
data ? (
|
||||||
|
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Triaged at"
|
||||||
|
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Converted client"
|
||||||
|
value={
|
||||||
|
data?.convertedClient ? (
|
||||||
|
<a
|
||||||
|
href={`/${portSlug}/clients/${data.convertedClient.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{data.convertedClient.fullName}
|
||||||
|
</a>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Converted interest"
|
||||||
|
value={
|
||||||
|
data?.convertedInterest ? (
|
||||||
|
<a
|
||||||
|
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View interest ({data.convertedInterest.pipelineStage})
|
||||||
|
</a>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payload',
|
||||||
|
label: 'Raw payload',
|
||||||
|
content: (
|
||||||
|
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
|
||||||
|
{JSON.stringify(data?.payload ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
isLoading={isLoading}
|
||||||
|
header={
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
|
||||||
|
{data ? (
|
||||||
|
<Badge className={TRIAGE_TONE[data.triageState]}>{data.triageState}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{data ? KIND_LABELS[data.kind] : ''} inquiry
|
||||||
|
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/inquiries/inquiry-filters.tsx
Normal file
33
src/components/inquiries/inquiry-filters.tsx
Normal file
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
127
src/components/inquiries/inquiry-list.tsx
Normal file
127
src/components/inquiries/inquiry-list.tsx
Normal file
@@ -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<InquiryRow>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Inquiries"
|
||||||
|
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
|
||||||
|
variant="gradient"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<FilterBar
|
||||||
|
filters={inquiryFilterDefinitions}
|
||||||
|
values={filters}
|
||||||
|
onChange={setFilter}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<TableSkeleton />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
data={data}
|
||||||
|
pagination={pagination}
|
||||||
|
onPaginationChange={(p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}}
|
||||||
|
sort={sort}
|
||||||
|
onSortChange={setSort}
|
||||||
|
isLoading={isFetching && !isLoading}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
|
||||||
|
emptyState={
|
||||||
|
<EmptyState
|
||||||
|
title="No inquiries found"
|
||||||
|
description="Submissions from the marketing site will appear here."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Bookmark,
|
Bookmark,
|
||||||
Building2,
|
Building2,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
|
MailQuestion,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
Home,
|
Home,
|
||||||
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
|
||||||
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
{ label: 'Interests', icon: Bookmark, segment: 'interests' },
|
||||||
|
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
|
||||||
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
|
||||||
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
{ label: 'Companies', icon: Building2, segment: 'companies' },
|
||||||
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
FileBarChart,
|
FileBarChart,
|
||||||
Inbox,
|
Inbox,
|
||||||
|
MailQuestion,
|
||||||
Camera,
|
Camera,
|
||||||
Globe,
|
Globe,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -115,6 +116,7 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
|
|||||||
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship },
|
||||||
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
{ href: `${base}/companies`, label: 'Companies', icon: Building2 },
|
||||||
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
|
||||||
|
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
|
||||||
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
|
||||||
{
|
{
|
||||||
href: `${base}/tenancies`,
|
href: `${base}/tenancies`,
|
||||||
|
|||||||
Reference in New Issue
Block a user