feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count

- End-reservation: API handler existed but had no UI surface. Adds an
  "End reservation" button + date dialog on the reservation detail page,
  visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
  `[portSlug]/berth-reservations` page so users can see all reservations
  across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
  never read the param — it now auto-opens the edit sheet on mount and
  strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
  / Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
  stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
  summed per active interest, so a berth with three open interests was
  counted three times. Switched to `selectDistinct(berthId, price)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 23:01:15 +02:00
parent e3e0e69c04
commit a391934b73
8 changed files with 301 additions and 44 deletions

View File

@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { PageHeader } from '@/components/shared/page-header';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { apiFetch } from '@/lib/api/client';
interface ReservationsApiResponse {
data: ReservationRow[];
pagination: { total: number; page: number; pageSize: number };
}
export function BerthReservationsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ReservationsApiResponse>({
queryKey: ['berth-reservations', 'list'],
queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'),
});
return (
<div className="flex flex-col gap-6">
<PageHeader
eyebrow="Marina"
title="Berth Reservations"
description="All reservations across all berths"
actions={
<Link
href={`/${portSlug}/berths`}
className="text-sm text-muted-foreground hover:text-foreground"
>
View berths
</Link>
}
/>
{isLoading ? (
<TableSkeleton />
) : (
<ReservationList
reservations={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No reservations found."
/>
)}
</div>
);
}

View File

@@ -1,17 +1,28 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PageHeader } from '@/components/shared/page-header';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
interface ReservationDoc {
id: string;
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
cancelled: 'cancelled',
};
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
interface EndReservationDialogProps {
reservationId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function EndReservationDialog({ reservationId, open, onOpenChange }: EndReservationDialogProps) {
const qc = useQueryClient();
const [endDate, setEndDate] = useState(todayIso);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
try {
await apiFetch(`/api/v1/berth-reservations/${reservationId}`, {
method: 'PATCH',
body: { action: 'end', endDate },
});
qc.invalidateQueries({ queryKey: ['reservation', reservationId] });
toast.success('Reservation ended');
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to end reservation');
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>End reservation</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="end-date">End date</Label>
<Input
id="end-date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={submitting}>
{submitting ? 'Ending…' : 'End reservation'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
interface ReservationDetailProps {
reservationId: string;
portSlug: string;
}
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
const [endDialogOpen, setEndDialogOpen] = useState(false);
const reservation = useQuery<{ data: ReservationData }>({
queryKey: ['reservation', reservationId],
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</>
}
actions={
<Button asChild variant="outline">
<Link href={`/${portSlug}/berths`}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
</Link>
</Button>
<div className="flex items-center gap-2">
{res.status === 'active' && (
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
<StopCircle className="mr-1.5 h-4 w-4" />
End reservation
</Button>
)}
<Button asChild variant="outline">
<Link href={`/${portSlug}/berths`}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
</Link>
</Button>
</div>
}
variant="gradient"
/>
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
<dl className="grid grid-cols-2 gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Berth</dt>
<dd>
<Link
href={`/${portSlug}/berths/${res.berthId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.berthId.slice(0, 8)}
</Link>
<dd className="font-medium">
<BerthLink berthId={res.berthId} portSlug={portSlug} />
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Yacht</dt>
<dd>
<Link
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.yachtId.slice(0, 8)}
</Link>
<dd className="font-medium">
<YachtLink yachtId={res.yachtId} portSlug={portSlug} />
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Client</dt>
<dd>
<Link
href={`/${portSlug}/clients/${res.clientId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.clientId.slice(0, 8)}
</Link>
<dd className="font-medium">
<ClientLink clientId={res.clientId} portSlug={portSlug} />
</dd>
</div>
<div>
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</section>
</div>
</div>
<EndReservationDialog
reservationId={reservationId}
open={endDialogOpen}
onOpenChange={setEndDialogOpen}
/>
</div>
);
}

View File

@@ -41,7 +41,7 @@ export interface ReservationListProps {
* 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 }) {
export function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string }) {
const { data } = useQuery<{ fullName: string }>({
queryKey: ['clients', clientId, 'name-only'],
queryFn: () =>
@@ -62,7 +62,7 @@ function ClientLink({ clientId, portSlug }: { clientId: string; portSlug: string
/**
* Renders a yacht's name as a link by fetching the yacht record.
*/
function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
export function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string }) {
const { data } = useQuery<{ name: string }>({
queryKey: ['yachts', yachtId, 'name-only'],
queryFn: () =>
@@ -83,7 +83,7 @@ function YachtLink({ yachtId, portSlug }: { yachtId: string; portSlug: string })
/**
* Renders a berth's mooring number as a link by fetching the berth record.
*/
function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
export function BerthLink({ berthId, portSlug }: { berthId: string; portSlug: string }) {
const { data } = useQuery<{ mooringNumber: string }>({
queryKey: ['berths', berthId, 'name-only'],
queryFn: () =>