diff --git a/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx b/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx new file mode 100644 index 0000000..e1ea071 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/yachts/[yachtId]/page.tsx @@ -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 ; +} diff --git a/src/components/yachts/yacht-detail-header.tsx b/src/components/yachts/yacht-detail-header.tsx new file mode 100644 index 0000000..5204538 --- /dev/null +++ b/src/components/yachts/yacht-detail-header.tsx @@ -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 = { + 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 = { + 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 ( + + {label ?? `${type === 'client' ? 'Client' : 'Company'} ${id.slice(0, 8)}`} + + ); +} + +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 ( + <> +
+
+
+
+

{yacht.name}

+ + {statusLabel} + + {isArchived && ( + + Archived + + )} +
+ + {dimensions &&

{dimensions}

} + +
+ Owner: + +
+
+ + {/* Actions */} +
+ + + +
+
+
+ + + + { + archiveMutation.mutate(); + }} + isLoading={archiveMutation.isPending} + /> + + {/* TODO(Task 5.5): Replace with real YachtTransferDialog component. */} + + + + Transfer Ownership + + The yacht ownership transfer flow will be implemented in Task 5.5. + + +
+ This stub will be replaced with a form that lets you pick a new owner, effective date, + reason, and notes — then calls{' '} + + POST /api/v1/yachts/{'{id}'}/transfer + + . +
+ + + +
+
+ + ); +} diff --git a/src/components/yachts/yacht-detail.tsx b/src/components/yachts/yacht-detail.tsx new file mode 100644 index 0000000..348d552 --- /dev/null +++ b/src/components/yachts/yacht-detail.tsx @@ -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({ + 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 ( + : null} + tabs={tabs} + defaultTab="overview" + isLoading={isLoading} + /> + ); +} diff --git a/src/components/yachts/yacht-ownership-history.tsx b/src/components/yachts/yacht-ownership-history.tsx new file mode 100644 index 0000000..7948b68 --- /dev/null +++ b/src/components/yachts/yacht-ownership-history.tsx @@ -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 = { + 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({ + queryKey: ['yachts', yachtId, 'ownership-history'], + queryFn: () => + apiFetch<{ data: OwnershipHistoryRow[] }>(`/api/v1/yachts/${yachtId}/ownership-history`).then( + (r) => r.data, + ), + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!data || data.length === 0) { + return ( + + ); + } + + return ( +
+ + + + Start Date + End Date + Owner + Reason + Notes + + + + {data.map((row) => ( + + {formatDate(row.startDate)} + + {row.endDate ? ( + formatDate(row.endDate) + ) : ( + + Current + + )} + + + + + + {row.transferReason + ? (REASON_LABELS[row.transferReason] ?? row.transferReason) + : '—'} + + + {row.transferNotes ?? '—'} + + + ))} + +
+
+ ); +} diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx new file mode 100644 index 0000000..26e17b6 --- /dev/null +++ b/src/components/yachts/yacht-tabs.tsx @@ -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 ( +
+
{label}
+
{value}
+
+ ); +} + +const STATUS_LABELS: Record = { + 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 ( +
+ {/* Identity */} +
+

Identity

+
+ + + + + + +
+
+ + {/* Build */} + {(yacht.builder || yacht.model || yacht.hullMaterial) && ( +
+

Build

+
+ + + +
+
+ )} + + {/* Dimensions (ft) */} + {hasFtDimensions && ( +
+

Dimensions (ft)

+
+ + + +
+
+ )} + + {/* Dimensions (m) */} + {hasMDimensions && ( +
+

Dimensions (m)

+
+ + + +
+
+ )} + + {/* Notes */} + {yacht.notes && ( +
+

Notes

+

+ {yacht.notes} +

+
+ )} +
+ ); +} + +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: , + }, + { + id: 'ownership-history', + label: 'Ownership History', + content: , + }, + { + id: 'interests', + label: 'Interests', + content: , + }, + { + id: 'reservations', + label: 'Reservations', + content: , + }, + { + 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: ( + + ), + }, + { + 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: ( + + ), + }, + ]; +}