Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
220
src/components/interests/interest-columns.tsx
Normal file
220
src/components/interests/interest-columns.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
|
||||
export interface InterestRow {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
in_communication: 'bg-sky-100 text-sky-700',
|
||||
visited: 'bg-violet-100 text-violet-700',
|
||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
||||
contract: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
hot_lead: 'Hot Lead',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (interest: InterestRow) => void;
|
||||
onArchive: (interest: InterestRow) => void;
|
||||
}
|
||||
|
||||
export function getInterestColumns({
|
||||
portSlug,
|
||||
onEdit,
|
||||
onArchive,
|
||||
}: GetColumnsOptions): ColumnDef<InterestRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'clientName',
|
||||
accessorKey: 'clientName',
|
||||
header: 'Client',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${row.original.clientId}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.clientName ?? '—'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'berthMooringNumber',
|
||||
accessorKey: 'berthMooringNumber',
|
||||
header: 'Berth',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${row.original.berthId}`}
|
||||
className="text-primary hover:underline text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.berthMooringNumber}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pipelineStage',
|
||||
accessorKey: 'pipelineStage',
|
||||
header: 'Stage',
|
||||
cell: ({ getValue }) => {
|
||||
const stage = getValue() as string;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{STAGE_LABELS[stage] ?? stage}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'leadCategory',
|
||||
accessorKey: 'leadCategory',
|
||||
header: 'Category',
|
||||
cell: ({ getValue }) => {
|
||||
const cat = getValue() as string | null;
|
||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
accessorKey: 'source',
|
||||
header: 'Source',
|
||||
cell: ({ getValue }) => {
|
||||
const source = getValue() as string | null;
|
||||
if (!source) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const rowTags = row.original.tags ?? [];
|
||||
if (rowTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{rowTags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{rowTags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{rowTags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableSorting: false,
|
||||
size: 48,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
211
src/components/interests/interest-detail-header.tsx
Normal file
211
src/components/interests/interest-detail-header.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Archive, RotateCcw, TrendingUp } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TagBadge } from '@/components/shared/tag-badge';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
open: 'bg-slate-100 text-slate-700',
|
||||
details_sent: 'bg-blue-100 text-blue-700',
|
||||
in_communication: 'bg-sky-100 text-sky-700',
|
||||
visited: 'bg-violet-100 text-violet-700',
|
||||
signed_eoi_nda: 'bg-amber-100 text-amber-700',
|
||||
deposit_10pct: 'bg-orange-100 text-orange-700',
|
||||
contract: 'bg-green-100 text-green-700',
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
hot_lead: 'Hot Lead',
|
||||
};
|
||||
|
||||
interface InterestDetailHeaderProps {
|
||||
portSlug: string;
|
||||
interest: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
archivedAt: string | null;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [stageOpen, setStageOpen] = useState(false);
|
||||
|
||||
const isArchived = !!interest.archivedAt;
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setArchiveOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setArchiveOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{interest.clientName ?? 'Unknown Client'}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
<Badge variant="secondary" className="text-xs">Archived</Badge>
|
||||
)}
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||
{interest.berthMooringNumber && (
|
||||
<span>
|
||||
Berth:{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{interest.berthMooringNumber}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{interest.leadCategory && (
|
||||
<span>
|
||||
Category:{' '}
|
||||
<span className="text-foreground">
|
||||
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{interest.source && (
|
||||
<span>
|
||||
Source:{' '}
|
||||
<span className="text-foreground capitalize">{interest.source}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{interest.tags && interest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{interest.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<PermissionGate resource="interests" action="edit">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="interests" action="change_stage">
|
||||
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}>
|
||||
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
|
||||
Change Stage
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
<PermissionGate resource="interests" action="delete">
|
||||
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}>
|
||||
{isArchived ? (
|
||||
<>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restore
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InterestForm
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
interest={interest as any}
|
||||
/>
|
||||
|
||||
<InterestStagePicker
|
||||
open={stageOpen}
|
||||
onOpenChange={setStageOpen}
|
||||
interestId={interest.id}
|
||||
currentStage={interest.pipelineStage}
|
||||
/>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
entityName={interest.clientName ?? 'Interest'}
|
||||
entityType="Interest"
|
||||
isArchived={isArchived}
|
||||
onConfirm={() => {
|
||||
if (isArchived) {
|
||||
restoreMutation.mutate();
|
||||
} else {
|
||||
archiveMutation.mutate();
|
||||
}
|
||||
}}
|
||||
isLoading={archiveMutation.isPending || restoreMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/interests/interest-detail.tsx
Normal file
84
src/components/interests/interest-detail.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
|
||||
import { getInterestTabs } from '@/components/interests/interest-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface InterestData {
|
||||
id: string;
|
||||
portId: string;
|
||||
clientId: string;
|
||||
clientName: string | null;
|
||||
berthId: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
leadCategory: string | null;
|
||||
source: string | null;
|
||||
eoiStatus: string | null;
|
||||
contractStatus: string | null;
|
||||
depositStatus: string | null;
|
||||
reservationStatus: string | null;
|
||||
dateFirstContact: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
notes: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
interface InterestDetailProps {
|
||||
interestId: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export function InterestDetail({ interestId, currentUserId }: InterestDetailProps) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
|
||||
const { data, isLoading } = useQuery<InterestData>({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
|
||||
(r) => r.data,
|
||||
),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'interest:updated': [['interests', interestId]],
|
||||
'interest:stageChanged': [['interests', interestId]],
|
||||
'interest:archived': [['interests', interestId]],
|
||||
'interest:berthLinked': [['interests', interestId]],
|
||||
'interest:berthUnlinked': [['interests', interestId]],
|
||||
});
|
||||
|
||||
const tabs = data
|
||||
? getInterestTabs({ interestId, currentUserId, interest: data })
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
header={
|
||||
data ? (
|
||||
<InterestDetailHeader portSlug={portSlug} interest={data} />
|
||||
) : null
|
||||
}
|
||||
tabs={tabs}
|
||||
defaultTab="overview"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/components/interests/interest-documents-tab.tsx
Normal file
68
src/components/interests/interest-documents-tab.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentList } from '@/components/documents/document-list';
|
||||
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface InterestDocumentsTabProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
interface InterestData {
|
||||
id: string;
|
||||
berthId?: string | null;
|
||||
client?: {
|
||||
fullName?: string | null;
|
||||
yachtLengthFt?: string | null;
|
||||
yachtLengthM?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
|
||||
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: interestRes } = useQuery({
|
||||
queryKey: ['interests', interestId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
|
||||
});
|
||||
|
||||
const interest = interestRes?.data;
|
||||
|
||||
const prerequisites = {
|
||||
hasName: Boolean(interest?.client?.fullName),
|
||||
hasEmail: Boolean(
|
||||
interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value),
|
||||
),
|
||||
hasYachtDims: Boolean(
|
||||
interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM,
|
||||
),
|
||||
hasBerth: Boolean(interest?.berthId),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
|
||||
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
|
||||
Generate EOI
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DocumentList interestId={interestId} />
|
||||
|
||||
<EoiGenerateDialog
|
||||
interestId={interestId}
|
||||
open={eoiDialogOpen}
|
||||
onOpenChange={setEoiDialogOpen}
|
||||
prerequisites={prerequisites}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/interests/interest-files-tab.tsx
Normal file
93
src/components/interests/interest-files-tab.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { FileGrid } from '@/components/files/file-grid';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { FileRow } from '@/components/files/file-grid';
|
||||
|
||||
interface InterestFilesTabProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
export function InterestFilesTab({ interestId }: InterestFilesTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow>({
|
||||
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
|
||||
endpoint: `/api/v1/files?entityType=interest&entityId=${encodeURIComponent(interestId)}`,
|
||||
filterDefinitions: [],
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'file:uploaded': [['files', { entityType: 'interest', entityId: interestId }]],
|
||||
'file:updated': [['files', { entityType: 'interest', entityId: interestId }]],
|
||||
'file:deleted': [['files', { entityType: 'interest', entityId: interestId }]],
|
||||
});
|
||||
|
||||
const handleDownload = async (file: FileRow) => {
|
||||
try {
|
||||
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||
`/api/v1/files/${file.id}/download`,
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = res.data.url;
|
||||
a.download = res.data.filename;
|
||||
a.click();
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (file: FileRow) => {
|
||||
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
|
||||
});
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
entityType="interest"
|
||||
entityId={interestId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</PermissionGate>
|
||||
|
||||
<FileGrid
|
||||
files={data}
|
||||
onDownload={handleDownload}
|
||||
onPreview={setPreviewFile}
|
||||
onRename={() => {}}
|
||||
onDelete={handleDelete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<FilePreviewDialog
|
||||
open={!!previewFile}
|
||||
onOpenChange={(open) => !open && setPreviewFile(null)}
|
||||
fileId={previewFile?.id}
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/interests/interest-filters.tsx
Normal file
72
src/components/interests/interest-filters.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
hot_lead: 'Hot Lead',
|
||||
};
|
||||
|
||||
export const interestFilterDefinitions: FilterDefinition[] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search interests...',
|
||||
},
|
||||
{
|
||||
key: 'pipelineStage',
|
||||
label: 'Stage',
|
||||
type: 'multi-select',
|
||||
options: PIPELINE_STAGES.map((s) => ({
|
||||
label: STAGE_LABELS[s] ?? s,
|
||||
value: s,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'leadCategory',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
options: LEAD_CATEGORIES.map((c) => ({
|
||||
label: CATEGORY_LABELS[c] ?? c,
|
||||
value: c,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Website', value: 'website' },
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Referral', value: 'referral' },
|
||||
{ label: 'Broker', value: 'broker' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'eoiStatus',
|
||||
label: 'EOI Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Waiting for Signatures', value: 'waiting_for_signatures' },
|
||||
{ label: 'Signed', value: 'signed' },
|
||||
{ label: 'Expired', value: 'expired' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
label: 'Include Archived',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
455
src/components/interests/interest-form.tsx
Normal file
455
src/components/interests/interest-form.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, ChevronsUpDown, Check } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
hot_lead: 'Hot Lead',
|
||||
};
|
||||
|
||||
interface InterestFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interest?: {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName?: string | null;
|
||||
berthId?: string | null;
|
||||
berthMooringNumber?: string | null;
|
||||
pipelineStage: string;
|
||||
leadCategory?: string | null;
|
||||
source?: string | null;
|
||||
notes?: string | null;
|
||||
reminderEnabled?: boolean;
|
||||
reminderDays?: number | null;
|
||||
tags?: Array<{ id: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function InterestForm({ open, onOpenChange, interest }: InterestFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!interest;
|
||||
|
||||
const [clientOpen, setClientOpen] = useState(false);
|
||||
const [berthOpen, setBerthOpen] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateInterestInput>({
|
||||
resolver: zodResolver(createInterestSchema),
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
pipelineStage: 'open',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
const reminderEnabled = watch('reminderEnabled');
|
||||
const selectedClientId = watch('clientId');
|
||||
const selectedBerthId = watch('berthId');
|
||||
|
||||
const { options: clientOptions, isLoading: clientsLoading, setSearch: setClientSearch } =
|
||||
useEntityOptions({
|
||||
endpoint: '/api/v1/clients/options',
|
||||
labelKey: 'fullName',
|
||||
});
|
||||
|
||||
const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch } =
|
||||
useEntityOptions({
|
||||
endpoint: '/api/v1/berths/options',
|
||||
labelKey: 'mooringNumber',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (interest && open) {
|
||||
reset({
|
||||
clientId: interest.clientId,
|
||||
berthId: interest.berthId ?? undefined,
|
||||
pipelineStage: interest.pipelineStage as typeof PIPELINE_STAGES[number],
|
||||
leadCategory: interest.leadCategory as typeof LEAD_CATEGORIES[number] | undefined,
|
||||
source: interest.source ?? undefined,
|
||||
notes: interest.notes ?? undefined,
|
||||
reminderEnabled: interest.reminderEnabled ?? false,
|
||||
reminderDays: interest.reminderDays ?? undefined,
|
||||
tagIds: interest.tags?.map((t) => t.id) ?? [],
|
||||
});
|
||||
} else if (!interest && open) {
|
||||
reset({
|
||||
clientId: '',
|
||||
pipelineStage: 'open',
|
||||
reminderEnabled: false,
|
||||
tagIds: [],
|
||||
});
|
||||
}
|
||||
}, [interest, open, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateInterestInput) => {
|
||||
if (isEdit) {
|
||||
const { tagIds: tIds, ...rest } = data;
|
||||
await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest });
|
||||
if (tIds) {
|
||||
await apiFetch(`/api/v1/interests/${interest!.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tagIds: tIds },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await apiFetch('/api/v1/interests', { method: 'POST', body: data });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
|
||||
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
||||
className="space-y-6 py-6"
|
||||
>
|
||||
{/* Client */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Client & Berth
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Client *</Label>
|
||||
<Popover open={clientOpen} onOpenChange={setClientOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={clientOpen}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!selectedClientId && 'text-muted-foreground',
|
||||
)}
|
||||
disabled={isEdit}
|
||||
>
|
||||
{selectedClient?.label ?? (interest?.clientName ?? 'Select client...')}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search clients..."
|
||||
onValueChange={setClientSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{clientsLoading ? 'Loading...' : 'No clients found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{clientOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(val) => {
|
||||
setValue('clientId', val);
|
||||
setClientOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedClientId === option.value ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{errors.clientId && (
|
||||
<p className="text-xs text-destructive">{errors.clientId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Berth (optional)</Label>
|
||||
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={berthOpen}
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!selectedBerthId && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{selectedBerth?.label ?? (interest?.berthMooringNumber ?? 'Select berth...')}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search berths..."
|
||||
onValueChange={setBerthSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{berthsLoading ? 'Loading...' : 'No berths found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value=""
|
||||
onSelect={() => {
|
||||
setValue('berthId', undefined);
|
||||
setBerthOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
!selectedBerthId ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{berthOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(val) => {
|
||||
setValue('berthId', val);
|
||||
setBerthOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Pipeline */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Pipeline
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Stage</Label>
|
||||
<Select
|
||||
value={watch('pipelineStage') ?? 'open'}
|
||||
onValueChange={(v) => setValue('pipelineStage', v as typeof PIPELINE_STAGES[number])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PIPELINE_STAGES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STAGE_LABELS[s] ?? s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Lead Category</Label>
|
||||
<Select
|
||||
value={watch('leadCategory') ?? ''}
|
||||
onValueChange={(v) =>
|
||||
setValue('leadCategory', v ? v as typeof LEAD_CATEGORIES[number] : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEAD_CATEGORIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{CATEGORY_LABELS[c] ?? c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
value={watch('source') ?? ''}
|
||||
onValueChange={(v) => setValue('source', v || undefined)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea
|
||||
{...register('notes')}
|
||||
placeholder="Add notes about this interest..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Reminder */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Reminder
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="reminderEnabled"
|
||||
checked={reminderEnabled ?? false}
|
||||
onCheckedChange={(v) => setValue('reminderEnabled', !!v)}
|
||||
/>
|
||||
<Label htmlFor="reminderEnabled">Enable reminder</Label>
|
||||
</div>
|
||||
{reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<Label>Reminder Days</Label>
|
||||
<Input
|
||||
{...register('reminderDays', { valueAsNumber: true })}
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="e.g. 7"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker
|
||||
selectedIds={tagIds}
|
||||
onChange={(ids) => setValue('tagIds', ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
{(isSubmitting || mutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEdit ? 'Save Changes' : 'Create Interest'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
184
src/components/interests/interest-list.tsx
Normal file
184
src/components/interests/interest-list.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Plus, LayoutList, Kanban } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { InterestForm } from '@/components/interests/interest-form';
|
||||
import { PipelineBoard } from '@/components/interests/pipeline-board';
|
||||
import { interestFilterDefinitions } from '@/components/interests/interest-filters';
|
||||
import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePipelineStore } from '@/stores/pipeline-store';
|
||||
|
||||
export function InterestList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const { viewMode, setViewMode } = usePipelineStore();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
|
||||
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
isFetching,
|
||||
sort,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<InterestRow>({
|
||||
queryKey: ['interests'],
|
||||
endpoint: '/api/v1/interests',
|
||||
filterDefinitions: interestFilterDefinitions,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'interest:created': [['interests']],
|
||||
'interest:updated': [['interests']],
|
||||
'interest:stageChanged': [['interests']],
|
||||
'interest:archived': [['interests']],
|
||||
'interest:berthLinked': [['interests']],
|
||||
'interest:berthUnlinked': [['interests']],
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
setArchiveInterest(null);
|
||||
},
|
||||
});
|
||||
|
||||
const columns = getInterestColumns({
|
||||
portSlug,
|
||||
onEdit: (interest) => setEditInterest(interest),
|
||||
onArchive: (interest) => setArchiveInterest(interest),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Interests"
|
||||
description="Track prospective berth interest and pipeline"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-md overflow-hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === 'table' ? 'default' : 'ghost'}
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('table')}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={viewMode === 'board' ? 'default' : 'ghost'}
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode('board')}
|
||||
>
|
||||
<Kanban className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<PermissionGate resource="interests" action="create">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Interest
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterBar
|
||||
filters={interestFilterDefinitions}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="interests"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{viewMode === 'board' ? (
|
||||
<PipelineBoard />
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No interests found"
|
||||
description="Get started by adding your first interest."
|
||||
action={{ label: 'New Interest', onClick: () => setCreateOpen(true) }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InterestForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{editInterest && (
|
||||
<InterestForm
|
||||
open={!!editInterest}
|
||||
onOpenChange={(open) => !open && setEditInterest(null)}
|
||||
interest={editInterest as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={!!archiveInterest}
|
||||
onOpenChange={(open) => !open && setArchiveInterest(null)}
|
||||
entityName={archiveInterest?.clientName ?? 'Interest'}
|
||||
entityType="Interest"
|
||||
isArchived={false}
|
||||
onConfirm={() =>
|
||||
archiveInterest && archiveMutation.mutate(archiveInterest.id)
|
||||
}
|
||||
isLoading={archiveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/interests/interest-score-badge.tsx
Normal file
84
src/components/interests/interest-score-badge.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFeatureFlag } from '@/hooks/use-feature-flag';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import type { InterestScore } from '@/lib/services/interest-scoring.service';
|
||||
|
||||
// ─── Score tier helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getScoreTier(score: number): { label: string; className: string } {
|
||||
if (score >= 80) return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' };
|
||||
if (score >= 60) return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' };
|
||||
if (score >= 40) return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' };
|
||||
return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' };
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InterestScoreBadgeProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
export function InterestScoreBadge({ interestId }: InterestScoreBadgeProps) {
|
||||
const featureEnabled = useFeatureFlag('ai_interest_scoring');
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: InterestScore }>({
|
||||
queryKey: ['interest-score', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
|
||||
enabled: featureEnabled,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour — mirrors server-side cache TTL
|
||||
});
|
||||
|
||||
if (!featureEnabled) return null;
|
||||
if (isLoading || !data) return null;
|
||||
|
||||
const score = data.data;
|
||||
const { label, className } = getScoreTier(score.totalScore);
|
||||
const { breakdown } = score;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold cursor-default select-none ${className}`}
|
||||
>
|
||||
{label}
|
||||
<span className="opacity-70">{score.totalScore}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="p-3 space-y-1 text-left min-w-[180px]">
|
||||
<p className="font-semibold text-sm mb-2">Interest Score Breakdown</p>
|
||||
<ScoreRow label="Pipeline Age" value={breakdown.pipelineAge} max={100} />
|
||||
<ScoreRow label="Stage Speed" value={breakdown.stageSpeed} max={100} />
|
||||
<ScoreRow label="Documents" value={breakdown.documentCompleteness} max={100} />
|
||||
<ScoreRow label="Engagement" value={breakdown.engagement} max={100} />
|
||||
<ScoreRow label="Berth Linked" value={breakdown.berthLinked} max={25} />
|
||||
<p className="text-xs opacity-60 pt-1 border-t border-primary-foreground/20">
|
||||
Total: {score.totalScore}/100
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreRow({ label, value, max }: { label: string; value: number; max: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 text-xs">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium">
|
||||
{value}/{max}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/interests/interest-stage-picker.tsx
Normal file
130
src/components/interests/interest-stage-picker.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
interface InterestStagePickerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interestId: string;
|
||||
currentStage: string;
|
||||
}
|
||||
|
||||
export function InterestStagePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
interestId,
|
||||
currentStage,
|
||||
}: InterestStagePickerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [newStage, setNewStage] = useState<string>(currentStage);
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: { pipelineStage: newStage, reason: reason || undefined },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
onOpenChange(false);
|
||||
setReason('');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Stage</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Current Stage</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{STAGE_LABELS[currentStage] ?? currentStage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>New Stage</Label>
|
||||
<Select value={newStage} onValueChange={setNewStage}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select new stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PIPELINE_STAGES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STAGE_LABELS[s] ?? s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Reason (optional)</Label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Reason for stage change..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || newStage === currentStage}
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
156
src/components/interests/interest-tabs.tsx
Normal file
156
src/components/interests/interest-tabs.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
import { RecommendationList } from '@/components/interests/recommendation-list';
|
||||
import { InterestTimeline } from '@/components/interests/interest-timeline';
|
||||
|
||||
interface InterestTabsOptions {
|
||||
interestId: string;
|
||||
currentUserId?: string;
|
||||
interest: {
|
||||
eoiStatus: string | null;
|
||||
contractStatus: string | null;
|
||||
depositStatus: string | null;
|
||||
reservationStatus: string | null;
|
||||
dateFirstContact: string | null;
|
||||
dateLastContact: string | null;
|
||||
dateEoiSent: string | null;
|
||||
dateEoiSigned: string | null;
|
||||
dateContractSent: string | null;
|
||||
dateContractSigned: string | null;
|
||||
dateDepositReceived: string | null;
|
||||
reminderEnabled: boolean;
|
||||
reminderDays: number | null;
|
||||
reminderLastFired: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b last:border-0">
|
||||
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: string | null) {
|
||||
if (!date) return null;
|
||||
return format(new Date(date), 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* EOI & Contract Status */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Status</h3>
|
||||
<dl>
|
||||
<InfoRow label="EOI Status" value={interest.eoiStatus} />
|
||||
<InfoRow label="Contract Status" value={interest.contractStatus} />
|
||||
<InfoRow label="Deposit Status" value={interest.depositStatus} />
|
||||
<InfoRow label="Reservation Status" value={interest.reservationStatus} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Key Dates */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
|
||||
<dl>
|
||||
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
|
||||
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
|
||||
<InfoRow label="EOI Sent" value={formatDate(interest.dateEoiSent)} />
|
||||
<InfoRow label="EOI Signed" value={formatDate(interest.dateEoiSigned)} />
|
||||
<InfoRow label="Contract Sent" value={formatDate(interest.dateContractSent)} />
|
||||
<InfoRow label="Contract Signed" value={formatDate(interest.dateContractSigned)} />
|
||||
<InfoRow label="Deposit Received" value={formatDate(interest.dateDepositReceived)} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Reminder */}
|
||||
{interest.reminderEnabled && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Reminder</h3>
|
||||
<dl>
|
||||
<InfoRow
|
||||
label="Reminder Days"
|
||||
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Last Fired"
|
||||
value={formatDate(interest.reminderLastFired)}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{interest.notes && (
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{interest.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getInterestTabs({
|
||||
interestId,
|
||||
currentUserId,
|
||||
interest,
|
||||
}: InterestTabsOptions): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab interest={interest} />,
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="interests"
|
||||
entityId={interestId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Documents tab available after document system is built</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Files tab available after file system is built</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'recommendations',
|
||||
label: 'Recommendations',
|
||||
content: <RecommendationList interestId={interestId} />,
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: <InterestTimeline interestId={interestId} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
87
src/components/interests/interest-timeline.tsx
Normal file
87
src/components/interests/interest-timeline.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Pencil, FileText, Clock, PlusCircle, Archive, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'audit' | 'document_event';
|
||||
action: string;
|
||||
description: string;
|
||||
userId: string | null;
|
||||
createdAt: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface InterestTimelineProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
function eventIcon(event: TimelineEvent) {
|
||||
if (event.type === 'document_event') return <FileText className="h-4 w-4" />;
|
||||
if (event.action === 'create') return <PlusCircle className="h-4 w-4 text-green-500" />;
|
||||
if (event.action === 'archive') return <Archive className="h-4 w-4 text-orange-500" />;
|
||||
if (event.action === 'restore') return <RotateCcw className="h-4 w-4 text-blue-500" />;
|
||||
if (event.metadata?.type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
|
||||
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
export function InterestTimeline({ interestId }: InterestTimelineProps) {
|
||||
const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({
|
||||
queryKey: ['interest-timeline', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/timeline`),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 animate-pulse">
|
||||
<div className="h-8 w-8 rounded-full bg-muted shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-muted rounded w-3/4" />
|
||||
<div className="h-2 bg-muted rounded w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const events = data?.data ?? [];
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No activity yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-0">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||
|
||||
{events.map((event, idx) => (
|
||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||
{/* Icon */}
|
||||
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
|
||||
{eventIcon(event)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pt-1">
|
||||
<p className="text-sm">{event.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{format(new Date(event.createdAt), 'MMM d, yyyy HH:mm')}
|
||||
{event.userId && ` · by ${event.userId}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/interests/pipeline-board.tsx
Normal file
131
src/components/interests/pipeline-board.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
|
||||
|
||||
import { PipelineColumn } from '@/components/interests/pipeline-column';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePipelineStore } from '@/stores/pipeline-store';
|
||||
import { PIPELINE_STAGES } from '@/lib/constants';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'Signed EOI/NDA',
|
||||
deposit_10pct: 'Deposit 10%',
|
||||
contract: 'Contract',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
interface InterestRow {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
leadCategory: string | null;
|
||||
pipelineStage: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function PipelineBoard() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const { boardFilters } = usePipelineStore();
|
||||
|
||||
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
|
||||
queryKey: ['interests-board', portSlug],
|
||||
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
|
||||
});
|
||||
|
||||
const interests = useMemo(() => {
|
||||
if (!allData?.data) return [];
|
||||
return allData.data.filter((i) => {
|
||||
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
|
||||
if (boardFilters.search) {
|
||||
const q = boardFilters.search.toLowerCase();
|
||||
if (!i.clientName?.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allData, boardFilters]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, InterestRow[]> = {};
|
||||
for (const stage of PIPELINE_STAGES) {
|
||||
map[stage] = [];
|
||||
}
|
||||
for (const interest of interests) {
|
||||
if (map[interest.pipelineStage]) {
|
||||
map[interest.pipelineStage]!.push(interest);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [interests]);
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
// over.id is a stage when dropped on a column, or an item id when dropped on a card
|
||||
let newStage = over.id as string;
|
||||
|
||||
// If dropped on a card (not a stage), find which stage that card belongs to
|
||||
if (!PIPELINE_STAGES.includes(newStage as typeof PIPELINE_STAGES[number])) {
|
||||
const targetInterest = interests.find((i) => i.id === newStage);
|
||||
if (!targetInterest) return;
|
||||
newStage = targetInterest.pipelineStage;
|
||||
}
|
||||
|
||||
const interestId = active.id as string;
|
||||
const currentInterest = interests.find((i) => i.id === interestId);
|
||||
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueryData<{ data: InterestRow[] }>(
|
||||
['interests-board', portSlug],
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((i) =>
|
||||
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ pipelineStage: newStage }),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
||||
} catch {
|
||||
// Revert optimistic update
|
||||
queryClient.invalidateQueries({ queryKey: ['interests-board', portSlug] });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{PIPELINE_STAGES.map((stage) => (
|
||||
<PipelineColumn
|
||||
key={stage}
|
||||
stage={stage}
|
||||
label={STAGE_LABELS[stage] ?? stage}
|
||||
items={grouped[stage] ?? []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
68
src/components/interests/pipeline-card.tsx
Normal file
68
src/components/interests/pipeline-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface PipelineCardProps {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
leadCategory: string | null;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
const LEAD_CATEGORY_COLORS: Record<string, string> = {
|
||||
general_interest: 'secondary',
|
||||
specific_qualified: 'default',
|
||||
hot_lead: 'destructive',
|
||||
};
|
||||
|
||||
export function PipelineCard({
|
||||
id,
|
||||
clientName,
|
||||
berthMooringNumber,
|
||||
leadCategory,
|
||||
updatedAt,
|
||||
}: PipelineCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="bg-card border rounded-md p-3 shadow-sm cursor-grab active:cursor-grabbing space-y-2"
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
|
||||
|
||||
{berthMooringNumber && (
|
||||
<p className="text-xs text-muted-foreground">Berth: {berthMooringNumber}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{leadCategory && (
|
||||
<Badge variant={(LEAD_CATEGORY_COLORS[leadCategory] as any) ?? 'secondary'}>
|
||||
{leadCategory.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{daysInStage}d
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/interests/pipeline-column.tsx
Normal file
63
src/components/interests/pipeline-column.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
|
||||
import { PipelineCard } from '@/components/interests/pipeline-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ColumnItem {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
leadCategory: string | null;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
interface PipelineColumnProps {
|
||||
stage: string;
|
||||
label: string;
|
||||
items: ColumnItem[];
|
||||
}
|
||||
|
||||
export function PipelineColumn({ stage, label, items }: PipelineColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: stage });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex flex-col gap-2 min-w-[220px] w-[220px] flex-shrink-0 bg-muted/40 rounded-lg p-3 transition-colors ${
|
||||
isOver ? 'bg-muted/70 ring-2 ring-primary/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-semibold capitalize">{label}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 overflow-y-auto max-h-[calc(100vh-220px)]">
|
||||
<SortableContext
|
||||
items={items.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<PipelineCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
clientName={item.clientName}
|
||||
berthMooringNumber={item.berthMooringNumber}
|
||||
leadCategory={item.leadCategory}
|
||||
updatedAt={item.updatedAt}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">Empty</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/interests/recommendation-list.tsx
Normal file
130
src/components/interests/recommendation-list.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Sparkles, Link, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
berthId: string;
|
||||
matchScore: string | null;
|
||||
matchReasons: Record<string, number> | null;
|
||||
source: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
lengthFt: string | null;
|
||||
widthFt: string | null;
|
||||
draftFt: string | null;
|
||||
}
|
||||
|
||||
interface RecommendationListProps {
|
||||
interestId: string;
|
||||
}
|
||||
|
||||
export function RecommendationList({ interestId }: RecommendationListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ data: Recommendation[] }>({
|
||||
queryKey: ['interest-recommendations', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/recommendations`),
|
||||
});
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/recommendations/generate`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-recommendations', interestId] });
|
||||
},
|
||||
});
|
||||
|
||||
const recommendations = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI-scored berth recommendations based on yacht dimensions.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => generateMutation.mutate()}
|
||||
disabled={generateMutation.isPending}
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
Generate Recommendations
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-muted rounded animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : recommendations.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No recommendations yet. Click "Generate Recommendations" to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recommendations.map((rec) => {
|
||||
const score = rec.matchScore ? Math.round(parseFloat(rec.matchScore)) : 0;
|
||||
return (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="border rounded-lg p-3 flex items-center gap-4"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">{rec.mooringNumber}</span>
|
||||
{rec.area && (
|
||||
<span className="text-xs text-muted-foreground">{rec.area}</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={rec.source === 'ai' ? 'secondary' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{rec.source === 'ai' ? 'AI' : 'Manual'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{rec.matchScore && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-8">{score}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(rec.lengthFt || rec.widthFt) && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{rec.lengthFt && `${rec.lengthFt}ft length`}
|
||||
{rec.lengthFt && rec.widthFt && ' · '}
|
||||
{rec.widthFt && `${rec.widthFt}ft beam`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user