feat(ui): add reservation-list table component

This commit is contained in:
Matt Ciaccio
2026-04-24 14:18:11 +02:00
parent 64d7b5c765
commit 2111bb8b60

View File

@@ -0,0 +1,215 @@
'use client';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { EmptyState } from '@/components/shared/empty-state';
import { apiFetch } from '@/lib/api/client';
export interface ReservationRow {
id: string;
berthId: string;
portId: string;
clientId: string;
yachtId: string;
status: 'pending' | 'active' | 'ended' | 'cancelled';
startDate: string;
endDate: string | null;
tenureType: string;
contractFileId: string | null;
notes: string | null;
createdAt: string;
}
export interface ReservationListProps {
reservations: ReservationRow[];
showBerth?: boolean;
portSlug?: string;
emptyMessage?: string;
}
/**
* Renders a client's name as a link by fetching the client record.
* Uses TanStack Query cache for memoization of repeated clientId queries.
*/
function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const { data } = useQuery<{ fullName: string }>({
queryKey: ['clients', clientId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { fullName: string } }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${clientId}` as any}
className="text-primary hover:underline"
>
{data?.fullName ?? `Client ${clientId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a yacht's name as a link by fetching the yacht record.
*/
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
const { data } = useQuery<{ name: string }>({
queryKey: ['yachts', yachtId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { name: string } }>(`/api/v1/yachts/${yachtId}`).then((r) => r.data),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${yachtId}` as any}
className="text-primary hover:underline"
>
{data?.name ?? `Yacht ${yachtId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a berth's mooring number as a link by fetching the berth record.
*/
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
const { data } = useQuery<{ mooringNumber: string }>({
queryKey: ['berths', berthId, 'name-only'],
queryFn: () =>
apiFetch<{ data: { mooringNumber: string } }>(`/api/v1/berths/${berthId}`).then(
(r) => r.data,
),
});
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/berths/${berthId}` as any}
className="text-primary hover:underline"
>
{data?.mooringNumber ?? `Berth ${berthId.slice(0, 8)}`}
</Link>
);
}
/**
* Renders a status badge with appropriate color coding.
*/
function StatusBadge({ status }: { status: ReservationRow['status'] }) {
const colorMap: Record<ReservationRow['status'], string> = {
pending: 'bg-gray-100 text-gray-800',
active: 'bg-green-100 text-green-800',
ended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-red-100 text-red-800',
};
const color = colorMap[status];
const label = status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
{label}
</span>
);
}
/**
* Pretty-prints tenure type for display.
*/
function prettyTenure(tenureType: string): string {
const tenureMap: Record<string, string> = {
permanent: 'Permanent',
fixed_term: 'Fixed term',
seasonal: 'Seasonal',
};
return tenureMap[tenureType] ?? tenureType;
}
/**
* Formats a date range as "{startDate} → {endDate or 'ongoing'}".
*/
function formatDateRange(startDate: string, endDate: string | null): string {
const start = new Date(startDate).toLocaleDateString();
const end = endDate ? new Date(endDate).toLocaleDateString() : 'ongoing';
return `${start}${end}`;
}
export function ReservationList({
reservations,
showBerth = false,
portSlug: portSlugProp,
emptyMessage,
}: ReservationListProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = portSlugProp ?? routeParams?.portSlug ?? '';
if (reservations.length === 0) {
return (
<EmptyState title="No reservations" description={emptyMessage ?? 'No reservations yet.'} />
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{showBerth && <TableHead>Berth</TableHead>}
<TableHead>Client</TableHead>
<TableHead>Yacht</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Tenure</TableHead>
<TableHead>Status</TableHead>
<TableHead>Contract</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reservations.map((r) => (
<TableRow key={r.id}>
{showBerth && (
<TableCell>
<BerthLink berthId={r.berthId} portSlug={portSlug} />
</TableCell>
)}
<TableCell>
<ClientLink clientId={r.clientId} portSlug={portSlug} />
</TableCell>
<TableCell>
<YachtLink yachtId={r.yachtId} portSlug={portSlug} />
</TableCell>
<TableCell>{formatDateRange(r.startDate, r.endDate)}</TableCell>
<TableCell>{prettyTenure(r.tenureType)}</TableCell>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell>
{r.contractFileId ? (
// TODO: Confirm final file-download endpoint URL when available
<a
href={`/api/v1/files/${r.contractFileId}/download`}
className="text-primary hover:underline"
>
View contract
</a>
) : (
'—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}