feat(ui): yacht detail page with header, tabs, ownership history
Implements Task 5.3: server page passes yachtId to a client YachtDetail, which fetches via TanStack Query and renders the shared DetailLayout with Overview / Ownership History / Interests / Reservations / Notes / Tags tabs. Header shows name, dimensions, polymorphic owner link, status badge, and Edit / Transfer / Archive actions. Transfer is a stub dialog pending Task 5.5; Notes tab is a placeholder because NotesList does not yet support entityType='yachts'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { YachtDetail } from '@/components/yachts/yacht-detail';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
interface YachtDetailPageProps {
|
||||||
|
params: Promise<{ yachtId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function YachtDetailPage({ params }: YachtDetailPageProps) {
|
||||||
|
const { yachtId } = await params;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const currentUserId = session?.user?.id;
|
||||||
|
|
||||||
|
return <YachtDetail yachtId={yachtId} currentUserId={currentUserId} />;
|
||||||
|
}
|
||||||
263
src/components/yachts/yacht-detail-header.tsx
Normal file
263
src/components/yachts/yacht-detail-header.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Pencil, Archive, ArrowRightLeft } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface YachtDetailHeaderYacht {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtDetailHeaderProps {
|
||||||
|
yacht: YachtDetailHeaderYacht;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
retired: 'bg-gray-100 text-gray-800 border-gray-300',
|
||||||
|
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold Away',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OwnerLink({
|
||||||
|
portSlug,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
portSlug: string;
|
||||||
|
type: 'client' | 'company';
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const { data } = useQuery<{ fullName?: string; name?: string }>({
|
||||||
|
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: { fullName?: string; name?: string } }>(
|
||||||
|
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
|
||||||
|
).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = type === 'client' ? data?.fullName : data?.name;
|
||||||
|
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
href={href as any}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{label ?? `${type === 'client' ? 'Client' : 'Company'} ${id.slice(0, 8)}`}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
|
||||||
|
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
|
||||||
|
|
||||||
|
let summary: string | null = null;
|
||||||
|
if (parts.length > 0) {
|
||||||
|
summary = parts.join(' × ');
|
||||||
|
}
|
||||||
|
if (yacht.draftFt) {
|
||||||
|
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
|
|
||||||
|
const isArchived = !!yacht.archivedAt;
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: () => apiFetch(`/api/v1/yachts/${yacht.id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts', yacht.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['yachts'] });
|
||||||
|
toast.success('Yacht archived');
|
||||||
|
setArchiveOpen(false);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
router.push(`/${portSlug}/yachts` as any);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
toast.error(err.message || 'Failed to archive yacht');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dimensions = formatDimensions(yacht);
|
||||||
|
const statusLabel = STATUS_LABELS[yacht.status] ?? yacht.status;
|
||||||
|
const statusColor = STATUS_COLORS[yacht.status] ?? 'bg-muted text-muted-foreground border-muted';
|
||||||
|
|
||||||
|
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">{yacht.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusColor}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{isArchived && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Archived
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dimensions && <p className="text-muted-foreground mt-0.5 text-sm">{dimensions}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-sm text-muted-foreground">
|
||||||
|
<span>Owner:</span>
|
||||||
|
<OwnerLink
|
||||||
|
portSlug={portSlug}
|
||||||
|
type={yacht.currentOwnerType}
|
||||||
|
id={yacht.currentOwnerId}
|
||||||
|
/>
|
||||||
|
</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="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTransferOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
|
disabled={isArchived}
|
||||||
|
>
|
||||||
|
<Archive className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<YachtForm
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
yacht={{
|
||||||
|
id: yacht.id,
|
||||||
|
name: yacht.name,
|
||||||
|
hullNumber: yacht.hullNumber,
|
||||||
|
registration: yacht.registration,
|
||||||
|
flag: yacht.flag,
|
||||||
|
yearBuilt: yacht.yearBuilt,
|
||||||
|
builder: yacht.builder,
|
||||||
|
model: yacht.model,
|
||||||
|
hullMaterial: yacht.hullMaterial,
|
||||||
|
lengthFt: yacht.lengthFt,
|
||||||
|
widthFt: yacht.widthFt,
|
||||||
|
draftFt: yacht.draftFt,
|
||||||
|
lengthM: yacht.lengthM,
|
||||||
|
widthM: yacht.widthM,
|
||||||
|
draftM: yacht.draftM,
|
||||||
|
currentOwnerType: yacht.currentOwnerType,
|
||||||
|
currentOwnerId: yacht.currentOwnerId,
|
||||||
|
status: yacht.status,
|
||||||
|
notes: yacht.notes,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArchiveConfirmDialog
|
||||||
|
open={archiveOpen}
|
||||||
|
onOpenChange={setArchiveOpen}
|
||||||
|
entityName={yacht.name}
|
||||||
|
entityType="Yacht"
|
||||||
|
isArchived={isArchived}
|
||||||
|
onConfirm={() => {
|
||||||
|
archiveMutation.mutate();
|
||||||
|
}}
|
||||||
|
isLoading={archiveMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TODO(Task 5.5): Replace with real YachtTransferDialog component. */}
|
||||||
|
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer Ownership</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The yacht ownership transfer flow will be implemented in Task 5.5.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">
|
||||||
|
This stub will be replaced with a form that lets you pick a new owner, effective date,
|
||||||
|
reason, and notes — then calls{' '}
|
||||||
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
|
POST /api/v1/yachts/{'{id}'}/transfer
|
||||||
|
</code>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setTransferOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/yachts/yacht-detail.tsx
Normal file
67
src/components/yachts/yacht-detail.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { DetailLayout } from '@/components/shared/detail-layout';
|
||||||
|
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
|
||||||
|
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
|
||||||
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export interface YachtData {
|
||||||
|
id: string;
|
||||||
|
portId: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
currentOwnerType: 'client' | 'company';
|
||||||
|
currentOwnerId: string;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtDetailProps {
|
||||||
|
yachtId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
|
||||||
|
const { data, isLoading } = useQuery<YachtData>({
|
||||||
|
queryKey: ['yachts', yachtId],
|
||||||
|
queryFn: () => apiFetch<{ data: YachtData }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useRealtimeInvalidation({
|
||||||
|
'yacht:updated': [['yachts', yachtId]],
|
||||||
|
'yacht:archived': [['yachts', yachtId]],
|
||||||
|
'yacht:ownership_transferred': [
|
||||||
|
['yachts', yachtId],
|
||||||
|
['yachts', yachtId, 'ownership-history'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = data ? getYachtTabs({ yachtId, currentUserId, yacht: data }) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DetailLayout
|
||||||
|
header={data ? <YachtDetailHeader yacht={data} /> : null}
|
||||||
|
tabs={tabs}
|
||||||
|
defaultTab="overview"
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/yachts/yacht-ownership-history.tsx
Normal file
123
src/components/yachts/yacht-ownership-history.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface OwnershipHistoryRow {
|
||||||
|
id: string;
|
||||||
|
yachtId: string;
|
||||||
|
ownerType: 'client' | 'company';
|
||||||
|
ownerId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string | null;
|
||||||
|
transferReason: string | null;
|
||||||
|
transferNotes: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtOwnershipHistoryProps {
|
||||||
|
yachtId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REASON_LABELS: Record<string, string> = {
|
||||||
|
sale: 'Sale',
|
||||||
|
inheritance: 'Inheritance',
|
||||||
|
gift: 'Gift',
|
||||||
|
company_restructure: 'Company restructure',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function YachtOwnershipHistory({ yachtId }: YachtOwnershipHistoryProps) {
|
||||||
|
const params = useParams<{ portSlug: string }>();
|
||||||
|
const portSlug = params?.portSlug ?? '';
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<OwnershipHistoryRow[]>({
|
||||||
|
queryKey: ['yachts', yachtId, 'ownership-history'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ data: OwnershipHistoryRow[] }>(`/api/v1/yachts/${yachtId}/ownership-history`).then(
|
||||||
|
(r) => r.data,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="No ownership history"
|
||||||
|
description="This yacht's ownership transfers will appear here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Start Date</TableHead>
|
||||||
|
<TableHead>End Date</TableHead>
|
||||||
|
<TableHead>Owner</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>Notes</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell>{formatDate(row.startDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.endDate ? (
|
||||||
|
formatDate(row.endDate)
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OwnerLink portSlug={portSlug} type={row.ownerType} id={row.ownerId} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{row.transferReason
|
||||||
|
? (REASON_LABELS[row.transferReason] ?? row.transferReason)
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground max-w-[320px] truncate">
|
||||||
|
{row.transferNotes ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/components/yachts/yacht-tabs.tsx
Normal file
168
src/components/yachts/yacht-tabs.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { DetailTab } from '@/components/shared/detail-layout';
|
||||||
|
import { EmptyState } from '@/components/shared/empty-state';
|
||||||
|
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
|
||||||
|
|
||||||
|
interface YachtTabsYacht {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hullNumber: string | null;
|
||||||
|
registration: string | null;
|
||||||
|
flag: string | null;
|
||||||
|
yearBuilt: number | null;
|
||||||
|
builder: string | null;
|
||||||
|
model: string | null;
|
||||||
|
hullMaterial: string | null;
|
||||||
|
lengthFt: string | null;
|
||||||
|
widthFt: string | null;
|
||||||
|
draftFt: string | null;
|
||||||
|
lengthM: string | null;
|
||||||
|
widthM: string | null;
|
||||||
|
draftM: string | null;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YachtTabsOptions {
|
||||||
|
yachtId: string;
|
||||||
|
currentUserId?: string;
|
||||||
|
yacht: YachtTabsYacht;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value?: string | number | null }) {
|
||||||
|
if (value === null || value === undefined || 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
retired: 'Retired',
|
||||||
|
sold_away: 'Sold away',
|
||||||
|
};
|
||||||
|
|
||||||
|
function OverviewTab({ yacht }: { yacht: YachtTabsYacht }) {
|
||||||
|
const hasFtDimensions = yacht.lengthFt || yacht.widthFt || yacht.draftFt;
|
||||||
|
const hasMDimensions = yacht.lengthM || yacht.widthM || yacht.draftM;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Identity</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Name" value={yacht.name} />
|
||||||
|
<InfoRow label="Hull Number" value={yacht.hullNumber} />
|
||||||
|
<InfoRow label="Registration" value={yacht.registration} />
|
||||||
|
<InfoRow label="Flag" value={yacht.flag} />
|
||||||
|
<InfoRow label="Year Built" value={yacht.yearBuilt} />
|
||||||
|
<InfoRow label="Status" value={STATUS_LABELS[yacht.status] ?? yacht.status} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Build */}
|
||||||
|
{(yacht.builder || yacht.model || yacht.hullMaterial) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Build</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Builder" value={yacht.builder} />
|
||||||
|
<InfoRow label="Model" value={yacht.model} />
|
||||||
|
<InfoRow label="Hull Material" value={yacht.hullMaterial} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dimensions (ft) */}
|
||||||
|
{hasFtDimensions && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Length" value={yacht.lengthFt ? `${yacht.lengthFt} ft` : null} />
|
||||||
|
<InfoRow label="Width" value={yacht.widthFt ? `${yacht.widthFt} ft` : null} />
|
||||||
|
<InfoRow label="Draft" value={yacht.draftFt ? `${yacht.draftFt} ft` : null} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dimensions (m) */}
|
||||||
|
{hasMDimensions && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
|
||||||
|
<dl>
|
||||||
|
<InfoRow label="Length" value={yacht.lengthM ? `${yacht.lengthM} m` : null} />
|
||||||
|
<InfoRow label="Width" value={yacht.widthM ? `${yacht.widthM} m` : null} />
|
||||||
|
<InfoRow label="Draft" value={yacht.draftM ? `${yacht.draftM} m` : null} />
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{yacht.notes && (
|
||||||
|
<div className="space-y-1 md:col-span-2">
|
||||||
|
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap rounded-md border bg-muted/30 p-3">
|
||||||
|
{yacht.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getYachtTabs({
|
||||||
|
yachtId,
|
||||||
|
// currentUserId reserved for when NotesList supports entityType='yachts'.
|
||||||
|
currentUserId: _currentUserId,
|
||||||
|
yacht,
|
||||||
|
}: YachtTabsOptions): DetailTab[] {
|
||||||
|
void _currentUserId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
content: <OverviewTab yacht={yacht} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ownership-history',
|
||||||
|
label: 'Ownership History',
|
||||||
|
content: <YachtOwnershipHistory yachtId={yachtId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interests',
|
||||||
|
label: 'Interests',
|
||||||
|
content: <EmptyState title="Interests" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reservations',
|
||||||
|
label: 'Reservations',
|
||||||
|
content: <EmptyState title="Reservations" description="Coming soon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
label: 'Notes',
|
||||||
|
// TODO: NotesList currently supports entityType 'clients' | 'interests'.
|
||||||
|
// Extend NotesList (or swap to a yacht-notes endpoint) in a follow-up.
|
||||||
|
content: (
|
||||||
|
<EmptyState
|
||||||
|
title="Notes"
|
||||||
|
description="Yacht notes coming soon — the notes endpoint is pending wiring."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
// TODO: replace with an inline tag editor once one exists; yacht tags
|
||||||
|
// can be edited via the Edit form in the meantime.
|
||||||
|
content: (
|
||||||
|
<EmptyState title="Tags" description="Manage tags from the Edit yacht form for now." />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user