Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View 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>
),
},
];
}

View 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}
/>
</>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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',
},
];

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />,
},
];
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}