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:
54
src/components/reservations/berth-reservations-list.tsx
Normal file
54
src/components/reservations/berth-reservations-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user