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:
164
src/components/clients/client-columns.tsx
Normal file
164
src/components/clients/client-columns.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'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 ClientRow {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName: string | null;
|
||||
source: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
interface GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (client: ClientRow) => void;
|
||||
onArchive: (client: ClientRow) => void;
|
||||
}
|
||||
|
||||
export function getClientColumns({
|
||||
portSlug,
|
||||
onEdit,
|
||||
onArchive,
|
||||
}: GetColumnsOptions): ColumnDef<ClientRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'fullName',
|
||||
accessorKey: 'fullName',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${portSlug}/clients/${row.original.id}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.fullName}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'companyName',
|
||||
accessorKey: 'companyName',
|
||||
header: 'Company',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'primaryContact',
|
||||
header: 'Primary Contact',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const primary = row.original.contacts?.find((c) => c.isPrimary);
|
||||
if (!primary) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="text-sm">
|
||||
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
|
||||
{primary.value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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="capitalize text-xs">
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const clientTags = row.original.tags ?? [];
|
||||
if (clientTags.length === 0) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clientTags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
{clientTags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{clientTags.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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
185
src/components/clients/client-detail-header.tsx
Normal file
185
src/components/clients/client-detail-header.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
|
||||
|
||||
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 { ClientForm } from '@/components/clients/client-form';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ClientDetailHeaderProps {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName?: string | null;
|
||||
nationality?: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyType?: string | null;
|
||||
actualOwnerName?: string | null;
|
||||
yachtName?: string | null;
|
||||
berthSizeDesired?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
archivedAt?: string | null;
|
||||
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
website: 'Website',
|
||||
manual: 'Manual',
|
||||
referral: 'Referral',
|
||||
broker: 'Broker',
|
||||
};
|
||||
|
||||
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
const isArchived = !!client.archivedAt;
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setArchiveOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setArchiveOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const primaryContact = client.contacts?.find((c) => c.isPrimary);
|
||||
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)
|
||||
?? client.contacts?.find((c) => c.channel === 'email');
|
||||
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary)
|
||||
?? client.contacts?.find((c) => c.channel === 'phone');
|
||||
|
||||
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 truncate">
|
||||
{client.fullName}
|
||||
</h1>
|
||||
{isArchived && (
|
||||
<Badge variant="secondary" className="text-xs">Archived</Badge>
|
||||
)}
|
||||
{client.isProxy && (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{client.companyName && (
|
||||
<p className="text-muted-foreground mt-0.5">{client.companyName}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
|
||||
{client.source && (
|
||||
<span>
|
||||
Source:{' '}
|
||||
<span className="text-foreground">
|
||||
{SOURCE_LABELS[client.source] ?? client.source}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{primaryEmail && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
{primaryEmail.value}
|
||||
</span>
|
||||
)}
|
||||
{primaryPhone && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{primaryPhone.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{client.tags.map((tag) => (
|
||||
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant={isArchived ? 'outline' : '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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClientForm
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
client={client as any}
|
||||
/>
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={archiveOpen}
|
||||
onOpenChange={setArchiveOpen}
|
||||
entityName={client.fullName}
|
||||
entityType="Client"
|
||||
isArchived={isArchived}
|
||||
onConfirm={() => {
|
||||
if (isArchived) {
|
||||
restoreMutation.mutate();
|
||||
} else {
|
||||
archiveMutation.mutate();
|
||||
}
|
||||
}}
|
||||
isLoading={archiveMutation.isPending || restoreMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
src/components/clients/client-detail.tsx
Normal file
82
src/components/clients/client-detail.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
|
||||
import { getClientTabs } from '@/components/clients/client-tabs';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface ClientData {
|
||||
id: string;
|
||||
portId: string;
|
||||
fullName: string;
|
||||
companyName: string | null;
|
||||
nationality: string | null;
|
||||
isProxy: boolean;
|
||||
proxyType: string | null;
|
||||
actualOwnerName: string | null;
|
||||
yachtName: string | null;
|
||||
yachtLengthFt: string | null;
|
||||
yachtWidthFt: string | null;
|
||||
yachtDraftFt: string | null;
|
||||
yachtLengthM: string | null;
|
||||
yachtWidthM: string | null;
|
||||
yachtDraftM: string | null;
|
||||
berthSizeDesired: string | null;
|
||||
preferredContactMethod: string | null;
|
||||
preferredLanguage: string | null;
|
||||
timezone: string | null;
|
||||
source: string | null;
|
||||
sourceDetails: string | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
contacts: Array<{
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
label: string | null;
|
||||
isPrimary: boolean;
|
||||
notes: string | null;
|
||||
}>;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ClientDetailProps {
|
||||
clientId: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
|
||||
const { data, isLoading } = useQuery<ClientData>({
|
||||
queryKey: ['clients', clientId],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'client:updated': [['clients', clientId]],
|
||||
'client:archived': [['clients', clientId]],
|
||||
'client:restored': [['clients', clientId]],
|
||||
});
|
||||
|
||||
const tabs = data
|
||||
? getClientTabs({ clientId, currentUserId, client: data })
|
||||
: [];
|
||||
|
||||
return (
|
||||
<DetailLayout
|
||||
header={data ? <ClientDetailHeader client={data} /> : null}
|
||||
tabs={tabs}
|
||||
defaultTab="overview"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
88
src/components/clients/client-files-tab.tsx
Normal file
88
src/components/clients/client-files-tab.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'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 ClientFilesTabProps {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow>({
|
||||
queryKey: ['files', { clientId }],
|
||||
endpoint: `/api/v1/files?clientId=${encodeURIComponent(clientId)}`,
|
||||
filterDefinitions: [],
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'file:uploaded': [['files', { clientId }]],
|
||||
'file:updated': [['files', { clientId }]],
|
||||
'file:deleted': [['files', { clientId }]],
|
||||
});
|
||||
|
||||
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', { clientId }] });
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
clientId={clientId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
37
src/components/clients/client-filters.tsx
Normal file
37
src/components/clients/client-filters.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FilterDefinition } from '@/components/shared/filter-bar';
|
||||
|
||||
export const clientFilterDefinitions: FilterDefinition[] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: 'Search',
|
||||
type: 'text',
|
||||
placeholder: 'Search by name or company...',
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Source',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Website', value: 'website' },
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Referral', value: 'referral' },
|
||||
{ label: 'Broker', value: 'broker' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'nationality',
|
||||
label: 'Nationality',
|
||||
type: 'text',
|
||||
placeholder: 'Filter by nationality...',
|
||||
},
|
||||
{
|
||||
key: 'isProxy',
|
||||
label: 'Proxy Client',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
key: 'includeArchived',
|
||||
label: 'Include Archived',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
436
src/components/clients/client-form.tsx
Normal file
436
src/components/clients/client-form.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
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 { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
|
||||
interface ClientFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** If provided, form is in edit mode */
|
||||
client?: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName?: string | null;
|
||||
nationality?: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyType?: string | null;
|
||||
actualOwnerName?: string | null;
|
||||
yachtName?: string | null;
|
||||
berthSizeDesired?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
contacts?: Array<{
|
||||
channel: string;
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary?: boolean;
|
||||
}>;
|
||||
tags?: Array<{ id: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!client;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateClientInput>({
|
||||
resolver: zodResolver(createClientSchema),
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||
const isProxy = watch('isProxy');
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (client && open) {
|
||||
reset({
|
||||
fullName: client.fullName,
|
||||
companyName: client.companyName ?? undefined,
|
||||
nationality: client.nationality ?? undefined,
|
||||
isProxy: client.isProxy ?? false,
|
||||
proxyType: client.proxyType ?? undefined,
|
||||
actualOwnerName: client.actualOwnerName ?? undefined,
|
||||
yachtName: client.yachtName ?? undefined,
|
||||
berthSizeDesired: client.berthSizeDesired ?? undefined,
|
||||
preferredContactMethod: (client.preferredContactMethod as any) ?? undefined,
|
||||
preferredLanguage: client.preferredLanguage ?? undefined,
|
||||
timezone: client.timezone ?? undefined,
|
||||
source: (client.source as any) ?? undefined,
|
||||
sourceDetails: client.sourceDetails ?? undefined,
|
||||
contacts:
|
||||
client.contacts && client.contacts.length > 0
|
||||
? client.contacts.map((c) => ({
|
||||
channel: c.channel as any,
|
||||
value: c.value,
|
||||
label: c.label ?? undefined,
|
||||
isPrimary: c.isPrimary ?? false,
|
||||
}))
|
||||
: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
tagIds: client.tags?.map((t) => t.id) ?? [],
|
||||
});
|
||||
} else if (!client && open) {
|
||||
reset({
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
});
|
||||
}
|
||||
}, [client, open, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateClientInput) => {
|
||||
if (isEdit) {
|
||||
const { contacts, tagIds: tIds, ...rest } = data;
|
||||
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
|
||||
if (tIds) {
|
||||
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tagIds: tIds },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
||||
className="space-y-6 py-6"
|
||||
>
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Full Name *</Label>
|
||||
<Input {...register('fullName')} placeholder="John Smith" />
|
||||
{errors.fullName && (
|
||||
<p className="text-xs text-destructive">{errors.fullName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Company Name</Label>
|
||||
<Input {...register('companyName')} placeholder="Acme Corp" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Input {...register('nationality')} placeholder="British" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Contacts
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ channel: 'email', value: '', isPrimary: false })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors.contacts?.root && (
|
||||
<p className="text-xs text-destructive">{errors.contacts.root.message}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(`contacts.${index}.channel`, v as any)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-8"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.label`)}
|
||||
className="h-8"
|
||||
placeholder="work"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center gap-1 pb-1">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) =>
|
||||
setValue(`contacts.${index}.isPrimary`, !!v)
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Primary</Label>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex justify-end pb-1">
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Proxy */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Proxy Information
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isProxy"
|
||||
checked={watch('isProxy')}
|
||||
onCheckedChange={(v) => setValue('isProxy', !!v)}
|
||||
/>
|
||||
<Label htmlFor="isProxy">This is a proxy client</Label>
|
||||
</div>
|
||||
|
||||
{isProxy && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select
|
||||
value={watch('proxyType') ?? ''}
|
||||
onValueChange={(v) => setValue('proxyType', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
<SelectItem value="representative">Representative</SelectItem>
|
||||
<SelectItem value="family_member">Family Member</SelectItem>
|
||||
<SelectItem value="legal_counsel">Legal Counsel</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Actual Owner Name</Label>
|
||||
<Input
|
||||
{...register('actualOwnerName')}
|
||||
placeholder="Actual owner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Yacht Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Yacht Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Yacht Name</Label>
|
||||
<Input {...register('yachtName')} placeholder="My Yacht" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Berth Size Desired</Label>
|
||||
<Input {...register('berthSizeDesired')} placeholder="e.g. 30m" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Source & Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Source & Preferences
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
value={watch('source') ?? ''}
|
||||
onValueChange={(v) => setValue('source', v as any)}
|
||||
>
|
||||
<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 className="space-y-1">
|
||||
<Label>Preferred Contact Method</Label>
|
||||
<Select
|
||||
value={watch('preferredContactMethod') ?? ''}
|
||||
onValueChange={(v) => setValue('preferredContactMethod', v as any)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preferred Language</Label>
|
||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Timezone</Label>
|
||||
<Input {...register('timezone')} placeholder="UTC+0" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
<Input
|
||||
{...register('sourceDetails')}
|
||||
placeholder="Referred by John Doe"
|
||||
/>
|
||||
</div>
|
||||
</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 Client'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
155
src/components/clients/client-list.tsx
Normal file
155
src/components/clients/client-list.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Plus } 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 { ClientForm } from '@/components/clients/client-form';
|
||||
import { clientFilterDefinitions } from '@/components/clients/client-filters';
|
||||
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
export function ClientList() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editClient, setEditClient] = useState<ClientRow | null>(null);
|
||||
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
isFetching,
|
||||
sort,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<ClientRow>({
|
||||
queryKey: ['clients'],
|
||||
endpoint: '/api/v1/clients',
|
||||
filterDefinitions: clientFilterDefinitions,
|
||||
});
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'client:created': [['clients']],
|
||||
'client:updated': [['clients']],
|
||||
'client:archived': [['clients']],
|
||||
'client:restored': [['clients']],
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/clients/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setArchiveClient(null);
|
||||
},
|
||||
});
|
||||
|
||||
const columns = getClientColumns({
|
||||
portSlug,
|
||||
onEdit: (client) => setEditClient(client),
|
||||
onArchive: (client) => setArchiveClient(client),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Manage your client records"
|
||||
actions={
|
||||
<PermissionGate resource="clients" action="create">
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Client
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<FilterBar
|
||||
filters={clientFilterDefinitions}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
<SavedViewsDropdown
|
||||
entityType="clients"
|
||||
currentFilters={filters}
|
||||
currentSort={sort}
|
||||
onApplyView={(savedFilters, savedSort) => {
|
||||
clearFilters();
|
||||
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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 clients found"
|
||||
description="Get started by adding your first client."
|
||||
action={{ label: 'New Client', onClick: () => setCreateOpen(true) }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ClientForm
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
|
||||
{editClient && (
|
||||
<ClientForm
|
||||
open={!!editClient}
|
||||
onOpenChange={(open) => !open && setEditClient(null)}
|
||||
client={editClient as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ArchiveConfirmDialog
|
||||
open={!!archiveClient}
|
||||
onOpenChange={(open) => !open && setArchiveClient(null)}
|
||||
entityName={archiveClient?.fullName ?? ''}
|
||||
entityType="Client"
|
||||
isArchived={false}
|
||||
onConfirm={() => archiveClient && archiveMutation.mutate(archiveClient.id)}
|
||||
isLoading={archiveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/clients/client-tabs.tsx
Normal file
208
src/components/clients/client-tabs.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||
import { NotesList } from '@/components/shared/notes-list';
|
||||
|
||||
interface ClientTabsOptions {
|
||||
clientId: string;
|
||||
currentUserId?: string;
|
||||
client: {
|
||||
fullName: string;
|
||||
companyName?: string | null;
|
||||
nationality?: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyType?: string | null;
|
||||
actualOwnerName?: string | null;
|
||||
yachtName?: string | null;
|
||||
yachtLengthFt?: string | null;
|
||||
yachtWidthFt?: string | null;
|
||||
yachtDraftFt?: string | null;
|
||||
berthSizeDesired?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
contacts?: Array<{
|
||||
id: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary: boolean;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
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-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Personal Info */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
|
||||
<dl>
|
||||
<InfoRow label="Full Name" value={client.fullName} />
|
||||
<InfoRow label="Company" value={client.companyName} />
|
||||
<InfoRow label="Nationality" value={client.nationality} />
|
||||
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
|
||||
<InfoRow label="Timezone" value={client.timezone} />
|
||||
<InfoRow
|
||||
label="Preferred Contact"
|
||||
value={client.preferredContactMethod}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
|
||||
{client.contacts && client.contacts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{client.contacts.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
|
||||
>
|
||||
<span className="capitalize text-muted-foreground w-20 shrink-0">
|
||||
{c.channel}
|
||||
</span>
|
||||
<span className="flex-1">{c.value}</span>
|
||||
{c.label && (
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{c.label}
|
||||
</span>
|
||||
)}
|
||||
{c.isPrimary && (
|
||||
<span className="text-xs font-medium text-primary">Primary</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No contacts added</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Yacht Details */}
|
||||
{(client.yachtName ||
|
||||
client.yachtLengthFt ||
|
||||
client.berthSizeDesired) && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Yacht Details</h3>
|
||||
<dl>
|
||||
<InfoRow label="Yacht Name" value={client.yachtName} />
|
||||
<InfoRow
|
||||
label="Length"
|
||||
value={
|
||||
client.yachtLengthFt
|
||||
? `${client.yachtLengthFt} ft`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Width"
|
||||
value={
|
||||
client.yachtWidthFt ? `${client.yachtWidthFt} ft` : undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Draft"
|
||||
value={
|
||||
client.yachtDraftFt
|
||||
? `${client.yachtDraftFt} ft`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Berth Size Desired" value={client.berthSizeDesired} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source */}
|
||||
{(client.source || client.sourceDetails) && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Source</h3>
|
||||
<dl>
|
||||
<InfoRow label="Source" value={client.source} />
|
||||
<InfoRow label="Source Details" value={client.sourceDetails} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy Info */}
|
||||
{client.isProxy && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium mb-2">Proxy Information</h3>
|
||||
<dl>
|
||||
<InfoRow
|
||||
label="Proxy Type"
|
||||
value={client.proxyType?.replace('_', ' ')}
|
||||
/>
|
||||
<InfoRow label="Actual Owner" value={client.actualOwnerName} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getClientTabs({
|
||||
clientId,
|
||||
currentUserId,
|
||||
client,
|
||||
}: ClientTabsOptions): DetailTab[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab client={client} />,
|
||||
},
|
||||
{
|
||||
id: 'interests',
|
||||
label: 'Interests',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Interests will appear here once created.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
label: 'Notes',
|
||||
content: (
|
||||
<NotesList
|
||||
entityType="clients"
|
||||
entityId={clientId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>File attachments coming soon.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
label: 'Activity',
|
||||
content: (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>Activity log coming soon.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user