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,5 @@
import { BerthReservationsList } from '@/components/reservations/berth-reservations-list';
export default function BerthReservationsPage() {
return <BerthReservationsList />;
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listReservations } from '@/lib/services/berth-reservations.service';
import { listReservationsSchema } from '@/lib/validators/reservations';
export const GET = withAuth(
withPermission('reservations', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listReservationsSchema);
const result = await listReservations(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DetailLayout } from '@/components/shared/detail-layout'; import { DetailLayout } from '@/components/shared/detail-layout';
@@ -8,6 +9,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader } from './berth-detail-header'; import { BerthDetailHeader } from './berth-detail-header';
import { BerthForm } from './berth-form';
import { buildBerthTabs } from './berth-tabs'; import { buildBerthTabs } from './berth-tabs';
interface BerthDetailProps { interface BerthDetailProps {
@@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
return () => setChrome({ title: null, showBackButton: false }); return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]); }, [titleForChrome, setChrome]);
// Auto-open edit sheet when ?edit=true is present in the URL
const searchParams = useSearchParams();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
if (searchParams.get('edit') === 'true') {
setEditOpen(true);
// Strip the param without adding a history entry
const params = new URLSearchParams(searchParams.toString());
params.delete('edit');
const newUrl = params.toString() ? `?${params.toString()}` : window.location.pathname;
// typedRoutes can't statically validate this dynamic path; cast is safe
// because we're always replacing within the same route segment.
router.replace(newUrl as never);
}
// Only run once on mount / when searchParams changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const berth = data as any; const berth = data as any;
return ( return (
<>
<DetailLayout <DetailLayout
isLoading={isLoading} isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null} header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth) : []} tabs={berth ? buildBerthTabs(berth) : []}
defaultTab="overview" defaultTab="overview"
/> />
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}
</>
); );
} }

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'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Bell, Download, FileSignature, Mail } from 'lucide-react'; import { ArrowLeft, Bell, Download, FileSignature, Mail, StopCircle } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; 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 { PageHeader } from '@/components/shared/page-header';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { ClientLink, YachtLink, BerthLink } from '@/components/reservations/reservation-list';
interface ReservationDoc { interface ReservationDoc {
id: string; id: string;
@@ -42,12 +53,77 @@ const RESERVATION_PILL: Record<string, StatusPillStatus> = {
cancelled: 'cancelled', 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 { interface ReservationDetailProps {
reservationId: string; reservationId: string;
portSlug: string; portSlug: string;
} }
export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) { export function ReservationDetail({ reservationId, portSlug }: ReservationDetailProps) {
const [endDialogOpen, setEndDialogOpen] = useState(false);
const reservation = useQuery<{ data: ReservationData }>({ const reservation = useQuery<{ data: ReservationData }>({
queryKey: ['reservation', reservationId], queryKey: ['reservation', reservationId],
queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`), queryFn: () => apiFetch(`/api/v1/berth-reservations/${reservationId}`),
@@ -215,11 +291,19 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</> </>
} }
actions={ actions={
<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"> <Button asChild variant="outline">
<Link href={`/${portSlug}/berths`}> <Link href={`/${portSlug}/berths`}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths <ArrowLeft className="mr-1.5 h-4 w-4" /> Back to berths
</Link> </Link>
</Button> </Button>
</div>
} }
variant="gradient" variant="gradient"
/> />
@@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
<dl className="grid grid-cols-2 gap-3 text-sm"> <dl className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<dt className="text-xs text-muted-foreground">Berth</dt> <dt className="text-xs text-muted-foreground">Berth</dt>
<dd> <dd className="font-medium">
<Link <BerthLink berthId={res.berthId} portSlug={portSlug} />
href={`/${portSlug}/berths/${res.berthId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.berthId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground">Yacht</dt> <dt className="text-xs text-muted-foreground">Yacht</dt>
<dd> <dd className="font-medium">
<Link <YachtLink yachtId={res.yachtId} portSlug={portSlug} />
href={`/${portSlug}/yachts/${res.yachtId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.yachtId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-xs text-muted-foreground">Client</dt> <dt className="text-xs text-muted-foreground">Client</dt>
<dd> <dd className="font-medium">
<Link <ClientLink clientId={res.clientId} portSlug={portSlug} />
href={`/${portSlug}/clients/${res.clientId}` as Route}
className="font-medium text-brand hover:underline"
>
{res.clientId.slice(0, 8)}
</Link>
</dd> </dd>
</div> </div>
<div> <div>
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
</section> </section>
</div> </div>
</div> </div>
<EndReservationDialog
reservationId={reservationId}
open={endDialogOpen}
onOpenChange={setEndDialogOpen}
/>
</div> </div>
); );
} }

View File

@@ -41,7 +41,7 @@ export interface ReservationListProps {
* Renders a client's name as a link by fetching the client record. * Renders a client's name as a link by fetching the client record.
* Uses TanStack Query cache for memoization of repeated clientId queries. * 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 }>({ const { data } = useQuery<{ fullName: string }>({
queryKey: ['clients', clientId, 'name-only'], queryKey: ['clients', clientId, 'name-only'],
queryFn: () => 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. * 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 }>({ const { data } = useQuery<{ name: string }>({
queryKey: ['yachts', yachtId, 'name-only'], queryKey: ['yachts', yachtId, 'name-only'],
queryFn: () => 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. * 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 }>({ const { data } = useQuery<{ mooringNumber: string }>({
queryKey: ['berths', berthId, 'name-only'], queryKey: ['berths', berthId, 'name-only'],
queryFn: () => queryFn: () =>

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import type { DetailTab } from '@/components/shared/detail-layout'; import type { DetailTab } from '@/components/shared/detail-layout';
import { EmptyState } from '@/components/shared/empty-state';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ReservationList, type ReservationRow } from '@/components/reservations/reservation-list';
import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history'; import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-history';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -206,6 +207,70 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
); );
} }
function YachtInterestsTab({ yachtId }: { yachtId: string }) {
const { data, isLoading } = useQuery<{
data: Array<{
id: string;
pipelineStage: string;
clientName: string | null;
berthMooringNumber: string | null;
updatedAt: string;
}>;
}>({
queryKey: ['interests', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/interests?yachtId=${yachtId}&limit=50&order=desc`),
});
const interests = data?.data ?? [];
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
if (interests.length === 0) {
return <p className="text-sm text-muted-foreground">No interests linked to this yacht.</p>;
}
return (
<ul className="space-y-2">
{interests.map((i) => (
<li
key={i.id}
className="flex items-center gap-3 rounded-md border bg-muted/30 p-3 text-sm"
>
<span className="w-36 shrink-0 text-xs font-medium uppercase text-muted-foreground">
{i.pipelineStage.replace(/_/g, ' ')}
</span>
<span className="flex-1 truncate">{i.clientName ?? '—'}</span>
{i.berthMooringNumber && (
<span className="shrink-0 text-xs text-muted-foreground">
Berth {i.berthMooringNumber}
</span>
)}
</li>
))}
</ul>
);
}
function YachtReservationsTab({ yachtId }: { yachtId: string }) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const { data, isLoading } = useQuery<{ data: ReservationRow[] }>({
queryKey: ['berth-reservations', 'by-yacht', yachtId],
queryFn: () => apiFetch(`/api/v1/berth-reservations?yachtId=${yachtId}&limit=50&order=desc`),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading</p>;
return (
<ReservationList
reservations={data?.data ?? []}
showBerth
portSlug={portSlug}
emptyMessage="No reservations for this yacht."
/>
);
}
export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] { export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] {
return [ return [
{ {
@@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
{ {
id: 'interests', id: 'interests',
label: 'Interests', label: 'Interests',
content: <EmptyState title="Interests" description="Coming soon" />, content: <YachtInterestsTab yachtId={yachtId} />,
}, },
{ {
id: 'reservations', id: 'reservations',
label: 'Reservations', label: 'Reservations',
content: <EmptyState title="Reservations" description="Coming soon" />, content: <YachtReservationsTab yachtId={yachtId} />,
}, },
{ {
id: 'notes', id: 'notes',

View File

@@ -27,9 +27,11 @@ export async function getKpis(portId: string) {
.from(interests) .from(interests)
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest)); .where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest));
// Pipeline value: SUM berths.price via JOIN from non-archived interests with berthId // Pipeline value: SUM each berth's price ONCE regardless of how many active
// interests reference it. A berth with multiple interests would otherwise be
// counted multiple times, inflating the total.
const pipelineRows = await db const pipelineRows = await db
.select({ price: berths.price }) .selectDistinct({ berthId: interests.berthId, price: berths.price })
.from(interests) .from(interests)
.innerJoin(berths, eq(interests.berthId, berths.id)) .innerJoin(berths, eq(interests.berthId, berths.id))
.where( .where(