From a391934b73d7011a1614f583fed57ebde5933899 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 23:01:15 +0200 Subject: [PATCH] feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../[portSlug]/berth-reservations/page.tsx | 5 + src/app/api/v1/berth-reservations/route.ts | 31 +++++ src/components/berths/berth-detail.tsx | 39 +++++- .../reservations/berth-reservations-list.tsx | 54 ++++++++ .../reservations/reservation-detail.tsx | 131 ++++++++++++++---- .../reservations/reservation-list.tsx | 6 +- src/components/yachts/yacht-tabs.tsx | 73 +++++++++- src/lib/services/dashboard.service.ts | 6 +- 8 files changed, 301 insertions(+), 44 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx create mode 100644 src/app/api/v1/berth-reservations/route.ts create mode 100644 src/components/reservations/berth-reservations-list.tsx diff --git a/src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx b/src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx new file mode 100644 index 0000000..26acaa8 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx @@ -0,0 +1,5 @@ +import { BerthReservationsList } from '@/components/reservations/berth-reservations-list'; + +export default function BerthReservationsPage() { + return ; +} diff --git a/src/app/api/v1/berth-reservations/route.ts b/src/app/api/v1/berth-reservations/route.ts new file mode 100644 index 0000000..626ea7d --- /dev/null +++ b/src/app/api/v1/berth-reservations/route.ts @@ -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); + } + }), +); diff --git a/src/components/berths/berth-detail.tsx b/src/components/berths/berth-detail.tsx index dd77b8c..686039c 100644 --- a/src/components/berths/berth-detail.tsx +++ b/src/components/berths/berth-detail.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; 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 { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { BerthDetailHeader } from './berth-detail-header'; +import { BerthForm } from './berth-form'; import { buildBerthTabs } from './berth-tabs'; interface BerthDetailProps { @@ -35,15 +37,38 @@ export function BerthDetail({ berthId }: BerthDetailProps) { return () => setChrome({ title: null, showBackButton: false }); }, [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 const berth = data as any; return ( - : null} - tabs={berth ? buildBerthTabs(berth) : []} - defaultTab="overview" - /> + <> + : null} + tabs={berth ? buildBerthTabs(berth) : []} + defaultTab="overview" + /> + {berth ? : null} + ); } diff --git a/src/components/reservations/berth-reservations-list.tsx b/src/components/reservations/berth-reservations-list.tsx new file mode 100644 index 0000000..e9668e2 --- /dev/null +++ b/src/components/reservations/berth-reservations-list.tsx @@ -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({ + queryKey: ['berth-reservations', 'list'], + queryFn: () => apiFetch('/api/v1/berth-reservations?page=1&limit=100&order=desc'), + }); + + return ( +
+ + View berths + + } + /> + + {isLoading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/components/reservations/reservation-detail.tsx b/src/components/reservations/reservation-detail.tsx index f089fb8..289de3d 100644 --- a/src/components/reservations/reservation-detail.tsx +++ b/src/components/reservations/reservation-detail.tsx @@ -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 = { 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 ( + + + + End reservation + +
+
+ + setEndDate(e.target.value)} + required + /> +
+ + + + +
+
+
+ ); +} + 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={ - +
+ {res.status === 'active' && ( + + )} + +
} variant="gradient" /> @@ -233,35 +317,20 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
Berth
-
- - {res.berthId.slice(0, 8)}… - +
+
Yacht
-
- - {res.yachtId.slice(0, 8)}… - +
+
Client
-
- - {res.clientId.slice(0, 8)}… - +
+
@@ -287,6 +356,12 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail
+ + ); } diff --git a/src/components/reservations/reservation-list.tsx b/src/components/reservations/reservation-list.tsx index 4f0ef49..a3e6e00 100644 --- a/src/components/reservations/reservation-list.tsx +++ b/src/components/reservations/reservation-list.tsx @@ -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: () => diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index 1608678..cd64af1 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -1,12 +1,13 @@ '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 { EmptyState } from '@/components/shared/empty-state'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; 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 { 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

Loading…

; + if (interests.length === 0) { + return

No interests linked to this yacht.

; + } + + return ( +
    + {interests.map((i) => ( +
  • + + {i.pipelineStage.replace(/_/g, ' ')} + + {i.clientName ?? '—'} + {i.berthMooringNumber && ( + + Berth {i.berthMooringNumber} + + )} +
  • + ))} +
+ ); +} + +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

Loading…

; + + return ( + + ); +} + export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions): DetailTab[] { return [ { @@ -221,12 +286,12 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions { id: 'interests', label: 'Interests', - content: , + content: , }, { id: 'reservations', label: 'Reservations', - content: , + content: , }, { id: 'notes', diff --git a/src/lib/services/dashboard.service.ts b/src/lib/services/dashboard.service.ts index 90ee163..1218f85 100644 --- a/src/lib/services/dashboard.service.ts +++ b/src/lib/services/dashboard.service.ts @@ -27,9 +27,11 @@ export async function getKpis(portId: string) { .from(interests) .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 - .select({ price: berths.price }) + .selectDistinct({ berthId: interests.berthId, price: berths.price }) .from(interests) .innerJoin(berths, eq(interests.berthId, berths.id)) .where(