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:
Matt Ciaccio
2026-04-24 13:40:41 +02:00
parent a604223c17
commit 76d2348873
5 changed files with 637 additions and 0 deletions

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

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

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

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

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