feat(ui): add reservation-list table component
This commit is contained in:
215
src/components/reservations/reservation-list.tsx
Normal file
215
src/components/reservations/reservation-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user